With this Templater script, I set out to create a quick and easy way to create embeds for a heading, block, multiple blocks or whole note with embeds that:
- Can be collapsed. (See feature request for this.)
- Show breadcrumb trail. (See feature request for this.)
- Don’t have gaps in-between them, unless separated by empty line. (Achieved via CSS snippet that can be found below. See relevant post for this.)
Demo
When run for selections Block 1
and Block 3
in:
The script copies block embeds, puts them under a callout, searches above for all parent headings (while ignoring non-parent headings), constructs a breadcrumb trail and places it in callout title:
The link alias clean
hides the embed border via a CSS snippet, which can be found below.
The advantage of this script over doing all this work manually is that it does all of this in one go.
The script can be run for
- Selection or cursor in single block.
- Single selection spanning multiple consecutive blocks.
- Multiple selections or cursors in different blocks (achieved by holding down Alt).
- Selection or cursor in heading.
- Whole note (if no selection and cursor on empty line)
What you need
- Templater installed and enabled.
- Text Transporter installed and enabled.
- The script below in your templates folder.
Templater script
Copy_Embeds_in_Callout.md (4.3 KB)
<%*
/**
* Template Name: Copy Embeds in Callout
* Description: Copies block embeds via Text Transporter command, puts them under callout, searches above for all parent headings (while ignoring non-parent headings), constructs breadcrumb path and places it in callout title.
* Version: 1.0
* Author: Created via Claude
* Source: https://forum.obsidian.md/t/easily-create-foldable-embeds-with-breadcrumbs/94402
* Last Updated: 2025-01-06
*/
if (app.workspace.activeEditor.getMode() === "preview") {
new Notice("Reading view isn't supported");
return;
}
const editor = app.workspace.activeEditor.editor;
const filename = tp.file.title;
const selections = editor.listSelections();
const cursor = editor.getCursor();
const currentLine = editor.getLine(cursor.line).trim();
if (currentLine === '') {
const calloutContent = `> [!cite]- ${filename}\n> ![[${filename}|clean]]`;
await navigator.clipboard.writeText(calloutContent);
new Notice('Copied to your clipboard');
tR = editor.getSelection();
return;
}
const selectionTexts = selections.map(sel =>
editor.getRange(sel.anchor, sel.head).trim()
);
await navigator.clipboard.writeText('');
const initialClipboard = await navigator.clipboard.readText();
await app.commands.executeCommandById('obsidian42-text-transporter:CC');
let clipboardContent = initialClipboard;
await Promise.race([
new Promise(resolve => {
function checkClipboard() {
navigator.clipboard.readText().then(content => {
if (content !== initialClipboard) {
clipboardContent = content;
resolve();
} else {
requestAnimationFrame(checkClipboard);
}
});
}
checkClipboard();
}),
new Promise(resolve => setTimeout(resolve, 100))
]);
const updatedContent = editor.getValue();
const newSelections = [];
for (const text of selectionTexts) {
const pos = updatedContent.indexOf(text);
if (pos !== -1) {
const from = editor.offsetToPos(pos);
const to = editor.offsetToPos(pos + text.length);
newSelections.push({anchor: from, head: to});
}
}
if (newSelections.length) {
editor.setSelections(newSelections);
}
editor.setCursor(cursor);
const linkPattern = /\[\[([^\]]+?)\]\]/g;
clipboardContent = clipboardContent.replace(linkPattern, (match, innerContent) => {
return `[[${innerContent}|clean]]`;
});
const startLine = editor.getCursor('from').line;
const lines = editor.getValue().split('\n');
const currentLineContent = lines[startLine].trim();
function isInCodeFence(lineNum, lines) {
let inCodeFence = false;
for (let i = 0; i <= lineNum; i++) {
if (lines[i].trim().startsWith('```')) {
inCodeFence = !inCodeFence;
}
}
return inCodeFence;
}
function isHeading(line, lineNum, lines) {
if (isInCodeFence(lineNum, lines)) return false;
return /^#{2,6}\s/.test(line.trim());
}
function getHeadingText(line) {
return line.replace(/^#+\s*/, '').trim();
}
function getHeadingLevel(line, lineNum, lines) {
if (isInCodeFence(lineNum, lines)) return 0;
const match = line.trim().match(/^(#{2,6})\s/);
return match ? match[1].length : 0;
}
const breadcrumbs = [filename];
let currentLevel = isHeading(currentLineContent, startLine, lines) ? getHeadingLevel(currentLineContent, startLine, lines) : 99;
if (isHeading(currentLineContent, startLine, lines)) {
breadcrumbs.push(getHeadingText(currentLineContent));
}
for (let i = startLine - 1; i >= 0; i--) {
const line = lines[i].trim();
const level = getHeadingLevel(line, i, lines);
if (!level || level === 1 || level >= currentLevel) continue;
const headingText = getHeadingText(line);
breadcrumbs.splice(1, 0, headingText);
currentLevel = level;
}
const breadcrumbsPath = breadcrumbs.join(' > ');
const blocks = clipboardContent.split(/\n\s*\n/).filter(block => block.trim());
const formattedBlocks = blocks.map(block =>
block.split('\n')
.filter(line => line.trim())
.map(line => `> ${line}`)
.join('\n')
);
const calloutContent = `> [!cite]- ${breadcrumbsPath}\n` + formattedBlocks.join('\n>\n');
await navigator.clipboard.writeText(calloutContent);
new Notice('Copied to your clipboard');
tR = editor.getSelection();
%>
Optional complements
CSS snippet to hide embed border
/* Hides blockquote border for embeds. (Doesn't hide border for blockquotes within embeds.) Extracted from SIRvb's Embed Adjustments: https://publish.obsidian.md/slrvb-docs/ITS+Theme/Embed+Adjustments */
.embed-clean .internal-embed > .markdown-embed, .embed-clean .internal-embed.is-loaded:not(.image-embed),
.internal-embed[alt*=clean] > .markdown-embed,
.internal-embed[alt*=clean].is-loaded:not(.image-embed) {
--embed-padding: 0;
border: none;
box-shadow: none;
}
With snippet ON and link alias clean
:
With snippet OFF, or without link alias clean
:
CSS snippet to make consecutive embeds appear as one embed
/*Make consecutive embeds appear as one embed (if not separated by empty line)*/
.markdown-rendered .markdown-embed {
margin: 0;
}
.markdown-rendered .inline-embed + br {
display: none;
}
.HyperMD-quote > .markdown-embed {
display: inline-block;
width: 99.9%;
vertical-align: middle;
}
See post for more details. (This handles embeds in callouts fine but doesn’t fully work for embeds in blockquotes in Live Preview—perhaps someone can take a stab at it? )
With snippet ON and no empty line between embedded links:
With snippet ON and empty line between embedded links:
With snippet OFF:
CSS snippet to prevent embed link icon overlap with text
/*Prevent embed link icon overlap with text*/
.markdown-source-view .internal-embed,
.markdown-preview-view .internal-embed {
position: relative;
padding-right: 24px;
}
With snippet ON:
With snippet OFF:
CSS snippet to hide headings in embeds
/*Hide headings in embeds by adding ‘no-hx’ to link alias or cssclasses*/
.internal-embed[alt*="no-h1"].markdown-embed h1,
.internal-embed[alt*="no-h2"].markdown-embed h2,
.internal-embed[alt*="no-h3"].markdown-embed h3,
.internal-embed[alt*="no-h4"].markdown-embed h4,
.internal-embed[alt*="no-h5"].markdown-embed h5,
.internal-embed[alt*="no-h6"].markdown-embed h6,
.no-h1 .internal-embed .markdown-rendered h1,
.no-h2 .internal-embed .markdown-rendered h2,
.no-h3 .internal-embed .markdown-rendered h3,
.no-h4 .internal-embed .markdown-rendered h4,
.no-h5 .internal-embed .markdown-rendered h5,
.no-h6 .internal-embed .markdown-rendered h6 {
display: none;
}
CSS snippet for sleek callouts
/*
Adapted from: https://discord.com/channels/686053708261228577/702656734631821413/1040275500297375856
Original author: Adonis, Anubis
*/
.callout:not(.is-collapsible) {
padding: 0px;
}
.callout:not(.is-collapsible) .callout-content {
padding: 0 var(--callout-title-padding) var(--callout-title-padding) var(--callout-title-padding);
}
.callout:not(.is-collapsible) .callout-title {
background-color: rgba(var(--callout-color), 0.1);
padding: var(--callout-title-padding);
cursor: pointer;
}
.callout:not(.is-collapsible) .callout-title .callout-title-inner {
font-weight: normal;
}
.callout:not(.is-collapsible) {
border-color: rgba(var(--callout-color), 0.4);
border-width: 1px;
border-radius: var(--callout-radius);
background-color: rgba(var(--ctp-mantle), 0.4);
}
.callout-content {
padding: var(--callout-title-padding) var(--callout-title-padding) var(--callout-title-padding) calc(var(--callout-title-padding) * 1.5);
border-top: 1px solid rgba(var(--callout-color), 0.4);
}
.callout-fold {
padding-right: 0px;
}
.callout-title-inner {
flex-grow: var(--anp-callout-fold-position, unset);
}
.callout {
--callout-title-padding: var(--size-4-2);
}
.callout.is-collapsible {
border-color: rgba(var(--callout-color), 0.4);
border-width: 1px;
border-radius: var(--callout-radius);
background-color: rgba(var(--ctp-mantle), 0.4);
--bold-weight: bolder;
padding: 0;
}
.callout.is-collapsible .callout-fold {
padding-right: 0px;
}
.callout.is-collapsible .callout-title-inner {
flex-grow: var(--anp-callout-fold-position, unset);
}
.callout.is-collapsible.is-collapsed {
padding: 0;
}
.callout.is-collapsible.is-collapsed .callout-title {
background-color: rgba(var(--callout-color), 0.1);
padding: var(--callout-title-padding);
cursor: pointer;
}
.callout.is-collapsible.is-collapsed .callout-content {
display: none;
}
.callout.is-collapsible:not(.is-collapsed) .callout-title {
background-color: rgba(var(--callout-color), 0.1);
padding: var(--callout-title-padding);
border-color: rgba(var(--callout-color), 0.4);
cursor: pointer;
}
.callout.is-collapsible:not(.is-collapsed) .callout-content:not(:empty) {
padding: var(--callout-title-padding) var(--callout-title-padding) var(--callout-title-padding) calc(var(--callout-title-padding) * 1.5);
border-top: 1px solid rgba(var(--callout-color), 0.4);
}
.callout .list-collapse-indicator {
margin-left: -35px;
padding-right: 3px;
}
CSS snippet for custom callout icon and color
I associate embeds with a emoji, so I reflected it in this custom style:
.callout[data-callout="embed"] {
--callout-color: var(--color-green-rgb);
--callout-icon: puzzle;
}
This will only apply to > [!embed]
callouts.
Run template via right-click and file menu
With Commander, it’s possible to add the command to run the template for when right-clicking in editor:
and to the file menu:
Enhancement ideas, bug reports and code improvements welcome