Copy block link, embed, footnote or URL with Templater

Copy URL to block


Outputs: obsidian://open?vault=vault&file=filename%23%5Eblock-id

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;
%>
  1. Create a macro in QuickAdd.
  2. As step one, add Obsidian command: “Copy Block Link: Copy link to current block or heading”.
  3. 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.


Room for improvement

Could create a modified version of the script that would, when run:

  1. Insert 🔗 before the block ID–to mark that the block is linked to outside the vault, and that either the block ID shouldn’t be renamed, or the URL to it should be updated on block ID rename.
  2. Add a task-type property value to frontmatter titled linked to and set its key to true–to mark that either the note shouldn’t be renamed, or the URLs to it should be updated on note rename.
  3. Insert the current timestamp after the emoji in block e.g. Regular block text. 🔗 %%YYYY-MM-DDTHH:mmZ%% ^block-id–to capture when the URL was generated.

Do note that a URL will break if the vault, note or block ID get renamed. See feature request for permanent URLs.