Templates in Roam

Add template button to Roam to easily insert custom templates

// ==UserScript==
// @name        Templates in Roam
// @namespace   https://eriknewhard.com/
// @description Add template button to Roam to easily insert custom templates
// @author      everruler
// @version     0.6
// @match       https://roamresearch.com/#/app/*
// @grant       none
// @require     https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.0/jquery.min.js
// @require     https://cdnjs.cloudflare.com/ajax/libs/vue/2.6.11/vue.js
// @require     https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js
// ==/UserScript==

// Wait for Roam to load before initializing
var observer = new MutationObserver(function (mutations) {
    mutations.forEach(function (mutation) {
        var newNodes = mutation.addedNodes // DOM NodeList
        if (newNodes !== null) { // If there are new nodes added
            $(newNodes).each(function () {
                if ($(this).hasClass('roam-body')) { // body has loaded
                    console.log('Roam Templates: Observer finished.')
                    observer.disconnect()
                    init()
                }
            })
        }
    })
})

var target = $('#app')[0]

var config = {
    attributes: true,
    childList: true,
    characterData: true
}

observer.observe(target, config)
console.log('Roam Templates: Observer running...')



// NOTES
// Blueprint icons: https://blueprintjs.com/docs/#icons



function init() {
    const searchBar = $('.rm-find-or-create-wrapper').eq(0)
    const divider = $('<div style="flex: 0 0 4px"></div>')

    let templating_button = $(`
    <span id="RT-vm-button" class="bp3-popover-wrapper">
        <span class="bp3-popover-target" style="margin-left: 12px">
            <button id="RT-templateButton" class="bp3-button bp3-minimal bp3-icon-add-to-artifact bp3-small" tabindex="0" @click="click" @mouseover="hoverIn" @mouseleave="hoverOut"></button>
        </span>
    </span>`)

    let popover = $(`
    <div id="RT-popover" class="bp3-transition-container" style="position: absolute; top: 47px; transform: translateX(-50%);" :style="{left: leftPos+'px'}"  v-show="show_popover && !show_panel">
        <div class="bp3-popover bp3-tooltip" style="transform-origin: 62px top;">
            <div class="bp3-popover-arrow" style="top: -8px; left: 50%; transform: translateX(-50%);">
                <svg viewBox="0 0 30 30" style="transform: rotate(90deg);">
                    <path class="bp3-popover-arrow-border" d="M8.11 6.302c1.015-.936 1.887-2.922 1.887-4.297v26c0-1.378-.868-3.357-1.888-4.297L.925 17.09c-1.237-1.14-1.233-3.034 0-4.17L8.11 6.302z"></path>
                    <path class="bp3-popover-arrow-fill" d="M8.787 7.036c1.22-1.125 2.21-3.376 2.21-5.03V0v30-2.005c0-1.654-.983-3.9-2.21-5.03l-7.183-6.616c-.81-.746-.802-1.96 0-2.7l7.183-6.614z"></path>
                </svg>
            </div>
                <div class="bp3-popover-content">{{popover_label}}</div>
        </div>
    </div>`)

    let panel = $(`
    <div class="bp3-overlay bp3-overlay-open roam-lift" v-show="show_panel">
        <div id="RT-panelContainer" class="bp3-transition-container bp3-popover-enter-done" style="position: absolute; top: 50px; right: 5px; min-width: 200px;">
            <div class="bp3-popover" style="transform-origin: 121px top; right: 5px;">
                <div id="RT-panelArrow" class="bp3-popover-arrow" style="top: -11px; transform: translateX(-50%);" :style="{right: rightPos+'px'}">
                    <svg viewBox="0 0 30 30" style="transform: rotate(90deg);">
                        <path class="bp3-popover-arrow-border" d="M8.11 6.302c1.015-.936 1.887-2.922 1.887-4.297v26c0-1.378-.868-3.357-1.888-4.297L.925 17.09c-1.237-1.14-1.233-3.034 0-4.17L8.11 6.302z"></path>
                        <path class="bp3-popover-arrow-fill" d="M8.787 7.036c1.22-1.125 2.21-3.376 2.21-5.03V0v30-2.005c0-1.654-.983-3.9-2.21-5.03l-7.183-6.616c-.81-.746-.802-1.96 0-2.7l7.183-6.614z"></path>
                    </svg>
                </div>
                <div class="bp3-popover-content">
                    <ul class="bp3-menu">
                        <h1 class="rm-level3" style="text-align:center">TEMPLATES</h1>
                        <li v-for="template in templates">
                            <span class="bp3-menu-item bp3-popover-dismiss" @click="copy(template)">
                                <div class="bp3-text-overflow-ellipsis bp3-fill">{{template.name}}</div>
                                <a @click.stop.prevent="edit(template)">Edit</a>
                            </span>
                        </li>
                        <div style="margin-top: 1em;">
                            <span class="bp3-button bp3-icon-import" @click="upload">Import</span>
                            <span class="bp3-button bp3-icon-floppy-disk" @click="download">Export</span>
                        </div>
                    </ul>
                </div>
            </div>
        </div>
    </div>`)

    templating_button.append(popover)
    templating_button.append(panel)
    templating_button.after(divider)
    searchBar.after(templating_button)


    vm_button = new Vue({
        el: '#RT-vm-button',
        data: {
            popover_label: 'Templates',
            show_popover: false,
            show_panel: false,
            leftPos: 0,
            rightPos: 0,
            templates: [],
            transforms: []
        },
        computed: {
            popover_display() {
                return this.show_popover ? "inline" : "none"
            }
        },
        methods: {
            click() {
                this.show_panel = !this.show_panel
                this.hoverOut()
                Vue.nextTick().then(this.updateRightPos)
            },
            hoverIn() {
                this.updateLeftPos()
                this.show_popover = true
            },
            hoverOut() {
                this.show_popover = false
            },
            updateLeftPos() {
                const button = $('#RT-templateButton')
                this.leftPos = button.position().left + button.width() / 2
            },
            updateRightPos() {
                const button = $('#RT-templateButton')
                const panel = $('#RT-panelContainer > div')
                this.rightPos = this.positionRight(button) - button.width() / 2 - this.positionRight(panel) - 5
            },
            positionRight($el) {
                return $(document).width() - ($el.offset().left + $el.outerWidth())
            },
            copy(template) {
                // transform template text
                var text_to_copy = this.transforms.reduce((acc, transform) => {
                    var re = new RegExp(transform.syntax, "g")
                    return acc.replace(re, transform.fn())
                }, template.text)

                // create hidden textarea to copy text
                var targetId = "RT-_hiddenCopyText_"
                var target = document.createElement("textarea")
                target.style.position = "absolute"
                target.style.left = "-9999px"
                target.style.top = "0"
                target.id = targetId
                document.body.appendChild(target)

                target.textContent = text_to_copy

                // select the content
                var currentFocus = document.activeElement
                target.focus()
                target.setSelectionRange(0, target.value.length)

                // copy the selection
                try {
                    document.execCommand("copy")
                    $('#RT-_hiddenCopyText_').remove()
                } catch (e) {
                    console.log("Roam Template: Copy failed.")
                }

                // restore original focus
                if (currentFocus && typeof currentFocus.focus === "function") {
                    currentFocus.focus()
                }

                // close panel
                setTimeout(() => {
                    // try opacity
                    this.show_panel = false
                    // popup 'Copied!'
                }, 100)
            },
            edit(template) {
                alert(template.text)
            },
            upload() {
                alert('Upload clicked')
            },
            download() {
                alert('Download clicked')
            }
        },
        mounted() {
            // allow edit, import, and export to with simple json to localStorage
            // load localStorage || default template
            this.templates = localStorage.RT_templates || [{
                "name": "Book",
                "text": "Author:: \nStatus:: \nRecommended by:: \n### Notes\n\t→"
            }, {
                "name": "Morning Pages",
                "text": "#[[Morning Pages]] {{current time}} {{word-count}}\n\t→"
            }]

            this.transforms = localStorage.RT_transforms || [{
                syntax: "{{current time}}",
                fn() {
                    return moment().format('HH:mm')
                }
            }, {
                syntax: "{{today}}",
                fn() {
                    return `[[${moment().format('MMMM Do, YYYY')}]]`
                }
            }]



            this.updateLeftPos()
            this.updateRightPos()
        }
    })
}