Promote/demote all selected headers

Use case or problem

Sometimes I copy material from one note into another, or within a note. Because of its new place in the note, the headers need to be changed. E.g., in one place a sequence of headers might be:

A

A1

A2

B

B1

But once I copy this into a new section of note beginning

Lists starting with capital letters

I now need to “demote” everything:

Lists starting with capital letters

A

A1

A2

B

B1

Proposed solution

I would be great to be able to do this with a single hotkey rather than changign each heading manually.

Current workaround (optional)

I add/remove the #s manually for each line.

9 Likes

Hi @JAndrews2, this is a very fair feature request IMHO. Just like we can tab and shift+tab outlines, we should be able to do the same with headings.

While we are waiting for an official feature like this, I wrote a little script to do it in the meantime. I’m just beginning to learn some javascript, so this was a fun exercise.

Script to promote/demote all headings in selection
<%*
const direction = 1;
// 1: Promote headings (make them bigger)
// -1: Demote headings (make smaller)

const editor = app.workspace.activeLeaf.view.editor;
let selection = editor.getSelection();
if (selection=="") {
	// if there is no selection, set it to the current line instead
	let l = editor.getCursor().line;
	editor.setSelection({line: l, ch: 0}, {line: l, ch: 99});
	selection = editor.getSelection();
}

/* Because this script is being called from Templater, the selection is automatically replaced with the text of this script before being executed and consumed.  The next three lines reset the selection to what it was before execution. For some reason a timer of 0ms is sufficient for that. */
let cAnchor = editor.getCursor('anchor');
let cHead = editor.getCursor('head');
setTimeout(() => editor.setSelection(cAnchor, cHead), 0);

if (direction == 1)
	editor.replaceSelection(selection.replace(/^#(#+) /gm, "$1 "));
else if (direction == -1)
	editor.replaceSelection(selection.replace(/^#+ /gm, "#$&"));

%>

How to implement

  1. Install two plugins: Templater & Hotkeys for templates
  2. Create two new files in your template folder (specified in Templater settings)
  3. Copy and paste the above script into each of the two files, and changeconst direction to -1 in only one of them (that will be your “demote”)
  4. Enable those templates in “Hotkeys for templates” settings
  5. Assign hotkeys to the command via Obsidian settings > Hotkeys

You could also set these up as macro choices in QuickAdd. A little more complicated with QuickAdd, but then you could avoid the strange setTimeout(... 0) that I don't fully understand.

And if someone can improve my code, all the better!

Demo

Sorry my custom css may make it a little harder to see what’s going on.
promote-demote-headings

4 Likes

You are a miracle worker! Thank you so much!

My pleasure :slightly_smiling_face:

This would be a fantastic feature, and I’m eager to use something link this natively in Obsidian. Thanks for the workaround @scwunch !

2 Likes

Hi @scwunch, I have been following the instructions you have given above, so I installed the two plugins, pasted the script into the two files in the template folder, and they looked jammed without all the line breaks, but I went ahead and run the templates anyway, but it only replaced whatever selection of text with the actual content of that template(which is the script itself). Is there any setting I need to toggle for the template to run as commands?

Hi @Heisenberg,

Did you make sure to include the <%* and %> at the beginning and end respectively? How are you calling the script?

I love the solution by @scwunch. I didn’t know the Templater plugin before, this is an amazing solution and opens up unlimited possibilities.

At the same time, I just discovered another easier solution leveraging jglev/obsidian-apply-patterns-plugin: An Obsidian plugin for applying patterns of find and replace in succession. (github.com).

Basically, the plugin allows us to apply customized regex to highlighted text. This is convenient for some tasks.

Here are the steps to promote/demote all selected headers.

  • Install the plugin
  • Copy below code to clipboard
  • Click “Import patterns from clipboard” in Apply Patterns's option page.

Then enjoy the solution

  • highlight any text range
  • trigger “Apply Patterns: Apply patterns to selection”, you will see two commands “refactor: increase heading level” and “refactor: decrease heading level”
[
  {
    "name": "refactor: increase heading level",
    "rules": [
      {
        "from": "^#",
        "to": "##",
        "caseInsensitive": false,
        "global": true,
        "multiline": true,
        "sticky": false,
        "disabled": false
      }
    ],
    "collapsed": false,
    "cursorRegexStart": "$",
    "cursorRegexEnd": "^"
  },
  {
    "name": "refactor: decrease heading level",
    "rules": [
      {
        "from": "^#",
        "to": "",
        "caseInsensitive": false,
        "global": true,
        "multiline": true,
        "sticky": false,
        "disabled": false
      }
    ],
    "collapsed": false,
    "cursorRegexStart": "$",
    "cursorRegexEnd": "^"
  }
]
8 Likes

Brilliant! Just what the doctor ordered. Hopefully it gets baked in the core, one fine day. (He said, with an already-overflowing plate and a mouthful of food.)

1 Like

Another solution is to place multiple cursors (alt+click) on each header, go to the beginning of the line using the keyboard key Home and hit # to demote, or hit Delete to promote!

You can also create the multiple cursors with more ease using Advanced Cursors (Obsidian Store, GitHub).

3 Likes

and another another solution: use the plugin obsidian-linter:

in my case I head many H5s under one H4 which I had to change to a H3 (and with it all the belonging H5s).
I only had to change the H4 to H3 and the linter automatically changed all lower H5s to H4s.

That’s at least one solution to once case :slight_smile:

Brilliant, this was very easy to set up and did just what I needed! Thank you!

1 Like

This is the solution I use regularly: it is simple, no scripts needed (risk of issues developing), not difficult to remember. Thank you.

2 Likes

I met the same problem with @Heisenberg, when trigger the hotkey, my seleted text is changed by the content of the script, rather than fommating. I named two files like a.md and b.md, and copy your script into them, and then set the hotkeys.

This works fine to me, I am currently using it. The only problem that I met is my tag
which is something like #abc is also changed. I am trying to update the code to adapt this.

Hi
Thank you for this solution.
For some reason in my case, the code simply replace the heading (including all subheadings and content) by itself not the increase of decrease the # character.
I feel like the first part of the code work but not the second part.
PS: I am new to obsidian and running scripts inside .md.

I have a new-and-improved script now :slightly_smiling_face:

Features

  • can be used to set headings to specific level, or increase/decrease
  • multi-line: increase/decrease all headings in multi-line selection
  • “promote” defaults to heading level 3 if no heading in selection
  • preserve cursor position / selection

Script

/* Set the heading to the specified level.
 * If the level is negative, will decrease heading size by that much.
 * If the level is 0 or undefined, will increase the heading by one, or turn a non-heading into a level-3 heading
 */
function setHeading(level) {
	const editor = app.workspace.activeLeaf.view.editor;
	const cAnchor = editor.getCursor('anchor');
	const cHead = editor.getCursor('head');
	const top = {line: Math.min(cAnchor.line, cHead.line), ch: 0};
	const bottom = {line: Math.max(cAnchor.line, cHead.line), ch: Infinity};
	const lineRange = editor.getRange(top, bottom);

	let replacement = lineRange;

	if (!level) {
		// promote all headings in lineRange. If no heading in lineRange, promote all lines to h3
		if (!lineRange.match(/^#+ /m)) {
			replacement = lineRange.replace(/^/gm, "### ");
		}
		else { // otherwise promote by 1
			replacement = lineRange.replace(/^#(#+) /gm, "$1 ");
		}
	}
	else if (level < -1)
		console.error("Heading level of less than -1 is not supported, demoting heading(s) by one only.  Aborting.");
	else if (level == -1) {
		replacement = lineRange.replace(/^#+ /gm, "#$&");
	}
	else {
		// set lineRange to header level
		console.log(`set header to level: ${level}.`);
		if (!lineRange.match(/^#+ /m)) {
			console.log('no headings found, replacing all lines')
			replacement = lineRange.replace(/^/gm, "#".repeat(level)+" ");
		}
		else {
			console.log('replace certain headings')
			replacement = lineRange.replace(/^#+ (.*)/gm, "#".repeat(level) + " $1");
		}
	}

	editor.replaceRange(replacement, top, bottom);

	// adjust new selection
	let lines = lineRange.split('\n');
	let newLines = replacement.split('\n');
	[cAnchor, cHead].forEach(p => {
		let i = p.line - top.line;
		if (p.ch)
			p.ch += newLines[i].length - lines[i].length;
	});
	editor.setSelection(cAnchor, cHead);

	return;
}

module.exports = setHeading;

Instructions

  1. Install Templater
  2. Create a file called “setHeading.js” in your vault (I suggest putting it in a folder called “Scripts”)
    • copy and paste the content (Script) from above
    • you will have to use a text editor other than Obsidian to create the js file
  3. Create two markdown files in your templates folder with the following content:
    <%*
    tp.user.setHeading();
    return;
    %>
    
    and
    <%*
    tp.user.setHeading(-1);
    return;
    %>
    
    • you can also use numbers 1 – 6 to set headings to a specific level
  4. Name the files something like “Increase Headings” and “Decrease Headings”
  5. In Templater Settings, make sure you can see tp.user.setHeading in the User Script Functions section.
  6. Add a Template Hotkey for each of the templates you just created and set to your desired shortcuts in Obsidian Settings > Hotkeys
  7. Enjoy :slightly_smiling_face:
1 Like

There is also GitHub - k4a-l/obsidian-heading-shifter: Easily Shift and Change markdown headings.