彩りの大地 Laboratory /サイトの各種要素を含んだテキストをMarkdown形式等としてコピーするスクリプト

ぺぇじへっどらいん

サイトの各種要素を含んだテキストをMarkdown形式等としてコピーする

  • 実際じっさいコピーしてみてくださいましまし
  • 123456+123456
  • どうです? 如何いかがでしょう

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 initRubyCopySupport = () => {
    // ルビ(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');
    });
};

// Markdownスクリプト -各種文字修飾
const initTextCopySupport = () => {
    // 前後を記号で囲む要素(インライン装飾など)
    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');
        });
    });
};

// Markdownスクリプト -見出し
const initHeaderCopySupport = () => {
    // 前方にのみ記号を付与する要素(見出しなど)
    const headingTagMap = {
        'h1': '# ', 'h2': '## ', 'h3': '### ', 'h4': '#### ','h5': '##### '
    };
    
    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');
        });
    });
};

// Markdownスクリプト -MathML関連
const initMathMLCopySupport = () => {
    document.querySelectorAll('math').forEach(math => {
        if (math.getAttribute('data-copy-processed')) return;
         
        // 子要素を順番に走査してテキストを結合
        let fullText = '';
         
        math.childNodes.forEach(node => {
            if (node.nodeType === Node.ELEMENT_NODE) {
                const tag = node.tagName.toLowerCase();
                const children = Array.from(node.children);
                
                if (tag === 'mfrac' && children.length >= 2) {
                    // 分数
                    const numText = children[0].textContent.trim();
                    const denText = children[1].textContent.trim();
                    fullText += `(${numText})/(${denText})`;
                } else if (tag === 'msup' && children.length >= 2) {
                    // 累乗
                    const baseText = children[0].textContent.trim();
                    const expText = children[1].textContent.trim();
                    fullText += `${baseText}^(${expText})`;
                } else {
                    // 他要素の + などの演算子やその他の要素はそのままテキストを取得
                    fullText += node.textContent.trim();
                }
            } else if (node.nodeType === Node.TEXT_NODE) {
                // 生のテキストノードがあればそのまま結合
                fullText += node.textContent.trim();
            }
        });
        
        if (fullText) {
            const copySpan = document.createElement('span');
            copySpan.className = 'sr-only-copy';
            copySpan.textContent = fullText; // 例: (123)/(456)+12345^(6)
            
            // math要素の直前に挿入
            math.before(copySpan);
            
            // math要素自体をコピー対象外にする
            math.style.userSelect = 'none';
            math.style.webkitUserSelect = 'none';
        }
        
        math.setAttribute('data-copy-processed', 'true');
    });
};

// Markdownスクリプト -List-Item
const initListItemCopySupport = () => {
    // リスト要素(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');
    });
};

// Markdownスクリプト -Link・Image
const initLinkImageCopySupport = () => {
    // リンク要素(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 text = a.textContent;
        
        // 前方に [ を追加
        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 = ']';
        
        if(varlpfl.match('/art/') === null && varlpfl.match('/artwk/') === null 
            && text != "↑"&& text != "▲"){
            // リンク先のURLをフルパスで表示したい場合はこちら
            suffix.textContent += `(${href})`;
            
        }
        // href属性の値を表示したい場合はこちら(内部リンク(相対パス)の場合はURL非表示)
        // if(href.startsWith('http')){
        //    suffix.textContent += `(${href})`;
        // }
        
        a.append(suffix);
        
        a.setAttribute('data-copy-processed', 'true');
    });
    
    // 画像要素(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}]`;
        
        if(varlpfl.match('/art/') === null && varlpfl.match('/artwk/') === null){
            // リンク先のURLをフルパスで表示したい場合はこちら
            mdText.textContent += `(${src})`;
        }
        
        // href属性の値を表示したい場合はこちら(内部リンク(相対パス)の場合はURL非表示)
        // if(src.startsWith('http')){
        //    mdText.textContent += `(${src})`;
        // }
        
        // 画像の後ろに配置(画像そのもののテキスト選択はできないため、その位置に挿入)
        img.after(mdText);
        
        img.setAttribute('data-copy-processed', 'true');
    });
};

// Markdownスクリプト -FormParts
const initFormPartCopySupport = () => {
    // 選択済セレクトボックス(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');
    });
    
    // 任意タイプの input フォーム部品処理 -> コピー用テキスト同期
    document.querySelectorAll("input").forEach(input => {
        if (input.getAttribute('data-copy-processed')) return;
        
        const type = input.type.toLowerCase();
        if (type === 'hidden' || type === 'file') return;
        
        const boxText = document.createElement('span');
        boxText.className = 'sr-only-copy';
        
        // readonly または disabled かどうかの判定
        const isReadOnly = input.readOnly || input.disabled;
        
        const updateCopyText = () => {
            let textVal = '';
            
            // --- 1. チェックボックス・ラジオボタン ---
            if (type === 'checkbox' || type === 'radio') {
                const label = input.labels && input.labels[0];
                const labelText = label ? label.textContent.trim() : (input.value !== 'on' ? input.value : '');
                
                if (isReadOnly) {
                    // readonly 時:チェックが入っている場合のみテキスト(ラベル名)をそのまま出す
                    // textVal = input.checked ? labelText : '';
                    textVal = '';
                } else {
                    // 通常時:チェック状態を [*] / [ ] などのテキスト表現で出力
                    textVal = input.checked ? '[*]' : '[ ]';
                    /*
                    if (labelText) {
                        textVal = input.checked ? `[*] ${labelText}` : `[ ] ${labelText}`;
                    } else {
                        textVal = input.checked ? '[*]' : '[ ]';
                    }
                    */
                }
            
            // --- 2. 画像ボタン ---
            } else if (type === 'image') {
                textVal = input.alt || input.value || '';
            
            // --- 3. その他(text, number, range, date, button, submit, reset 等) ---
            } else {
                // readonly の場合でも value の値をそのまま抽出
                textVal = input.value || '';
            }
            
            boxText.textContent = textVal;
        };
        
        // 初回実行
        updateCopyText();
        input.after(boxText);
        
        // イベント監視(readonly の場合は変更イベントが発生しないため監視をスキップしてもOK)
        if (!isReadOnly) {
            input.addEventListener('input', updateCopyText);
            input.addEventListener('change', updateCopyText);
            
            const parentForm = input.closest('form');
            if (parentForm) {
                parentForm.addEventListener('reset', () => {
                    setTimeout(updateCopyText, 0);
                });
            }
        }
        
        input.setAttribute('data-copy-processed', 'true');
    });    
    
    // テキストエリア(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() { 
    // Markdownスクリプト -ルビ
    initRubyCopySupport();
    // Markdownスクリプト -各種文字修飾
    initTextCopySupport();
    // Markdownスクリプト -見出し
    initHeaderCopySupport();
    // Markdownスクリプト -MathML関連
    initMathMLCopySupport();
    // Markdownスクリプト -List-Item
    initListItemCopySupport();
    // Markdownスクリプト -Link・Image
    initLinkImageCopySupport();
    // Markdownスクリプト -FormParts
    initFormPartCopySupport();
},false);

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

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

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

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

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

MathML関連要素の場合はさらに面倒で、Math要素内部の値を抜き出して分数・累乗を認識して「/」「^」を付与し、 生成できた文字列をMath要素外の不可視領域に突っ込むという事をしている。
つまり、Math要素内とMath要素外の不可視領域の2つが存在するため、Math要素内の値を無視すべく、 わざと文字列選択できないように処理をしてごまかしている。