Highlight Web 2.0

July 17, 2024

Following my old post, i realized that highlighting content, creating a new file and copying everything was too slow when im in a marathon reading.

Thats how this 2.0 version was born. Inspired by Going Lean by Lea Verou i started looking if i can do something similar but the other way around. Prepopulate a new file in github with the content of the current article. I found a github issue that answers this.

So the only thing left was extend my previous bookmarklet to also save the selected text and make some ui with two buttons: one for highlighting, and one for sharing.

For the ui, nothing fancy, just a wrapper div with two buttons.

let $wrapper, $buttonHighlight;
function buildUI(){
    $wrapper = document.querySelector(".pudymody-highlighter")
    if( $wrapper !== null ){
        return;
    }

    $wrapper = document.createElement("div");
    $wrapper.className = "pudymody-highlighter"

    $buttonHighlight = document.createElement("button");
    $buttonHighlight.style = `
        all: unset;
        padding: 0.5rem;
        background: #000;
        color: #fff;
        border-radius: 50%;
        cursor: pointer;
        display:block;
        user-select:none;
        position: fixed;
        bottom: 0.5rem;
        left: 0.5rem;
    `;
    $buttonHighlight.innerHTML = `
        <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="width:1rem;height:1rem;display:block;">
            <path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" />
        </svg>`
    $buttonHighlight.addEventListener("click", highlightText);
    $wrapper.appendChild($buttonHighlight);

    $buttonShare = document.createElement("button");
    $buttonShare.style = `
        all: unset;
        padding: 0.5rem;
        background: #000;
        color: #fff;
        border-radius: 50%;
        cursor: pointer;
        display:block;
        user-select:none;
        position: fixed;
        bottom: 0.5rem;
        left: 3rem;
    `;
    $buttonShare.innerHTML = `
        <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="width:1rem;height:1rem;display:block;">
            <path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z" />
        </svg>`
    $buttonShare.addEventListener("click", share);
    $wrapper.appendChild($buttonShare);

    document.body.appendChild($wrapper);
}

To save the selected texts, its almost the same as the previous one, but also using the fabolous cloneContents of the Range object.

let selections = [];
function highlightText(){
    const currentSelection = window.getSelection();
    const totalSelections = currentSelection.rangeCount;
    for(let i = 0; i < totalSelections; i++){
        const range = currentSelection.getRangeAt(i);
        selections.push(
            range.cloneContents()
        );
        [...range.getClientRects()]
            .map( e => {
                const a = document.createElement("mark");
                a.style.position = "absolute";
                a.style.pointerEvents = "none";
                a.style.background = highlightColor;
                a.style.left= (e.left+document.documentElement.scrollLeft)+"px";
                a.style.top = (e.top+document.documentElement.scrollTop)+"px";
                a.style.height = e.height+"px";
                a.style.width = e.width+"px";
                return a;
            })
            .forEach(d => { $wrapper.appendChild(d); });
    }
}

And finally, to convert the html to markdown, im using the unified library with the rehype-remark plugin. Which is an ecosystem of tools to work with syntax trees. Im also using the amazing ESM cdn to convert this libraries to ESM ones.

const {unified} = await import('https://esm.sh/unified?exports=unified')
const {default: rehypeParse} = await import('https://esm.sh/rehype-parse')
const {default: remarkStringify} = await import('https://esm.sh/remark-stringify')
const {default: rehypeRemark} = await import('https://esm.sh/rehype-remark')
const processor = unified()
    .use(rehypeParse)
    .use(rehypeRemark)
    .use(remarkStringify);

function getFileName(){
    const date = new Date();

    const year = date.getFullYear();
    const month = (date.getMonth() + 1).toString().padStart(2, "0");
    const day = (date.getDate()).toString().padStart(2, "0");

    const hour = (date.getHours()).toString().padStart(2, "0");
    const minutes = (date.getMinutes()).toString().padStart(2, "0");

    return `${year}-${month}-${day}_${hour}-${minutes}.md`
}

function share(){
    const fileName = getFileName();

    const htmlBody = selections
        .flatMap( e => [...e.childNodes] )
        .map( e => e.nodeType === e.TEXT_NODE ? `<p>${e.textContent}</p>` : e.outerHTML)
        .join("");

    const markdownTxt = processor.processSync(htmlBody)
        .value
        .split("\n\n")
        .map( l => `> ${l}`)
    
    markdownTxt.push(`From [${document.title}](${location.toString()})`)
    markdownTxt.unshift(`---
layout: "post"
date: ${formatISO(new Date())}
---`);

    fileContent = markdownTxt.join("\n\n");

    window.open(`https://github.com/pudymody/pudymody.github.io/new/master/content/stream?filename=${encodeURIComponent(fileName)}&value=${encodeURIComponent(fileContent)}`);
}

Code

And finally, all the code together and as a bookmarklet for your use:

(async function(window, document){
    /* esm.sh - esbuild bundle(date-fns@3.6.0/formatISO) es2022 production */
    function p(t){let n=Object.prototype.toString.call(t);return t instanceof Date||typeof t=="object"&&n==="[object Date]"?new t.constructor(+t):typeof t=="number"||n==="[object Number]"||typeof t=="string"||n==="[object String]"?new Date(t):new Date(NaN)}function o(t,n){let e=t<0?"-":"",i=Math.abs(t).toString().padStart(n,"0");return e+i}function formatISO(t,n){let e=p(t);if(isNaN(e.getTime()))throw new RangeError("Invalid time value");let i=n?.format??"extended",f=n?.representation??"complete",s="",c="",d=i==="extended"?"-":"",m=i==="extended"?":":"";if(f!=="time"){let r=o(e.getDate(),2),a=o(e.getMonth()+1,2);s=`${o(e.getFullYear(),4)}${d}${a}${d}${r}`}if(f!=="date"){let r=e.getTimezoneOffset();if(r!==0){let u=Math.abs(r),D=o(Math.trunc(u/60),2),h=o(u%60,2);c=`${r<0?"+":"-"}${D}:${h}`}else c="Z";let a=o(e.getHours(),2),l=o(e.getMinutes(),2),g=o(e.getSeconds(),2),$=s===""?"":"T",b=[a,l,g].join(m);s=`${s}${$}${b}${c}`}return s};

    const {unified} = await import('https://esm.sh/unified?exports=unified')
    const {default: rehypeParse} = await import('https://esm.sh/rehype-parse')
    const {default: remarkStringify} = await import('https://esm.sh/remark-stringify')
    const {default: rehypeRemark} = await import('https://esm.sh/rehype-remark')

    const highlightColor = "rgba(255,255,0,.3)"

    let $wrapper, $buttonHighlight;
    function buildUI(){
        $wrapper = document.querySelector(".pudymody-highlighter")
        if( $wrapper !== null ){
            return;
        }

        $wrapper = document.createElement("div");
        $wrapper.className = "pudymody-highlighter"

        $buttonHighlight = document.createElement("button");
        $buttonHighlight.style = `
            all: unset;
            padding: 0.5rem;
            background: #000;
            color: #fff;
            border-radius: 50%;
            cursor: pointer;
            display:block;
            user-select:none;
            position: fixed;
            bottom: 0.5rem;
            left: 0.5rem;
        `;
        $buttonHighlight.innerHTML = `
            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="width:1rem;height:1rem;display:block;">
                <path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" />
            </svg>`
        $buttonHighlight.addEventListener("click", highlightText);
        $wrapper.appendChild($buttonHighlight);

        $buttonShare = document.createElement("button");
        $buttonShare.style = `
            all: unset;
            padding: 0.5rem;
            background: #000;
            color: #fff;
            border-radius: 50%;
            cursor: pointer;
            display:block;
            user-select:none;
            position: fixed;
            bottom: 0.5rem;
            left: 3rem;
        `;
        $buttonShare.innerHTML = `
            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="width:1rem;height:1rem;display:block;">
              <path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z" />
            </svg>`
        $buttonShare.addEventListener("click", share);
        $wrapper.appendChild($buttonShare);

        document.body.appendChild($wrapper);
    }

    let selections = [];
    function highlightText(){
        const currentSelection = window.getSelection();
        const totalSelections = currentSelection.rangeCount;
        for(let i = 0; i < totalSelections; i++){
            const range = currentSelection.getRangeAt(i);
            selections.push(
                range.cloneContents()
            );
            [...range.getClientRects()]
                .map( e => {
                    const a = document.createElement("mark");
                    a.style.position = "absolute";
                    a.style.pointerEvents = "none";
                    a.style.background = highlightColor;
                    a.style.left= (e.left+document.documentElement.scrollLeft)+"px";
                    a.style.top = (e.top+document.documentElement.scrollTop)+"px";
                    a.style.height = e.height+"px";
                    a.style.width = e.width+"px";
                    return a;
                })
                .forEach(d => { $wrapper.appendChild(d); });
        }
    }

    const processor = unified()
        .use(rehypeParse)
        .use(rehypeRemark)
        .use(remarkStringify);

    function getFileName(){
        const date = new Date();

        const year = date.getFullYear();
        const month = (date.getMonth() + 1).toString().padStart(2, "0");
        const day = (date.getDate()).toString().padStart(2, "0");

        const hour = (date.getHours()).toString().padStart(2, "0");
        const minutes = (date.getMinutes()).toString().padStart(2, "0");

        return `${year}-${month}-${day}_${hour}-${minutes}.md`
    }

    function share(){
        const fileName = getFileName();

        const htmlBody = selections
            .flatMap( e => [...e.childNodes] )
            .map( e => e.nodeType === e.TEXT_NODE ? `<p>${e.textContent}</p>` : e.outerHTML)
            .join("");

        const markdownTxt = processor.processSync(htmlBody)
            .value
            .split("\n\n")
            .map( l => `> ${l}`)
        
        markdownTxt.push(`From [${document.title}](${location.toString()})`)
        markdownTxt.unshift(`---
layout: "post"
date: ${formatISO(new Date())}
---`);

        fileContent = markdownTxt.join("\n\n");

        window.open(`https://github.com/pudymody/pudymody.github.io/new/master/content/stream?filename=${encodeURIComponent(fileName)}&value=${encodeURIComponent(fileContent)}`);
    }

    buildUI();
    highlightText();
})(window, document)

Leave your comment on the github issue, sending me an email or DMing me on twitter