Promote/demote all selected headers

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 !

3 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": "^"
  }
]
9 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:

1 Like

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:
4 Likes

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

3 Likes

Need this feature!
(I’m not really into the script idea…)

2 Likes

@Alicila See Azol’s comment above.

Using the method in the 1st paragraph works fine, I use it regularly, and you don’t need a script nor a plug-in.

I did an update to the plugin Smarter hotkeys and that’s doing this now.
toggle headers
I’m using ctrl 1 & ctrl 2 with the command increase / decrease heading level

5 Likes

Thanks. Exactly what I was looking for.