Templater script
Copy_URL_to_Block.md (11.4 KB)
<%*
/**
* Template Name: Copy URL to Block
* Description: Extracts or generates a block ID from cursor position or selection and copies a URL to it.
* Version: 2.2
* Author: Created via Claude
* Source: https://forum.obsidian.md/t/copy-block-link-embed-footnote-or-url-with-templater/92600/4
* Last Updated: 2025-01-17
*/
if (app.workspace.activeEditor.getMode() === "preview") {
new Notice("Reading view isn't supported");
tR = selection;
return;
}
const editor = tp.app.workspace.getActiveViewOfType(tp.obsidian.MarkdownView).editor;
const selections = editor.listSelections();
if (selections.length > 1) {
new Notice("Multiple selections or cursors aren't supported");
tR = editor.getSelection();
return;
}
function generateId() {
return Math.random().toString(36).substr(2, 6);
}
function shouldInsertAfter(block) {
if (block.type) {
return [
"blockquote",
"code",
"table",
"comment",
"footnoteDefinition",
].includes(block.type);
}
return block.heading !== undefined;
}
function isValidBlockId(id) {
return /^[a-zA-Z0-9-]+$/.test(id);
}
function findNearestNonEmptyLineAbove(editor, currentLine) {
for (let i = currentLine - 1; i >= 0; i--) {
const lineContent = editor.getLine(i).trim();
if (lineContent !== '') {
const isBlockId = lineContent.match(/^\^[a-zA-Z0-9-]+$/);
if (!isBlockId) return i;
}
}
return -1;
}
function findStandaloneBlockId(startLine, editor) {
for (let i = startLine; i < editor.lineCount(); i++) {
const line = editor.getLine(i).trim();
if (line === '') continue;
const match = line.match(/^\^([a-zA-Z0-9-]+)$/);
if (match) return match[1];
break;
}
return null;
}
function getInlineBlockId(line) {
const match = line.match(/\s\^([a-zA-Z0-9-]+)$/);
return match ? match[1] : null;
}
function getBlock(editor, fileCache) {
const cursor = editor.getCursor("to");
const cursorLine = cursor.line;
const currentLineContent = editor.getLine(cursorLine).trim();
const currentSection = fileCache?.sections?.find(section =>
section.position.start.line <= cursorLine &&
section.position.end.line >= cursorLine
);
if (currentSection?.type === "table") {
const blockId = findStandaloneBlockId(currentSection.position.end.line + 1, editor);
return {
...currentSection,
id: blockId,
type: blockId ? 'text-with-standalone-id' : 'table'
};
}
if (currentLineContent.match(/^\^[a-zA-Z0-9-]+$/)) {
const blockLine = findNearestNonEmptyLineAbove(editor, cursorLine);
if (blockLine === -1) return null;
const section = fileCache?.sections?.find(section =>
section.position.start.line <= blockLine &&
section.position.end.line >= blockLine
);
if (section?.type === "list") {
return {
...section,
id: currentLineContent.substring(1),
type: 'text-with-standalone-id'
};
}
return {
position: {
start: { line: blockLine, ch: 0 },
end: { line: blockLine, ch: editor.getLine(blockLine).length }
},
type: 'text-with-standalone-id',
id: currentLineContent.substring(1)
};
}
if (currentSection?.type === "list") {
const listItem = fileCache?.listItems?.find(item =>
item.position.start.line <= cursorLine &&
item.position.end.line >= cursorLine &&
currentLineContent === editor.getLine(item.position.start.line).trim()
);
if (listItem) {
const standaloneId = findStandaloneBlockId(listItem.position.end.line + 1, editor);
if (currentLineContent === '' && standaloneId) {
return {
...currentSection,
id: standaloneId,
type: 'text-with-standalone-id'
};
}
return listItem;
} else {
const standaloneId = findStandaloneBlockId(currentSection.position.end.line + 1, editor);
if (standaloneId && currentLineContent === '') {
return {
...currentSection,
id: standaloneId,
type: 'text-with-standalone-id'
};
}
return currentSection;
}
} else if (currentSection?.type === "heading") {
const heading = fileCache.headings.find(heading =>
heading.position.start.line === currentSection.position.start.line
);
if (heading) {
const headingLine = editor.getLine(heading.position.start.line);
const inlineId = getInlineBlockId(headingLine);
if (inlineId) {
return {...heading, id: inlineId, type: 'text-with-standalone-id'};
}
const standaloneId = findStandaloneBlockId(heading.position.start.line + 1, editor);
if (standaloneId) {
return {...heading, id: standaloneId, type: 'text-with-standalone-id'};
}
return heading;
}
} else if (currentSection) {
const standaloneId = findStandaloneBlockId(currentSection.position.end.line + 1, editor);
if (standaloneId) {
return {
...currentSection,
id: standaloneId,
type: 'text-with-standalone-id'
};
}
return currentSection;
}
const prevLine = cursorLine - 1;
if (prevLine >= 0) {
const prevSection = fileCache?.sections?.find(section =>
section.type === "list" && section.position.end.line === prevLine
);
if (prevSection) {
const standaloneId = findStandaloneBlockId(cursorLine + 1, editor);
if (standaloneId && currentLineContent === '') {
return {
...prevSection,
id: standaloneId,
type: 'text-with-standalone-id'
};
}
if (currentLineContent === '') {
return {
position: {
start: { line: cursorLine, ch: 0 },
end: { line: cursorLine, ch: 0 }
},
type: 'empty-after-list'
};
}
}
}
if (currentLineContent !== '') {
const standaloneId = findStandaloneBlockId(cursorLine + 1, editor);
return {
position: {
start: { line: cursorLine, ch: 0 },
end: { line: cursorLine, ch: editor.getLine(cursorLine).length }
},
type: standaloneId ? 'text-with-standalone-id' : 'text',
id: standaloneId
};
}
return null;
}
function checkSelectionSpansBlocks(from, to, fileCache, editor) {
const sections = fileCache?.sections || [];
const blockquote = sections.find(section =>
section.type === "blockquote" &&
section.position.start.line <= from.line &&
section.position.end.line >= to.line
);
if (blockquote) return false;
const listItems = fileCache?.listItems || [];
const selectedItems = listItems.filter(item => {
const itemStart = item.position.start.line;
let itemEnd = item.position.end.line;
if (findStandaloneBlockId(itemEnd + 1, editor)) {
itemEnd += 2;
}
return itemStart >= from.line && itemEnd <= to.line;
});
if (selectedItems.length > 1) {
const firstItemLine = selectedItems[0].position.start.line;
const section = sections.find(section =>
section.type === "list" &&
section.position.start.line <= firstItemLine &&
section.position.end.line >= firstItemLine
);
if (section && selectedItems.every(item =>
item.position.start.line >= section.position.start.line &&
item.position.end.line <= section.position.end.line
)) {
return false;
}
return true;
}
const selectedSections = sections.filter(section => {
const sectionStart = section.position.start.line;
let sectionEnd = section.position.end.line;
if (findStandaloneBlockId(sectionEnd + 1, editor)) {
sectionEnd += 2;
}
return (sectionStart >= from.line && sectionStart <= to.line) ||
(sectionEnd >= from.line && sectionEnd <= to.line) ||
(sectionStart <= from.line && sectionEnd >= to.line);
});
return selectedSections.length > 1;
}
async function handleBlock(file, editor, block) {
let blockId;
if (block.type === 'text-with-standalone-id' || block.id) {
blockId = block.id;
} else {
blockId = generateId();
const currentLine = block.position.end.line;
if (block.type === 'empty-after-list') {
await editor.replaceRange("\n", {line: currentLine - 1, ch: editor.getLine(currentLine - 1).length});
const nextLineEmpty = currentLine + 2 < editor.lineCount() && editor.getLine(currentLine + 2).trim() === '';
await editor.replaceRange(`^${blockId}${nextLineEmpty ? '' : '\n'}`, {line: currentLine + 1, ch: 0});
} else if (shouldInsertAfter(block)) {
const nextLineEmpty = currentLine + 1 < editor.lineCount() && editor.getLine(currentLine + 1).trim() === '';
if (!nextLineEmpty) {
await editor.replaceRange("\n\n", {line: currentLine, ch: editor.getLine(currentLine).length});
await editor.replaceRange(`^${blockId}\n`, {line: currentLine + 2, ch: 0});
} else {
await editor.replaceRange("\n", {line: currentLine + 1, ch: 0});
await editor.replaceRange(`^${blockId}\n`, {line: currentLine + 2, ch: 0});
}
} else if (block.position.end.line > block.position.start.line) {
const lastLineContent = editor.getLine(currentLine);
await editor.replaceRange(` ^${blockId}`, {
line: currentLine,
ch: lastLineContent.length
});
} else {
await editor.replaceRange(` ^${blockId}`, {
line: currentLine,
ch: block.position.end.col || editor.getLine(currentLine).length
});
}
}
const vaultName = app.vault.getName();
const filePath = file.path.replace('.md', '');
const encodedVault = encodeURIComponent(vaultName);
const encodedPath = encodeURIComponent(filePath);
return `obsidian://open?vault=${encodedVault}&file=${encodedPath}%23%5E${blockId}`;
}
const view = tp.app.workspace.getActiveViewOfType(tp.obsidian.MarkdownView);
if (!view) return;
const selection = editor.getSelection();
if (selection && checkSelectionSpansBlocks(editor.getCursor('from'), editor.getCursor('to'), tp.app.metadataCache.getFileCache(view.file), editor)) {
new Notice("Selections spanning multiple blocks aren't supported");
tR = selection;
return;
}
const block = getBlock(editor, tp.app.metadataCache.getFileCache(view.file));
if (!block) {
tR = selection;
return;
}
const result = await handleBlock(view.file, editor, block);
if (result) {
await navigator.clipboard.writeText(result);
new Notice("URL copied to your clipboard");
}
tR = selection;
%>
- Create a macro in QuickAdd.
- As step one, add Obsidian command: “Copy Block Link: Copy link to current block or heading”.
- As step two, add user script:
Copy_URL_to_block.js
.
Copy Block Link cannot generate block IDs for headings, so this only works for non-heading blocks.