彩りの大地 Laboratory /HTMLをMarkdown形式でコピーするスクリプト

ぺぇじへっどらいん

HTMLをMarkdown形式でコピーするスクリプト

  • 実際じっさいコピーしてみてくださいましまし

HTML要素をコピーした際にMarkdown形式やルビ付きテキストとしてクリップボードに保持されるようにアクセシビリティ対応構造へ変換するスクリプトです。
当サイトの基本として組み込まれているため、いろんなページで試してみてもいいかもしれません。
なお、当サイトではこちらの設定で機能のON/OFFを変更することができる。 つまり、機能が無効化されている場合はこちらでも反映されないためご用心。

最近はGeminiさんなどの生成AIさんに「こんなスクリプト作りたい!」って言えば作ってくれるのであまり意味を成しませんが、一応。

CSS

.sr-only-copy{
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border-width: 0;
}
Javascript

// サイト内の要素をコピーした際、Markdown形式やルビ付きテキストとして
// クリップボードに保持されるようにアクセシビリティ対応構造へ変換する
const initCopySupport = () => {
    // 1. ルビ(ruby)タグの処理
    document.querySelectorAll('ruby').forEach(ruby => {
        if(ruby.getAttribute('data-copy-processed')) return;
        
        const rtTags = ruby.querySelectorAll('rt');
        if(rtTags.length === 0) return;
        
        rtTags.forEach(rt => {
            // スクリーンリーダーには無視させ、コピー時のみ有効にする
            rt.setAttribute('aria-hidden', 'true');
            
            // 開始括弧
            const openParen = document.createElement('span');
            openParen.className = 'sr-only-copy';
            openParen.textContent = '(';
            rt.before(openParen);
            
            // 終了括弧とコピー用テキストルビ
            const closeParen = document.createElement('span');
            closeParen.className = 'sr-only-copy';
            closeParen.textContent = `${rt.textContent})`;
            rt.after(closeParen);
        });
        
        ruby.setAttribute('data-copy-processed', 'true');
    });
    
    // 2. 前後を記号で囲む要素(インライン装飾など)
    const wrapTagMap = {
        'strong': '**',        'b': '**',
        'em': '_',            'i': '_',
        'del': '~~',        's': '~~',
        'ins': '~',            'u': '~',
        'code': '`'
    };
    
    Object.entries(wrapTagMap).forEach(([tagName, symbol]) => {
        document.querySelectorAll(tagName).forEach(el => {
            if(el.getAttribute('data-copy-processed')) return;
            
            const prefix = document.createElement('span');
            prefix.className = 'sr-only-copy';
            prefix.textContent = symbol;
            el.prepend(prefix);
            
            const suffix = document.createElement('span');
            suffix.className = 'sr-only-copy';
            suffix.textContent = symbol;
            el.append(suffix);
            
            el.setAttribute('data-copy-processed', 'true');
        });
    });
    
    // 3. 前方にのみ記号を付与する要素(見出しなど)
    const headingTagMap = {
        'h1': '# ', 'h2': '## ', 'h3': '### ', 'h4': '#### '
    };
    
    Object.entries(headingTagMap).forEach(([tagName, symbol]) => {
        document.querySelectorAll(tagName).forEach(el => {
            if(el.getAttribute('data-copy-processed')) return;
            
            const prefix = document.createElement('span');
            prefix.className = 'sr-only-copy';
            prefix.textContent = symbol;
            el.prepend(prefix);
            
            el.setAttribute('data-copy-processed', 'true');
        });
    });
    
    // 4. リスト要素(li)の処理(ul / ol を自動判別)
    document.querySelectorAll('li').forEach(li => {
        if(li.getAttribute('data-copy-processed')) return;
        
        // 親要素が ol(順序付きリスト)かどうかを判定
        const parent = li.closest('ol, ul');
        let symbol = '- '; // デフォルトは順序なしリスト記号
        
        if(parent && parent.tagName.toLowerCase() === 'ol'){
            // olの中にあるli要素のうち、自分が何番目か(1から始まるインデックス)を取得
            const index = Array.from(parent.querySelectorAll(':scope > li')).indexOf(li) + 1;
            symbol = `${index}. `; // 「1. 」「2. 」という形にする
        }
        
        const prefix = document.createElement('span');
        prefix.className = 'sr-only-copy';
        prefix.textContent = symbol;
        li.prepend(prefix);
        
        li.setAttribute('data-copy-processed', 'true');
    });
    
    // 5. リンク要素(a)の処理 -> [テキスト](URL)
    document.querySelectorAll('a').forEach(a => {
        if(a.getAttribute('data-copy-processed')) return;
        
        // href属性の値を表示したい場合はこちら
        // const href = a.getAttribute('href') || '';
        
        // リンク先のURLをフルパスで表示したい場合はこちら
        const href = a.href || '';
        
        // 前方に [ を追加
        const prefix = document.createElement('span');
        prefix.className = 'sr-only-copy';
        prefix.textContent = '[';
        a.prepend(prefix);
        
        // 後方に ](URL) を追加
        const suffix = document.createElement('span');
        suffix.className = 'sr-only-copy';
        suffix.textContent = ']';
        
        // リンク先のURLをフルパスで表示したい場合はこちら
        suffix.textContent += `(${href})`;
        
        // href属性の値を表示したい場合はこちら(内部リンク(相対パス)の場合はURL非表示)
        // if(href.startsWith('http')){
        //    suffix.textContent += `(${href})`;
        // }
        
        a.append(suffix);
        
        a.setAttribute('data-copy-processed', 'true');
    });
    
    // 6. 画像要素(img)の処理 -> ![代替テキスト](URL)
    document.querySelectorAll('img').forEach(img => {
        if(img.getAttribute('data-copy-processed')) return;
        
        // src属性の値を表示したい場合はこちら
        // const src = img.getAttribute('src') || '';
        
        const src = img.src || '';
        
        const alt = img.getAttribute('alt') || '';
        
        // imgの直後に Markdown表現用の非表示spanを挿入
        const mdText = document.createElement('span');
        mdText.className = 'sr-only-copy';
        mdText.textContent = `![${alt}]`;
        
        // リンク先のURLをフルパスで表示したい場合はこちら
        mdText.textContent += `(${src})`;
        
        // href属性の値を表示したい場合はこちら(内部リンク(相対パス)の場合はURL非表示)
        // if(src.startsWith('http')){
        //    mdText.textContent += `(${src})`;
        // }
        
        // 画像の後ろに配置(画像そのもののテキスト選択はできないため、その位置に挿入)
        img.after(mdText);
        
        img.setAttribute('data-copy-processed', 'true');
    });
    
    // 7. 選択済セレクトボックス(select)の処理 -> テキスト
    document.querySelectorAll('select').forEach(select => {
        if (select.getAttribute('data-copy-processed')) return;
        
        const selText = document.createElement('span');
        selText.className = 'sr-only-copy';
        
        // 現在の選択値をspanに反映する共通関数
        const updateSelectCopyText = () => {
            if (select.selectedIndex !== -1) {
                selText.textContent = select.options[select.selectedIndex].text;
            } else {
                selText.textContent = '';
            }
        };
        
        // 初回実行時のテキストを設定
        updateSelectCopyText();
        select.after(selText);
        
        // ユーザーが選択肢を変更したとき
        select.addEventListener('change', updateSelectCopyText);
        
        // もしform要素の中にいる場合、リセットボタン対策を行う
        const parentForm = select.closest('form');
        if (parentForm) {
            parentForm.addEventListener('reset', () => {
                // リセット処理は一瞬遅れてDOMに反映されるため、setTimeoutで一瞬待ってから更新する
                setTimeout(updateSelectCopyText, 0);
            });
        }
        
        select.setAttribute('data-copy-processed', 'true');
    });
    
    // 8. テキストボックス(input type="text")の処理 -> テキスト
    document.querySelectorAll("[type='text']").forEach(textbox => {
        if (textbox.getAttribute('data-copy-processed')) return;
        
        const boxText = document.createElement('span');
        boxText.className = 'sr-only-copy';
        
        // 現在の値をspanに反映する共通関数
        const updateTextboxCopyText = () => {
            if (textbox.value !== '') {
                boxText.textContent = textbox.value;
            } else {
                boxText.textContent = '';
            }
        };
        
        // 初回実行時のテキストを設定
        updateTextboxCopyText();
        textbox.after(boxText);
        
        // ユーザーが選択肢を変更したとき
        textbox.addEventListener('change', updateTextboxCopyText);
        
        // もしform要素の中にいる場合、リセットボタン対策を行う
        const parentForm = textbox.closest('form');
        if (parentForm) {
            parentForm.addEventListener('reset', () => {
                // リセット処理は一瞬遅れてDOMに反映されるため、setTimeoutで一瞬待ってから更新する
                setTimeout(updateTextboxCopyText, 0);
            });
        }
        
        textbox.setAttribute('data-copy-processed', 'true');
    });
    
    // 9. テキストエリア(textarea)の処理 -> テキスト
    document.querySelectorAll('textarea').forEach(textarea => {
        if (textarea.getAttribute('data-copy-processed')) return;
        
        const textareaText = document.createElement('span');
        const br = document.createElement('br');
        textareaText.className = 'sr-only-copy';
        
        // 現在の値をspanに反映する共通関数
        const updateTextareaCopyText = () => {
            if (textarea.value !== '') {
                textareaText.textContent = textarea.value;
            } else {
                textareaText.textContent = '';
            }
        };
        
        // 初回実行時のテキストを設定
        updateTextareaCopyText();
        textarea.after(br);
        textarea.after(textareaText);
        
        // ユーザーが選択肢を変更したとき
        textarea.addEventListener('change', updateTextareaCopyText);
        
        // もしform要素の中にいる場合、リセットボタン対策を行う
        const parentForm = textarea.closest('form');
        if (parentForm) {
            parentForm.addEventListener('reset', () => {
                // リセット処理は一瞬遅れてDOMに反映されるため、setTimeoutで一瞬待ってから更新する
                setTimeout(updateTextareaCopyText, 0);
            });
        }
        
        textarea.setAttribute('data-copy-processed', 'true');
    });
};

window.addEventListener('DOMContentLoaded', function() { 
    initCopySupport();
},false);

書いた内容をコピーした際に単なるテキストにするだけでは物足りなかったので思い切ってMarkDown形式になるように考えたのがきっかけ。 具体的にはルビの内容もコピーできたらいいなというのが最初の動機。
じゃあ、どうせならと思って現行の形に至るということである。

上述している通り、Geminiさんに質問して作ってもらったスクリプトを思いっきり改良したり応用したりして使っている。 しっかりと形になったようで何よりである。
とまあこのように、AIにJavascriptの1つや2つを作ってと相談する程度ならわけないようだ。いい時代になったものだ。

CSSのsr-only-copyクラスセレクタはディスプレイ上では非表示にするために必要。
JSで挿入しているのはsr-only-copyクラスのコンテンツなので、CSSを省いた場合は全部表示されてしまう。

基本的には既存のテキストに修飾する形でコンテンツを突っ込んでいるのだが、 img要素とselect要素はもともとコピペするテキストがないため、取った属性値を要素の後に追加している。

blockquote要素が入っていないが、 当サイトではそもそも使っていない(使う予定もない)要素だし、わざわざつけるまでもないのでこの形でフィニッシュしている。 つけたければ自分でやってね。