Copy block link, embed, footnote or URL with Templater

Copy link to block as footnote


Outputs: ^[[[filename#^block-id]]]

Templater script

Copy_Link_to_Block_as_Footnote.md (11.2 KB)

<%* 
/**
 * Template Name: Copy Link to Block as Footnote
 * Description: Extracts or generates a block ID from cursor position or selection and copies a reference as wikilink in footnote.
 * Version: 2.2
 * Author: Created via Claude
 * Source: https://forum.obsidian.md/t/copy-block-link-embed-footnote-or-url-with-templater/92600/3
 * Last Updated: 2025-01-17
 */

if (app.workspace.activeEditor.getMode() === "preview") {
    new Notice("Reading view isn't supported");
    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
            });
        }
    }
    return `^[[[${file.basename}#^${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("Copied to your clipboard");
}
tR = selection;
%>

Alternative solutions

These community plugins achieve similar goals:

  1. Carry-Forward
  2. Text Transporter

Unfortunately, links in inline footnotes don’t get updated as of 1.7.7 due to a bug.