コンテンツにスキップ

「MediaWiki:Forum.js」の版間の差分

提供: Re-Agatadia
編集の要約なし
編集の要約なし
 
(同じ利用者による、間の16版が非表示)
1行目: 1行目:
// <nowiki>
// <nowiki>
(()=>{
(() => {
     // 設定の読み込み待機
     // 設定の読み込み待機
     const checkConfig = () => {
     const checkConfig = () => {
         if (!window.mw || !mw.config || !mw.loader) return;
         if (!window.mw || !mw.config || !mw.loader) return;
       
 
         // 設定がない場合のデフォルト値
         // 設定がない場合のデフォルト値
         mw.forum = mw.forum || {};
         mw.forum = mw.forum || {};
         mw.forum.toppage = mw.forum.toppage || 'KenryoBBS1';
         mw.forum.toppage = mw.forum.toppage || 'KenryoBBS';
         mw.forum.threads = mw.forum.threads || 20;
         mw.forum.threads = mw.forum.threads || 20;
         mw.forum.sticky = mw.forum.sticky || [];
         mw.forum.sticky = mw.forum.sticky || [];
       
 
         const fedit = mw.util.getParamValue('fedit');
         const fedit = mw.util.getParamValue('fedit');
       
 
         // ページ判定(掲示板ページでなければ終了)
         // ページ判定(掲示板ページでなければ終了)
         if(!new RegExp(`^${mw.forum.toppage}(|/.*)$`).test(mw.config.get('wgPageName')) || mw.config.get('wgAction') != 'view'){
         if (!new RegExp(`^${mw.forum.toppage}(|/.*)$`).test(mw.config.get('wgPageName')) || mw.config.get('wgAction') != 'view') {
             return;
             return;
         }
         }
       
 
         // 編集モード時のユーザーチェック
         // 編集モード時のユーザーチェック
         if(fedit && document.querySelector(`[id="${fedit}-user"]`) && document.querySelector(`[id="${fedit}-user"]`).innerText != mw.config.get('wgUserName')){
         if (fedit && document.querySelector(`[id="${fedit}-user"]`) && document.querySelector(`[id="${fedit}-user"]`).innerText != mw.config.get('wgUserName')) {
             return;
             return;
         }
         }


         // UIモジュールの読み込み
         // UIモジュールの読み込み
         mw.loader.load(['mediawiki.ui.button','mediawiki.ui.input','mediawiki.ui.checkbox']);
         mw.loader.load(['mediawiki.ui.button', 'mediawiki.ui.input', 'mediawiki.ui.checkbox']);


         // ========== 直接編集の非表示(管理者以外) ==========
         // ========== 直接編集の非表示(管理者以外) ==========
31行目: 31行目:
         // ※ JS無効化や直接APIアクセスは防げないため、完全な保護が
         // ※ JS無効化や直接APIアクセスは防げないため、完全な保護が
         //  必要な場合は LocalSettings.php でページを保護してください。
         //  必要な場合は LocalSettings.php でページを保護してください。
         if(!mw.config.get('wgUserGroups').includes('sysop')){
         if (!mw.config.get('wgUserGroups').includes('sysop')) {
             const editBlockStyle = document.createElement('style');
             const editBlockStyle = document.createElement('style');
             editBlockStyle.textContent = [
             editBlockStyle.textContent = [
39行目: 39行目:
                 '.mw-editsection',  // 節編集リンク
                 '.mw-editsection',  // 節編集リンク
                 '#t-permalink',      // 念のため固定リンクも非表示
                 '#t-permalink',      // 念のため固定リンクも非表示
                '#ca-history',      // 「履歴を表示」タブ
                '#t-upload',        // サイドバーのアップロードリンク
             ].join(',') + '{display:none!important;}';
             ].join(',') + '{display:none!important;}';
             document.head.appendChild(editBlockStyle);
             document.head.appendChild(editBlockStyle);
         }
         }
         // =====================================================
         // =====================================================
       
 
         mw.loader.using(['mediawiki.util', 'mediawiki.user', 'mediawiki.api'], function() {
         mw.loader.using(['mediawiki.util', 'mediawiki.user', 'mediawiki.api'], function () {
             // 動物リスト
             // 動物リスト
             const animals = [
             const animals = [
                 'アライグマ', 'カピバラ', 'キリン', 'ペンギン', 'パンダ',  
                 'イヌ', 'ネコ', 'ハムスター', 'ウシ', 'ウマ', 'ヒツジ', 'ヤギ', 'ブタ', 'ニワトリ', 'アヒル',
                 'ウサギ', 'リス', 'ハリネズミ', 'コアラ', 'ナマケモノ',
                'クマ', 'サル', 'ゴリラ', 'チンパンジー', 'オランウータン', 'マンドリル',
                 'イルカ', 'クジラ', 'サメ', 'タコ', 'イカ',
                'チーター', 'ヒョウ', 'ジャガー', 'ピューマ', 'ハイエナ', 'ミーアキャット',
                 'フクロウ', 'タカ', 'スズメ', 'ツバメ', 'カラス',
                'シマウマ', 'ガゼル', 'インパラ', 'バッファロー', 'ヌー', 'アルパカ', 'リャマ', 'ラクダ',
                 'ライオン', 'トラ', 'ゾウ', 'サイ', 'カバ',
                'カンガルー', 'ワラビー', 'ウォンバット', 'カモノハシ', 'ハリモグラ',
                 'オオカミ', 'キツネ', 'タヌキ', 'イノシシ', 'シカ'
                'アザラシ', 'アシカ', 'セイウチ', 'オットセイ', 'ジュゴン', 'マナティ', 'ラッコ',
                'シャチ', 'ベルーガ', 'マッコウクジラ', 'ザトウクジラ', 'ジンベエザメ', 'ホオジロザメ', 'ハンマーヘッドシャーク',
                'エイ', 'マンボウ', 'タツノオトシゴ', 'ウツボ', 'クリオネ', 'クラゲ', 'サンゴ', 'イソギンチャク',
                'ワシ', 'トンビ', 'ハト', 'インコ', 'オウム', 'ブンチョウ', 'カナリア',
                'ダチョウ', 'エミュー', 'ヒクイドリ', 'キウィ', 'フラミンゴ', 'クジャク', 'ハクチョウ',
                'カワセミ', 'ハヤブサ', 'ミミズク', 'キツツキ', 'カモメ', 'ペリカン',
                'ワニ', 'ヘビ', 'トカゲ', 'カメレオン', 'イグアナ', 'ヤモリ', 'コモドドラゴン',
                'カエル', 'イモリ', 'サンショウウオ', 'ウーパールーパー',
                'アリクイ', 'アルマジロ', 'ヤマアラシ', 'スカンク', 'イタチ', 'テン', 'カワウソ', 'ビーバー',
                'ムササビ', 'モモンガ', 'フェレット', 'チンチラ', 'デグー', 'モルモット',
                'ドラゴン', 'ユニコーン', 'ペガサス', 'フェニックス', 'グリフォン', 'ケンタウロス', 'ケルベロス',
                'ヒドラ', 'キマイラ', 'バジリスク', 'コカトリス', 'ワイバーン', 'マンティコア', 'ゴーレム',
                'クラーケン', 'リヴァイアサン', 'シーサーペント', '人魚', 'セイレーン',
                'カッパ', '天狗', '鬼', '龍', '麒麟', '鳳凰', '霊亀', '八咫烏',
                'ツチノコ', 'ネッシー', 'イエティ', 'ビッグフット', 'チュパカブラ', 'モンゴリアンデスワーム',
                'カブトムシ', 'クワガタ', 'チョウ', 'トンボ', 'セミ', 'カマキリ', 'ハチ', 'アリ',
                'クモ', 'サソリ', 'ムカデ', 'ダンゴムシ', 'ペンギン', 'イルカ', 'コアラ', 'パンダ', 'レッサーパンダ',
                 'カピバラ', 'ナマケモノ', 'オポッサム', 'ミツバチ', 'テントウムシ', 'ホタル', 'カブトガニ', 'ダイオウグソクムシ',
                'シーラカンス', 'オウムガイ', 'アノマロカリス', 'ウミサソリ', 'トリロバイト', 'ディメトロドン', 'エダフォサウルス',
                'プレシオサウルス', 'モササウルス', 'ティラノサウルス', 'トリケラトプス', 'ステゴサウルス', 'ブラキオサウルス',
                 'アンキロサウルス', 'ディプロドクス', 'ヴェロキラプトル', 'ギガノトサウルス', 'カルノタウルス', 'メガロサウルス',
                 'スピノサウルス', 'イグアノドン', 'パラサウロロフス', 'エドモントサウルス', 'ヒプシロフォドン', 'マイアサウラ',
                 'プロトケラトプス', 'オヴィラプトル', 'オルニトミムス', 'シノサウルス', 'サウロロフス', 'サウルハドン', 'ガリミムス',
                 'アルバートサウルス', 'ダコタラプトル', 'アクロカントサウルス', 'シェノサウルス', 'エラスモサウルス', 'イクチオサウルス',
                'オルニトレステス', 'テリジノサウルス', 'アマルガサウルス', 'シンラプトル'
             ];
             ];


91行目: 117行目:
                     const saved = localStorage.getItem('forum_anon_settings');
                     const saved = localStorage.getItem('forum_anon_settings');
                     return saved ? JSON.parse(saved) : null;
                     return saved ? JSON.parse(saved) : null;
                 } catch(e) { return null; }
                 } catch (e) { return null; }
             };
             };


98行目: 124行目:
                 try {
                 try {
                     localStorage.setItem('forum_anon_settings', JSON.stringify({ name, tripKey }));
                     localStorage.setItem('forum_anon_settings', JSON.stringify({ name, tripKey }));
                 } catch(e) {}
                 } catch (e) { }
             };
             };
             // ======================================
             // ======================================


             (async()=>{
             (async () => {
                 if (isAnon) {
                 if (isAnon) {
                     // デフォルトの匿名名(IPハッシュベース)
                     // デフォルトの匿名名(IPハッシュベース)
126行目: 152行目:
                 }
                 }


                 if(!mw.forum.username) {
                 if (!mw.forum.username) {
                    // フォールバック
                    // フォールバック
                    try {
                    try {
                        const token = await fetch(mw.config.get('wgArticlePath').replace('$1',"特別:トークページ")).then(r=>r.text());
                        const token = await fetch(mw.config.get('wgArticlePath').replace('$1', "特別:トークページ")).then(r => r.text());
                        const doc = new DOMParser().parseFromString(token,'text/html');
                        const doc = new DOMParser().parseFromString(token, 'text/html');
                        const title = doc.querySelector('.mw-page-title-main');
                        const title = doc.querySelector('.mw-page-title-main');
                        if(title) mw.forum.username = title.innerText;
                        if (title) mw.forum.username = title.innerText;
                    } catch(e) {}
                    } catch (e) { }
                 }
                 }
               
 
                 // メイン処理の開始
                 // メイン処理の開始
                 startForum();
                 startForum();
146行目: 172行目:
                 // 匿名ユーザーのみ投稿フォームの上部に表示される
                 // 匿名ユーザーのみ投稿フォームの上部に表示される
                 const anonSettingsHtml = isAnon ? `<details id="f-name-settings" style="margin-bottom:.8em;border:1px solid #ccc;padding:.5em;border-radius:3px;">
                 const anonSettingsHtml = isAnon ? `<details id="f-name-settings" style="margin-bottom:.8em;border:1px solid #ccc;padding:.5em;border-radius:3px;">
<summary style="cursor:pointer;font-weight:bold;user-select:none;">名前・トリップの設定</summary>
<summary style="cursor:pointer;font-weight:bold;user-select:none;">名前・トリップの設定</summary>
<div style="margin-top:.5em;">
<div style="margin-top:.5em;">
   <p style="margin:.2em 0 .5em;font-size:.9em;color:#555;">
   <p style="margin:.2em 0 .5em;font-size:.9em;color:#555;">
165行目: 191行目:
</details>` : '';
</details>` : '';
                 // =================================================
                 // =================================================
                // ========== 書式ツールバーHTML ==========
                const toolbarHtml = `<div id="f-toolbar" style="margin-bottom:.4em;display:flex;gap:.3em;flex-wrap:wrap;align-items:center;padding:.3em;background:#f8f8f8;border:1px solid #ccc;border-radius:3px;">
<span style="font-size:.85em;color:#555;margin-left:.3em;">文字色:</span>
${['#cc0000', '#e07000', '#007700', '#0055cc', '#7700aa', '#555555'].map(c =>
                    `<span class="f-tb-color" data-color="${c}" title="${c}" style="display:inline-block;width:1.2em;height:1.2em;background:${c};border:2px solid #aaa;border-radius:2px;cursor:pointer;vertical-align:middle;"></span>`
                ).join('')}
</div>`;
                // =========================================


                 const msg = Object.assign({
                 const msg = Object.assign({
                     loading: '<p>読み込み中...</p>',
                     loading: '<p>読み込み中...</p>',
                     postform: `<div id="f-form"><h2>投稿${fedit ? `の編集 (<a href="#post-${fedit}")>#${fedit}</a>)` : ''}</h2>${anonSettingsHtml}<div class="mw-ui-checkbox" style="margin-bottom:.3em;${fedit ? 'display:none;' : ''}"><input type="checkbox" class="mw-ui-checkbox" id="f-reply-cb"><label for="f-reply-cb" style="user-select:none;">返信する</label></div><input type="number" id="f-reply" class="mw-ui-input" style="widht:50%;margin-bottom:.5em;"><textarea accesskey="," id="wpTextbox1" cols="80" rows="25" class="mw-editfont-monospace"></textarea><input type="button" id="f-post" value="投稿" class="mw-ui-button mw-ui-progressive" style="margin-top:.5em;"><input type="button" id="f-preview" value="プレビュー" class="mw-ui-button" style="margin: .5em 0 0 .5em;"><fieldset hidden><legend>プレビュー</legend><div id="f-preview-content"></div></fieldset><style>.mw-ui-checkbox:has(#f-reply-cb:checked)+input{display:block;}#f-reply{display:none;}</style></div>`,
                     postform: `<div id="f-form"><h2>投稿${fedit ? `の編集 (<a href="#post-${fedit}")>#${fedit}</a>)` : ''}</h2>${anonSettingsHtml}<div class="mw-ui-checkbox" style="margin-bottom:.3em;${fedit ? 'display:none;' : ''}"><input type="checkbox" class="mw-ui-checkbox" id="f-reply-cb"><label for="f-reply-cb" style="user-select:none;">返信する</label></div><input type="number" id="f-reply" class="mw-ui-input" style="widht:50%;margin-bottom:.5em;">${toolbarHtml}<textarea accesskey="," id="wpTextbox1" cols="80" rows="25" class="mw-editfont-monospace"></textarea><input type="button" id="f-post" value="投稿" class="mw-ui-button mw-ui-progressive" style="margin-top:.5em;"><input type="button" id="f-preview" value="プレビュー" class="mw-ui-button" style="margin: .5em 0 0 .5em;"><fieldset hidden><legend>プレビュー</legend><div id="f-preview-content"></div></fieldset><style>.mw-ui-checkbox:has(#f-reply-cb:checked)+input{display:block;}#f-reply{display:none;}</style></div>`,
                     postsummary: 'post',
                     postsummary: 'post',
                     replysummary: 'reply to',
                     replysummary: 'reply to',
                     editsummary: 'edit',
                     editsummary: 'edit',
                     deletesummary: 'delete post',
                     deletesummary: 'delete post',
                     toppage_css: "<style>.f-sticky>td:first-child>a::before{content:'';background-image:url(https://upload.wikimedia.org/wikipedia/commons/a/a5/OOjs_UI_icon_pushPin.svg);width:.8em;height:.8em;margin-right:.2em;display:inline-block;background-size:.8em}#f-loadmore{display:block;margin-left:auto;margin-right:auto;}</style>",
                    deletethreadsummary: 'スレッドの削除',
                     toppage_css: "<style>.f-sticky>td:first-child>a::before{content:'';background-image:url(https://upload.wikimedia.org/wikipedia/commons/a/a5/OOjs_UI_icon_pushPin.svg);width:.8em;height:.8em;margin-right:.2em;display:inline-block;background-size:.8em}#f-loadmore{display:block;margin-left:auto;margin-right:auto;}#f-create-btn-mobile{display:none;margin:1em 0;text-align:right;}@media (max-width:768px){.mw-indicators{display:block!important;}#f-create-btn-mobile{display:block;}}#f-notice{background:#fffacd;border:1px solid #daa520;border-radius:4px;padding:1em;margin:1em 0;font-size:.95em;line-height:1.6;}</style>",
                    toppage_notice: mw.forum.notice ? `<div id="f-notice">${mw.forum.notice}</div>` : '',
                     load_more: '<input type="button" value="もっと読み込む" class="mw-ui-button" id="f-loadmore">',
                     load_more: '<input type="button" value="もっと読み込む" class="mw-ui-button" id="f-loadmore">',
                     create: '<input type="button" class="mw-ui-button mw-ui-progressive" value="スレッドを作成">',
                     create: '<input type="button" class="mw-ui-button mw-ui-progressive" value="スレッドを作成">',
                     createform: '<div id="f-form"><label>スレッド名: <input type="text" id="f-threadname" class="mw-ui-input" style="margin-bottom:.5em;"></label><textarea accesskey="," id="wpTextbox1" cols="80" rows="25" class="mw-editfont-monospace"></textarea><input type="button" value="スレッドを作成" id="f-create" class="mw-ui-button mw-ui-progressive" style="margin-top:.5em;"><input type="button" id="f-preview" value="プレビュー" class="mw-ui-button" style="margin: .5em 0 0 .5em;"><fieldset hidden><legend>プレビュー</legend><div id="f-preview-content"></div></fieldset></div>',
                     createform: `<div id="f-form"><label>スレッド名: <input type="text" id="f-threadname" class="mw-ui-input" style="margin-bottom:.5em;"></label>${toolbarHtml}<textarea accesskey="," id="wpTextbox1" cols="80" rows="25" class="mw-editfont-monospace"></textarea><input type="button" value="スレッドを作成" id="f-create" class="mw-ui-button mw-ui-progressive" style="margin-top:.5em;"><input type="button" id="f-preview" value="プレビュー" class="mw-ui-button" style="margin: .5em 0 0 .5em;"><fieldset hidden><legend>プレビュー</legend><div id="f-preview-content"></div></fieldset></div>`,
                     createthreadsummary: 'スレッドの作成',
                     createthreadsummary: 'スレッドの作成',
                     posterror: 'エラー: 投稿できませんでした',
                     posterror: 'エラー: 投稿できませんでした',
                     deleteerror: 'エラー: 削除できませんでした',
                     deleteerror: 'エラー: 削除できませんでした',
                     gotoform: '<a href="#wpTextbox1"><input type="button" value="投稿フォームへ" class="mw-ui-button" id="f-loadmore"></a>',
                     gotoform: '<a href="#wpTextbox1"><input type="button" value="投稿フォームへ" class="mw-ui-button" id="f-loadmore"></a>',
                     editlink: `<a href="${(()=>{const url=new URL(location.href);url.searchParams.set('fedit','$1');return url;})()}" style="margin-left:7px;">編集</a>`
                     editlink: `<a href="${(() => { const url = new URL(location.href); url.searchParams.set('fedit', '$1'); return url; })()}" style="margin-left:7px;">編集</a>`
                 }, mw.forum.msg);
                 }, mw.forum.msg);


191行目: 228行目:
                             list: "allpages",
                             list: "allpages",
                             formatversion: "2",
                             formatversion: "2",
                             [(cont ? 'apcontinue' : 'apprefix')]: (cont ?? new mw.Title(mw.forum.toppage).title+'/'),
                             [(cont ? 'apcontinue' : 'apprefix')]: (cont ?? new mw.Title(mw.forum.toppage).title + '/'),
                             apnamespace: new mw.Title(mw.forum.toppage).namespace,
                             apnamespace: new mw.Title(mw.forum.toppage).namespace,
                             apfilterredir: "nonredirects",
                             apfilterredir: "nonredirects",
209行目: 246行目:
                         });
                         });
                     },
                     },
                     preview: function(){
                     preview: function () {
                         new mw.Api().get({
                         new mw.Api().get({
                             action: "parse",
                             action: "parse",
226行目: 263行目:
                     },
                     },
                     deletePost: async (postId) => {
                     deletePost: async (postId) => {
                         if(!confirm('本当にこの投稿を削除しますか?')) return;
                         if (!confirm('本当にこの投稿を削除しますか?')) return;
                         const source = await (await fetch(mw.config.get('wgScript') + `?title=${mw.config.get('wgPageName')}&action=raw`)).text();
                         const source = await (await fetch(mw.config.get('wgScript') + `?title=${mw.config.get('wgPageName')}&action=raw`)).text();
                         const newText = source.replace(new RegExp(`(\\{\\{post\\|(.*?)\\|${postId}\\|(.*?)\\|4=)((.|\n)*?)(}})`), '$1[Deleted]$6');
                         const newText = source.replace(new RegExp(`(\\{\\{post\\|(.*?)\\|${postId}\\|(.*?)\\|4=)((.|\n)*?)(}})`), '$1[Deleted]$6');
                         new mw.Api().postWithToken('csrf', {
                         fetch('/wiki/forum-proxy.php', {
                             action: 'edit',
                            method: 'POST',
                             title: mw.config.get('wgPageName'),
                             headers: { 'Content-Type': 'application/json' },
                            text: newText,
                             body: JSON.stringify({
                            summary: `${msg.deletesummary} #${postId}`,
                                title: mw.config.get('wgPageName'),
                             format: 'json'
                                text: newText,
                         }).done(() => {
                                summary: `${msg.deletesummary} #${postId}`
                             location.reload();
                             })
                         }).fail(() => {
                         }).then(r => r.json()).then(data => {
                             if (data.result === 'Success') {
                                location.reload();
                            } else {
                                mw.notify(msg.deleteerror);
                            }
                         }).catch(() => {
                             mw.notify(msg.deleteerror);
                             mw.notify(msg.deleteerror);
                         });
                         });
                     },
                     },
                    // ========== スレッド削除(管理者のみ) ==========
                    deleteThread: async () => {
                        const threadName = mw.config.get('wgPageName').replace(mw.forum.toppage + '/', '');
                        if (!confirm(`スレッド「${threadName}」を削除しますか?\nこの操作は取り消せません。`)) return;
                        fetch('/wiki/forum-proxy.php', {
                            method: 'POST',
                            headers: { 'Content-Type': 'application/json' },
                            body: JSON.stringify({
                                action: 'delete',
                                title: mw.config.get('wgPageName'),
                                reason: msg.deletethreadsummary
                            })
                        }).then(r => r.json()).then(data => {
                            if (data.result === 'Success') {
                                location.href = mw.config.get('wgArticlePath').replace('$1', mw.forum.toppage);
                            } else {
                                mw.notify(msg.deleteerror);
                            }
                        }).catch(() => mw.notify(msg.deleteerror));
                    },
                    // =================================================
                    // ========== 書式ツールバーの初期化 ==========
                    initToolbar: () => {
                        const toolbar = document.querySelector('#f-toolbar');
                        if (!toolbar) return;
                        const ta = document.querySelector('#wpTextbox1');
                        // 選択範囲をマークアップで囲むヘルパー
                        const wrapText = (start, end) => {
                            const s = ta.selectionStart, e = ta.selectionEnd;
                            const selected = ta.value.substring(s, e);
                            ta.value = ta.value.substring(0, s) + start + selected + end + ta.value.substring(e);
                            ta.selectionStart = s + start.length;
                            ta.selectionEnd = s + start.length + selected.length;
                            ta.focus();
                        };
                        // 太字・斜体ボタン
                        toolbar.querySelectorAll('.f-tb-btn').forEach(btn => {
                            btn.onclick = () => wrapText(btn.dataset.start, btn.dataset.end);
                        });
                        // 文字色ボタン
                        toolbar.querySelectorAll('.f-tb-color').forEach(btn => {
                            btn.onclick = () => wrapText(`<span style="color:${btn.dataset.color}">`, '</span>');
                        });
                    },
                    // =============================================
                     // ========== 名前設定UIの初期化 ==========
                     // ========== 名前設定UIの初期化 ==========
                     initAnonSettings: () => {
                     initAnonSettings: () => {
246行目: 339行目:
                         const nameInput = document.querySelector('#f-anon-name');
                         const nameInput = document.querySelector('#f-anon-name');
                         const tripInput = document.querySelector('#f-anon-trip');
                         const tripInput = document.querySelector('#f-anon-trip');
                         const preview = document.querySelector('#f-trip-preview');
                         const preview = document.querySelector('#f-trip-preview');
                         const saveBtn = document.querySelector('#f-save-name');
                         const saveBtn = document.querySelector('#f-save-name');
                         if (!nameInput || !tripInput) return;
                         if (!nameInput || !tripInput) return;


257行目: 350行目:
                         const updatePreview = async () => {
                         const updatePreview = async () => {
                             const name = nameInput.value.trim() || mw.forum.anonDefaultName;
                             const name = nameInput.value.trim() || mw.forum.anonDefaultName;
                             const key = tripInput.value;
                             const key = tripInput.value;
                             if (key) {
                             if (key) {
                                 const trip = await computeTrip(key);
                                 const trip = await computeTrip(key);
271行目: 364行目:
                         // 保存ボタン
                         // 保存ボタン
                         saveBtn.onclick = async () => {
                         saveBtn.onclick = async () => {
                             const newName   = nameInput.value.trim() || mw.forum.anonDefaultName;
                             const newName = nameInput.value.trim() || mw.forum.anonDefaultName;
                             const newTripKey = tripInput.value;
                             const newTripKey = tripInput.value;
                             saveAnonSettings(newName, newTripKey);
                             saveAnonSettings(newName, newTripKey);
                             mw.forum.anonName   = newName;
                             mw.forum.anonName = newName;
                             mw.forum.anonTripKey = newTripKey;
                             mw.forum.anonTripKey = newTripKey;
                             if (newTripKey) {
                             if (newTripKey) {
289行目: 382行目:


                 const mc = document.querySelector('#mw-content-text>.mw-parser-output');
                 const mc = document.querySelector('#mw-content-text>.mw-parser-output');
                 if(!mc) return;
                 if (!mc) return;


                 if(mw.config.get('wgPageName') == mw.forum.toppage){
                 if (mw.config.get('wgPageName') == mw.forum.toppage) {
                     // トップページ処理
                     // トップページ処理
                     if(mw.util.getParamValue('newthread')=='1'){
                     if (mw.util.getParamValue('newthread') == '1') {
                         mc.innerHTML = msg.createform;
                         mc.innerHTML = msg.createform;
                         document.querySelector("#f-preview").onclick = func.preview;
                         document.querySelector("#f-preview").onclick = func.preview;
                        func.initToolbar();
                         mw.loader.using('ext.wikiEditor');
                         mw.loader.using('ext.wikiEditor');
                         document.querySelector('#f-create').onclick = function(){
                         document.querySelector('#f-create').onclick = function () {
                             let content = '';
                            // ホームへ戻るリンクをページ先頭に追加
                             if(mw.forum.zeroTemplate){
                             let content = `[[${mw.forum.toppage}|← ${mw.forum.toppage}に戻る]]\n`;
                             if (mw.forum.zeroTemplate) {
                                 content += `{{post|System|0|{{subst:#timel:Y/m/d H:i:s}}|4=${mw.forum.zeroTemplate}}}\n`;
                                 content += `{{post|System|0|{{subst:#timel:Y/m/d H:i:s}}|4=${mw.forum.zeroTemplate}}}\n`;
                             }
                             }
305行目: 400行目:
                             const anonParam = isAnon ? '|anon=1' : '';
                             const anonParam = isAnon ? '|anon=1' : '';
                             content += `{{post|${mw.forum.username}|1|{{subst:#timel:Y/m/d H:i:s}}|4=${document.querySelector('#wpTextbox1').value}${anonParam}}}`;
                             content += `{{post|${mw.forum.username}|1|{{subst:#timel:Y/m/d H:i:s}}|4=${document.querySelector('#wpTextbox1').value}${anonParam}}}`;
                             new mw.Api().postWithToken('csrf', {
                             fetch('/wiki/forum-proxy.php', {
                                 action: 'edit',
                                method: 'POST',
                                 title: `${mw.forum.toppage}/${document.querySelector('#f-threadname').value}`,
                                 headers: { 'Content-Type': 'application/json' },
                                text: content,
                                 body: JSON.stringify({
                                summary: msg.createthreadsummary,
                                    title: `${mw.forum.toppage}/${document.querySelector('#f-threadname').value}`,
                                 format: 'json'
                                    text: content,
                             }).done(() => {
                                    summary: msg.createthreadsummary,
                                 location.href = `${mw.config.get('wgArticlePath').replace('$1',mw.forum.toppage)}/${document.querySelector('#f-threadname').value}`;
                                    protect: true
                             }).fail(() => {
                                 })
                             }).then(r => r.json()).then(data => {
                                 if (data.result === 'Success') {
                                    location.href = `${mw.config.get('wgArticlePath').replace('$1', mw.forum.toppage)}/${document.querySelector('#f-threadname').value}`;
                                } else {
                                    mw.notify(msg.posterror);
                                }
                             }).catch(() => {
                                 mw.notify(msg.posterror);
                                 mw.notify(msg.posterror);
                             });
                             });
321行目: 423行目:
                     mc.innerHTML = msg.loading;
                     mc.innerHTML = msg.loading;
                     func.getthreads().then((res) => {
                     func.getthreads().then((res) => {
                         mc.innerHTML = res[0] + msg.toppage_css;
                         mc.innerHTML = msg.toppage_notice + res[0] + msg.toppage_css;
                         if(res[1]){
                       
                        // モバイル用の「スレッドを作成」ボタンを追加
                        const mobileBtn = document.createElement('div');
                        mobileBtn.id = 'f-create-btn-mobile';
                        mobileBtn.innerHTML = msg.create;
                        mc.insertBefore(mobileBtn, mc.firstChild);
                        mobileBtn.querySelector('input').onclick = function () {
                            const url = new URL(window.location.href);
                            url.searchParams.set('newthread', '1');
                            location.href = url;
                        };
                       
                         if (res[1]) {
                             document.querySelector('#mw-content-text').innerHTML += msg.load_more;
                             document.querySelector('#mw-content-text').innerHTML += msg.load_more;
                             document.querySelector('#f-loadmore').onclick = function(){
                             document.querySelector('#f-loadmore').onclick = function () {
                                 func.getthreads(res[1]).then((mres) => {
                                 func.getthreads(res[1]).then((mres) => {
                                     document.querySelector('#mw-content-text>.mw-parser-output>table>tbody').innerHTML += mres[0];
                                     document.querySelector('#mw-content-text>.mw-parser-output>table>tbody').innerHTML += mres[0];
                                     if(!mres[1]){
                                     if (!mres[1]) {
                                         document.querySelector('#f-loadmore').remove();
                                         document.querySelector('#f-loadmore').remove();
                                     }
                                     }
335行目: 449行目:
                     });
                     });
                     const indicators = document.querySelector('.mw-indicators');
                     const indicators = document.querySelector('.mw-indicators');
                     if(indicators) {
                     if (indicators) {
                         indicators.innerHTML = msg.create;
                         indicators.innerHTML = msg.create;
                         indicators.querySelector("input").onclick = function(){
                         indicators.querySelector("input").onclick = function () {
                             const url=new URL(window.location.href);
                             const url = new URL(window.location.href);
                             url.searchParams.set('newthread','1');
                             url.searchParams.set('newthread', '1');
                             location.href=url;
                             location.href = url;
                         };
                         };
                     }
                     }
                 }else{
                 } else {
                     // スレッドページ処理
                     // スレッドページ処理
                     mc.innerHTML += msg.postform;
                     mc.innerHTML += msg.postform;
349行目: 463行目:
                     mw.loader.using('ext.wikiEditor');
                     mw.loader.using('ext.wikiEditor');
                     const indicators = document.querySelector('.mw-indicators');
                     const indicators = document.querySelector('.mw-indicators');
                     if(indicators) indicators.innerHTML = msg.gotoform;
                     if (indicators) indicators.innerHTML = msg.gotoform;


                     // 名前設定UIを初期化(匿名ユーザーのみ)
                     // 名前設定UIを初期化(匿名ユーザーのみ)
                     func.initAnonSettings();
                     func.initAnonSettings();
                      
 
                     document.querySelector('#f-post').onclick = (async() => {
                     // 書式ツールバーを初期化
                    func.initToolbar();
 
                    // 管理者にスレッド削除ボタンを表示
                    if (isAdmin) {
                        const delThreadBtn = document.createElement('input');
                        delThreadBtn.type = 'button';
                        delThreadBtn.value = 'スレッドを削除';
                        delThreadBtn.className = 'mw-ui-button mw-ui-destructive';
                        delThreadBtn.style.cssText = 'margin-left:.5em;';
                        delThreadBtn.onclick = func.deleteThread;
                        const ind = document.querySelector('.mw-indicators');
                        if (ind) ind.appendChild(delThreadBtn);
                    }
 
                     document.querySelector('#f-post').onclick = (async () => {
                         document.querySelector('#f-post').disabled = true;
                         document.querySelector('#f-post').disabled = true;
                         const source = (await (await fetch(mw.config.get('wgScript') + `?title=${mw.config.get('wgPageName')}&action=raw`)).text());
                         const source = (await (await fetch(mw.config.get('wgScript') + `?title=${mw.config.get('wgPageName')}&action=raw`)).text());
                         const lp = source.split('{{post|').length;
                         const lp = source.split('{{post|').length - 1; // 0スレッドを考慮して投稿数+1をIDにする
                         let summary;
                         let summary;
                         if(fedit) {
                         if (fedit) {
                             summary = `${msg.editsummary} [[${mw.config.get('wgPageName')}#post-${fedit}|#${fedit}]]`;
                             summary = `${msg.editsummary} [[${mw.config.get('wgPageName')}#post-${fedit}|#${fedit}]]`;
                         } else if(document.querySelector('#f-reply-cb').checked) {
                         } else if (document.querySelector('#f-reply-cb').checked) {
                             const replyToUser = document.querySelector('[id="'+document.querySelector('#f-reply').value+'-user"]').innerText;
                             const replyToUser = document.querySelector('[id="' + document.querySelector('#f-reply').value + '-user"]').innerText;
                             summary = `${msg.postsummary} [[${mw.config.get('wgPageName')}#post-${lp}|#${lp}]] ${msg.replysummary} [[User:${replyToUser}|${replyToUser}]]`;
                             summary = `${msg.postsummary} [[${mw.config.get('wgPageName')}#post-${lp}|#${lp}]] ${msg.replysummary} [[User:${replyToUser}|${replyToUser}]]`;
                         } else {
                         } else {
371行目: 500行目:
                         const anonParam = isAnon ? '|anon=1' : '';
                         const anonParam = isAnon ? '|anon=1' : '';


                         new mw.Api().postWithToken('csrf', {
                         // プロキシ経由でBot名義に編集する
                            action: 'edit',
                        const postBody = {
                             title: mw.config.get('wgPageName'),
                             title: mw.config.get('wgPageName'),
                             [fedit ? 'text' : 'appendtext']: fedit
                             summary: summary
                                ? source.replace(new RegExp(`\\{\\{post\\|(.*?)\\|${fedit}\\|(.*?)\\|4=((.|\n)*?)}}`), `{{post|$1|${fedit}|$2|4=${document.querySelector('#wpTextbox1').value}}}`)
                        };
                                : `\n{{post|${mw.forum.username}|${lp}|{{subst:#timel:Y/m/d H:i:s}}|4=${document.querySelector('#wpTextbox1').value}${document.querySelector('#f-reply-cb').checked ? '|re='+document.querySelector('#f-reply').value : ''}${anonParam}}}`,
                        if (fedit) {
                             summary: summary,
                            postBody.text = source.replace(new RegExp(`\\{\\{post\\|(.*?)\\|${fedit}\\|(.*?)\\|4=((.|\n)*?)}}`), `{{post|$1|${fedit}|$2|4=${document.querySelector('#wpTextbox1').value}}}`);
                             format: 'json'
                        } else {
                         }).done(() => {
                            postBody.appendtext = `\n{{post|${mw.forum.username}|${lp}|{{subst:#timel:Y/m/d H:i:s}}|4=${document.querySelector('#wpTextbox1').value}${document.querySelector('#f-reply-cb').checked ? '|re=' + document.querySelector('#f-reply').value : ''}${anonParam}}}`;
                             if(fedit){
                        }
                                location.href = `${mw.util.getUrl()}#post-${fedit}`;
                        fetch('/wiki/forum-proxy.php', {
                             }else{
                             method: 'POST',
                                 location.reload();
                             headers: { 'Content-Type': 'application/json' },
                            body: JSON.stringify(postBody)
                         }).then(r => r.json()).then(data => {
                             if (data.result === 'Success') {
                                if (fedit) {
                                    location.href = `${mw.util.getUrl()}#post-${fedit}`;
                                } else {
                                    location.reload();
                                }
                             } else {
                                 mw.notify(msg.posterror);
                                document.querySelector('#f-post').disabled = false;
                             }
                             }
                         }).fail(() => {
                         }).catch(() => {
                             mw.notify(msg.posterror);
                             mw.notify(msg.posterror);
                             document.querySelector('#f-post').disabled = false;
                             document.querySelector('#f-post').disabled = false;
391行目: 531行目:
                     });
                     });


                     if(fedit){
                     if (fedit) {
                         location.hash = '#f-form';
                         location.hash = '#f-form';
                         document.querySelector('#wpTextbox1').focus();
                         document.querySelector('#wpTextbox1').focus();
                         (async()=>{(await (await fetch(mw.config.get('wgScript') + `?title=${mw.config.get('wgPageName')}&action=raw`)).text()).replace(new RegExp(`\\{\\{post\\|(.*?)\\|${fedit}\\|(.*?)\\|4=((.|\n)*?)}}`), (m, u, d, t)=>{document.querySelector('#wpTextbox1').value = t;})})();
                         (async () => { (await (await fetch(mw.config.get('wgScript') + `?title=${mw.config.get('wgPageName')}&action=raw`)).text()).replace(new RegExp(`\\{\\{post\\|(.*?)\\|${fedit}\\|(.*?)\\|4=((.|\n)*?)}}`), (m, u, d, t) => { document.querySelector('#wpTextbox1').value = t; }) })();
                     }
                     }


412行目: 552行目:
                             delLink.style.color = '#d33';
                             delLink.style.color = '#d33';
                             delLink.style.cursor = 'pointer';
                             delLink.style.cursor = 'pointer';
                             delLink.onclick = function() {
                             delLink.onclick = function () {
                                 const numText = header.querySelector('.f-number-text').innerText;
                                 const numText = header.querySelector('.f-number-text').innerText;
                                 const postId = numText.replace('#', '');
                                 const postId = numText.replace('#', '');
421行目: 561行目:
                         });
                         });
                     }
                     }
                    // ========== レスホバープレビュー ==========
                    // >>>N のリンクにマウスを乗せると該当投稿をポップアップ表示する
                    (() => {
                        // ポップアップ用の浮動div(ページに1つだけ作成)
                        const popup = document.createElement('div');
                        popup.id = 'f-hover-popup';
                        popup.style.cssText = [
                            'position:fixed',
                            'z-index:9999',
                            'max-width:480px',
                            'min-width:200px',
                            'background:#fff',
                            'border:2px solid #BBBBBB',
                            'border-radius:3px',
                            'box-shadow:2px 4px 12px rgba(0,0,0,.2)',
                            'pointer-events:none',  // ポップアップ自体はマウスイベントを無視
                            'display:none',
                            'font-size:.9em',
                            'line-height:1.4',
                        ].join(';');
                        document.body.appendChild(popup);
                        let hideTimer = null;
                        const showPopup = (anchorEl, postId) => {
    const target = document.querySelector(`#post-${postId}`);
    if (!target) return;
    // 対象投稿をクローンしてポップアップに表示
    const clone = target.cloneNode(true);
    clone.querySelectorAll('a[href*="fedit"], span[style*="#d33"]').forEach(el => el.remove());
    popup.innerHTML = '';
    popup.appendChild(clone);
    // ポップアップの位置をリンクの近くに配置(モバイル対応)
    const rect = anchorEl.getBoundingClientRect();
    const viewportWidth = window.innerWidth;
    const viewportHeight = window.innerHeight;
   
    // ポップアップの最大幅を画面幅の90%に制限
    const maxWidth = Math.min(480, viewportWidth * 0.9);
    popup.style.maxWidth = maxWidth + 'px';
   
    // 一度表示して実際の高さを取得
    popup.style.display = 'block';
    popup.style.visibility = 'hidden';
    const popupHeight = popup.offsetHeight;
    popup.style.visibility = 'visible';
   
    // 縦位置:下に余裕があれば下、なければ上
    const spaceBelow = viewportHeight - rect.bottom;
    const top = spaceBelow > popupHeight + 10
        ? rect.bottom + 4
        : Math.max(4, rect.top - popupHeight - 4);
   
    // 横位置:画面からはみ出ないように調整
    let left = rect.left;
    if (left + maxWidth > viewportWidth - 10) {
        left = viewportWidth - maxWidth - 10;
    }
    if (left < 10) {
        left = 10;
    }
   
    popup.style.left = left + 'px';
    popup.style.top = top + 'px';
};
                        const hidePopup = () => {
                            popup.style.display = 'none';
                        };
                        // >>>N リンクにホバーイベントを設定
                        document.querySelectorAll('.f-content a[href^="#post-"]').forEach(link => {
                            const postId = link.getAttribute('href').replace('#post-', '');
                            link.addEventListener('mouseenter', () => {
                                clearTimeout(hideTimer);
                                showPopup(link, postId);
                            });
                            link.addEventListener('mouseleave', () => {
                                // 少し遅延させてチラつきを防ぐ
                                hideTimer = setTimeout(hidePopup, 100);
                            });
                        });
                    })();
                    // ==========================================
                 }
                 }
             } // end startForum
             } // end startForum
         });
         });
     };
     };
   
 
     if (window.mw && window.mw.loader) {
     if (window.mw && window.mw.loader) {
         checkConfig();
         checkConfig();

2026年2月23日 (月) 17:09時点における最新版

// <nowiki>
(() => {
    // 設定の読み込み待機
    const checkConfig = () => {
        if (!window.mw || !mw.config || !mw.loader) return;

        // 設定がない場合のデフォルト値
        mw.forum = mw.forum || {};
        mw.forum.toppage = mw.forum.toppage || 'KenryoBBS';
        mw.forum.threads = mw.forum.threads || 20;
        mw.forum.sticky = mw.forum.sticky || [];

        const fedit = mw.util.getParamValue('fedit');

        // ページ判定(掲示板ページでなければ終了)
        if (!new RegExp(`^${mw.forum.toppage}(|/.*)$`).test(mw.config.get('wgPageName')) || mw.config.get('wgAction') != 'view') {
            return;
        }

        // 編集モード時のユーザーチェック
        if (fedit && document.querySelector(`[id="${fedit}-user"]`) && document.querySelector(`[id="${fedit}-user"]`).innerText != mw.config.get('wgUserName')) {
            return;
        }

        // UIモジュールの読み込み
        mw.loader.load(['mediawiki.ui.button', 'mediawiki.ui.input', 'mediawiki.ui.checkbox']);

        // ========== 直接編集の非表示(管理者以外) ==========
        // 掲示板ページで「編集」「ソースを編集」タブを隠し、
        // 直接ウィキテキストを操作して投稿を削除できないようにする。
        // ※ JS無効化や直接APIアクセスは防げないため、完全な保護が
        //   必要な場合は LocalSettings.php でページを保護してください。
        if (!mw.config.get('wgUserGroups').includes('sysop')) {
            const editBlockStyle = document.createElement('style');
            editBlockStyle.textContent = [
                '#ca-edit',          // 「編集」タブ
                '#ca-ve-edit',       // VisualEditorタブ
                '#ca-editsource',    // 「ソースを編集」タブ
                '.mw-editsection',   // 節編集リンク
                '#t-permalink',      // 念のため固定リンクも非表示
                '#ca-history',       // 「履歴を表示」タブ
                '#t-upload',         // サイドバーのアップロードリンク
            ].join(',') + '{display:none!important;}';
            document.head.appendChild(editBlockStyle);
        }
        // =====================================================

        mw.loader.using(['mediawiki.util', 'mediawiki.user', 'mediawiki.api'], function () {
            // 動物リスト
            const animals = [
                'イヌ', 'ネコ', 'ハムスター', 'ウシ', 'ウマ', 'ヒツジ', 'ヤギ', 'ブタ', 'ニワトリ', 'アヒル',
                'クマ', 'サル', 'ゴリラ', 'チンパンジー', 'オランウータン', 'マンドリル',
                'チーター', 'ヒョウ', 'ジャガー', 'ピューマ', 'ハイエナ', 'ミーアキャット',
                'シマウマ', 'ガゼル', 'インパラ', 'バッファロー', 'ヌー', 'アルパカ', 'リャマ', 'ラクダ',
                'カンガルー', 'ワラビー', 'ウォンバット', 'カモノハシ', 'ハリモグラ',
                'アザラシ', 'アシカ', 'セイウチ', 'オットセイ', 'ジュゴン', 'マナティ', 'ラッコ',
                'シャチ', 'ベルーガ', 'マッコウクジラ', 'ザトウクジラ', 'ジンベエザメ', 'ホオジロザメ', 'ハンマーヘッドシャーク',
                'エイ', 'マンボウ', 'タツノオトシゴ', 'ウツボ', 'クリオネ', 'クラゲ', 'サンゴ', 'イソギンチャク',
                'ワシ', 'トンビ', 'ハト', 'インコ', 'オウム', 'ブンチョウ', 'カナリア', 
                'ダチョウ', 'エミュー', 'ヒクイドリ', 'キウィ', 'フラミンゴ', 'クジャク', 'ハクチョウ',
                'カワセミ', 'ハヤブサ', 'ミミズク', 'キツツキ', 'カモメ', 'ペリカン',
                'ワニ', 'ヘビ', 'トカゲ', 'カメレオン', 'イグアナ', 'ヤモリ', 'コモドドラゴン',
                'カエル', 'イモリ', 'サンショウウオ', 'ウーパールーパー',
                'アリクイ', 'アルマジロ', 'ヤマアラシ', 'スカンク', 'イタチ', 'テン', 'カワウソ', 'ビーバー',
                'ムササビ', 'モモンガ', 'フェレット', 'チンチラ', 'デグー', 'モルモット',
                'ドラゴン', 'ユニコーン', 'ペガサス', 'フェニックス', 'グリフォン', 'ケンタウロス', 'ケルベロス',
                'ヒドラ', 'キマイラ', 'バジリスク', 'コカトリス', 'ワイバーン', 'マンティコア', 'ゴーレム',
                'クラーケン', 'リヴァイアサン', 'シーサーペント', '人魚', 'セイレーン',
                'カッパ', '天狗', '鬼', '龍', '麒麟', '鳳凰', '霊亀', '八咫烏',
                'ツチノコ', 'ネッシー', 'イエティ', 'ビッグフット', 'チュパカブラ', 'モンゴリアンデスワーム',
                'カブトムシ', 'クワガタ', 'チョウ', 'トンボ', 'セミ', 'カマキリ', 'ハチ', 'アリ',
                'クモ', 'サソリ', 'ムカデ', 'ダンゴムシ', 'ペンギン', 'イルカ', 'コアラ', 'パンダ', 'レッサーパンダ',
                'カピバラ', 'ナマケモノ', 'オポッサム', 'ミツバチ', 'テントウムシ', 'ホタル', 'カブトガニ', 'ダイオウグソクムシ',
                'シーラカンス', 'オウムガイ', 'アノマロカリス', 'ウミサソリ', 'トリロバイト', 'ディメトロドン', 'エダフォサウルス',
                'プレシオサウルス', 'モササウルス', 'ティラノサウルス', 'トリケラトプス', 'ステゴサウルス', 'ブラキオサウルス',
                'アンキロサウルス', 'ディプロドクス', 'ヴェロキラプトル', 'ギガノトサウルス', 'カルノタウルス', 'メガロサウルス',
                'スピノサウルス', 'イグアノドン', 'パラサウロロフス', 'エドモントサウルス', 'ヒプシロフォドン', 'マイアサウラ',
                'プロトケラトプス', 'オヴィラプトル', 'オルニトミムス', 'シノサウルス', 'サウロロフス', 'サウルハドン', 'ガリミムス',
                'アルバートサウルス', 'ダコタラプトル', 'アクロカントサウルス', 'シェノサウルス', 'エラスモサウルス', 'イクチオサウルス',
                'オルニトレステス', 'テリジノサウルス', 'アマルガサウルス', 'シンラプトル'
            ];

            const isAnon = mw.user.isAnon();
            const isAdmin = mw.config.get('wgUserGroups').includes('sysop');

            // ユーザー名(IPなど)をハッシュ化して動物インデックスに変換
            const stringToHash = (str) => {
                let hash = 0;
                if (!str || str.length === 0) return hash;
                for (let i = 0; i < str.length; i++) {
                    const char = str.charCodeAt(i);
                    hash = ((hash << 5) - hash) + char;
                    hash = hash & hash;
                }
                return Math.abs(hash);
            };

            // ========== トリップ機能 ==========
            // SHA-256を使ってトリップコードを生成する
            // 入力: 秘密のキー文字列
            // 出力: "◆" + Base64の先頭10文字
            const computeTrip = async (key) => {
                const encoder = new TextEncoder();
                const data = encoder.encode(key);
                const hashBuffer = await crypto.subtle.digest('SHA-256', data);
                const bytes = new Uint8Array(hashBuffer);
                // バイト列をBase64文字列に変換
                let binary = '';
                for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
                const base64 = btoa(binary);
                return '◆' + base64.slice(0, 10);
            };

            // localStorageから匿名ユーザーの名前設定を読み込む
            const loadAnonSettings = () => {
                try {
                    const saved = localStorage.getItem('forum_anon_settings');
                    return saved ? JSON.parse(saved) : null;
                } catch (e) { return null; }
            };

            // 匿名ユーザーの名前設定をlocalStorageに保存する
            const saveAnonSettings = (name, tripKey) => {
                try {
                    localStorage.setItem('forum_anon_settings', JSON.stringify({ name, tripKey }));
                } catch (e) { }
            };
            // ======================================

            (async () => {
                if (isAnon) {
                    // デフォルトの匿名名(IPハッシュベース)
                    const identifier = mw.user.getName() || mw.user.sessionId();
                    const index = stringToHash(identifier) % animals.length;
                    const defaultName = '匿名' + animals[index];

                    // 保存済み設定があればそれを使う
                    const saved = loadAnonSettings();
                    mw.forum.anonDefaultName = defaultName;
                    mw.forum.anonName = (saved && saved.name) ? saved.name : defaultName;
                    mw.forum.anonTripKey = (saved && saved.tripKey) ? saved.tripKey : '';

                    // トリップがあれば計算してusernameに反映
                    if (mw.forum.anonTripKey) {
                        const trip = await computeTrip(mw.forum.anonTripKey);
                        mw.forum.username = mw.forum.anonName + trip;
                    } else {
                        mw.forum.username = mw.forum.anonName;
                    }
                } else {
                    mw.forum.username = mw.config.get('wgUserName');
                }

                if (!mw.forum.username) {
                    // フォールバック
                    try {
                        const token = await fetch(mw.config.get('wgArticlePath').replace('$1', "特別:トークページ")).then(r => r.text());
                        const doc = new DOMParser().parseFromString(token, 'text/html');
                        const title = doc.querySelector('.mw-page-title-main');
                        if (title) mw.forum.username = title.innerText;
                    } catch (e) { }
                }

                // メイン処理の開始
                startForum();
            })();

            // メイン処理関数
            function startForum() {

                // ========== 名前・トリップ設定UIのHTML ==========
                // 匿名ユーザーのみ投稿フォームの上部に表示される
                const anonSettingsHtml = isAnon ? `<details id="f-name-settings" style="margin-bottom:.8em;border:1px solid #ccc;padding:.5em;border-radius:3px;">
<summary style="cursor:pointer;font-weight:bold;user-select:none;">名前・トリップの設定</summary>
<div style="margin-top:.5em;">
  <p style="margin:.2em 0 .5em;font-size:.9em;color:#555;">
    トリップキーを設定すると、投稿名の後ろに <b>◆記号+固有コード</b> が付き、なりすましを防げます。<br>
    キーは他人に見えませんが、同じキーからは常に同じコードが生成されます。
  </p>
  <label style="display:block;margin-bottom:.4em;">
    名前:
    <input type="text" id="f-anon-name" class="mw-ui-input" placeholder="例: 名無しさん" style="width:200px;display:inline-block;margin-left:.5em;">
  </label>
  <label style="display:block;margin-bottom:.4em;">
    トリップキー:
    <input type="text" id="f-anon-trip" class="mw-ui-input" placeholder="秘密のキー(省略可・日本語可)" style="width:200px;display:inline-block;margin-left:.5em;" autocomplete="off">
  </label>
  <span id="f-trip-preview" style="font-size:.9em;color:#555;display:block;margin-bottom:.4em;"></span>
  <input type="button" id="f-save-name" value="設定を保存" class="mw-ui-button">
</div>
</details>` : '';
                // =================================================

                // ========== 書式ツールバーHTML ==========
                const toolbarHtml = `<div id="f-toolbar" style="margin-bottom:.4em;display:flex;gap:.3em;flex-wrap:wrap;align-items:center;padding:.3em;background:#f8f8f8;border:1px solid #ccc;border-radius:3px;">
<span style="font-size:.85em;color:#555;margin-left:.3em;">文字色:</span>
${['#cc0000', '#e07000', '#007700', '#0055cc', '#7700aa', '#555555'].map(c =>
                    `<span class="f-tb-color" data-color="${c}" title="${c}" style="display:inline-block;width:1.2em;height:1.2em;background:${c};border:2px solid #aaa;border-radius:2px;cursor:pointer;vertical-align:middle;"></span>`
                ).join('')}
</div>`;
                // =========================================

                const msg = Object.assign({
                    loading: '<p>読み込み中...</p>',
                    postform: `<div id="f-form"><h2>投稿${fedit ? `の編集 (<a href="#post-${fedit}")>#${fedit}</a>)` : ''}</h2>${anonSettingsHtml}<div class="mw-ui-checkbox" style="margin-bottom:.3em;${fedit ? 'display:none;' : ''}"><input type="checkbox" class="mw-ui-checkbox" id="f-reply-cb"><label for="f-reply-cb" style="user-select:none;">返信する</label></div><input type="number" id="f-reply" class="mw-ui-input" style="widht:50%;margin-bottom:.5em;">${toolbarHtml}<textarea accesskey="," id="wpTextbox1" cols="80" rows="25" class="mw-editfont-monospace"></textarea><input type="button" id="f-post" value="投稿" class="mw-ui-button mw-ui-progressive" style="margin-top:.5em;"><input type="button" id="f-preview" value="プレビュー" class="mw-ui-button" style="margin: .5em 0 0 .5em;"><fieldset hidden><legend>プレビュー</legend><div id="f-preview-content"></div></fieldset><style>.mw-ui-checkbox:has(#f-reply-cb:checked)+input{display:block;}#f-reply{display:none;}</style></div>`,
                    postsummary: 'post',
                    replysummary: 'reply to',
                    editsummary: 'edit',
                    deletesummary: 'delete post',
                    deletethreadsummary: 'スレッドの削除',
                    toppage_css: "<style>.f-sticky>td:first-child>a::before{content:'';background-image:url(https://upload.wikimedia.org/wikipedia/commons/a/a5/OOjs_UI_icon_pushPin.svg);width:.8em;height:.8em;margin-right:.2em;display:inline-block;background-size:.8em}#f-loadmore{display:block;margin-left:auto;margin-right:auto;}#f-create-btn-mobile{display:none;margin:1em 0;text-align:right;}@media (max-width:768px){.mw-indicators{display:block!important;}#f-create-btn-mobile{display:block;}}#f-notice{background:#fffacd;border:1px solid #daa520;border-radius:4px;padding:1em;margin:1em 0;font-size:.95em;line-height:1.6;}</style>",
                    toppage_notice: mw.forum.notice ? `<div id="f-notice">${mw.forum.notice}</div>` : '',
                    load_more: '<input type="button" value="もっと読み込む" class="mw-ui-button" id="f-loadmore">',
                    create: '<input type="button" class="mw-ui-button mw-ui-progressive" value="スレッドを作成">',
                    createform: `<div id="f-form"><label>スレッド名: <input type="text" id="f-threadname" class="mw-ui-input" style="margin-bottom:.5em;"></label>${toolbarHtml}<textarea accesskey="," id="wpTextbox1" cols="80" rows="25" class="mw-editfont-monospace"></textarea><input type="button" value="スレッドを作成" id="f-create" class="mw-ui-button mw-ui-progressive" style="margin-top:.5em;"><input type="button" id="f-preview" value="プレビュー" class="mw-ui-button" style="margin: .5em 0 0 .5em;"><fieldset hidden><legend>プレビュー</legend><div id="f-preview-content"></div></fieldset></div>`,
                    createthreadsummary: 'スレッドの作成',
                    posterror: 'エラー: 投稿できませんでした',
                    deleteerror: 'エラー: 削除できませんでした',
                    gotoform: '<a href="#wpTextbox1"><input type="button" value="投稿フォームへ" class="mw-ui-button" id="f-loadmore"></a>',
                    editlink: `<a href="${(() => { const url = new URL(location.href); url.searchParams.set('fedit', '$1'); return url; })()}" style="margin-left:7px;">編集</a>`
                }, mw.forum.msg);

                const func = {
                    getthreads: async (cont) => {
                        return await new mw.Api().get({
                            action: "query",
                            format: "json",
                            list: "allpages",
                            formatversion: "2",
                            [(cont ? 'apcontinue' : 'apprefix')]: (cont ?? new mw.Title(mw.forum.toppage).title + '/'),
                            apnamespace: new mw.Title(mw.forum.toppage).namespace,
                            apfilterredir: "nonredirects",
                            aplimit: mw.forum.threads
                        }).then(async (allpages) => {
                            return await new mw.Api().get({
                                action: "parse",
                                format: "json",
                                text: `{{#invoke:Thread|main|1=${cont ? '{}' : JSON.stringify(mw.forum.sticky)}|2=${JSON.stringify(allpages.query.allpages)}${cont ? '|3=true' : ''}}}`,
                                prop: "text",
                                wrapoutputclass: "",
                                contentmodel: "wikitext",
                                formatversion: "2"
                            }).then((parse) => {
                                return [parse.parse.text, (allpages.continue ? allpages.continue.apcontinue : undefined)];
                            });
                        });
                    },
                    preview: function () {
                        new mw.Api().get({
                            action: "parse",
                            format: "json",
                            text: document.querySelector("#wpTextbox1").value,
                            prop: "text",
                            preview: "true",
                            wrapoutputclass: "",
                            contentmodel: "wikitext",
                            disablelimitreport: "1",
                            formatversion: "2"
                        }).then((parse) => {
                            document.querySelector("#f-form > fieldset").hidden = false;
                            document.querySelector("#f-preview-content").innerHTML = `<div style="white-space:pre;">${parse.parse.text}</div>`;
                        });
                    },
                    deletePost: async (postId) => {
                        if (!confirm('本当にこの投稿を削除しますか?')) return;
                        const source = await (await fetch(mw.config.get('wgScript') + `?title=${mw.config.get('wgPageName')}&action=raw`)).text();
                        const newText = source.replace(new RegExp(`(\\{\\{post\\|(.*?)\\|${postId}\\|(.*?)\\|4=)((.|\n)*?)(}})`), '$1[Deleted]$6');
                        fetch('/wiki/forum-proxy.php', {
                            method: 'POST',
                            headers: { 'Content-Type': 'application/json' },
                            body: JSON.stringify({
                                title: mw.config.get('wgPageName'),
                                text: newText,
                                summary: `${msg.deletesummary} #${postId}`
                            })
                        }).then(r => r.json()).then(data => {
                            if (data.result === 'Success') {
                                location.reload();
                            } else {
                                mw.notify(msg.deleteerror);
                            }
                        }).catch(() => {
                            mw.notify(msg.deleteerror);
                        });
                    },
                    // ========== スレッド削除(管理者のみ) ==========
                    deleteThread: async () => {
                        const threadName = mw.config.get('wgPageName').replace(mw.forum.toppage + '/', '');
                        if (!confirm(`スレッド「${threadName}」を削除しますか?\nこの操作は取り消せません。`)) return;
                        fetch('/wiki/forum-proxy.php', {
                            method: 'POST',
                            headers: { 'Content-Type': 'application/json' },
                            body: JSON.stringify({
                                action: 'delete',
                                title: mw.config.get('wgPageName'),
                                reason: msg.deletethreadsummary
                            })
                        }).then(r => r.json()).then(data => {
                            if (data.result === 'Success') {
                                location.href = mw.config.get('wgArticlePath').replace('$1', mw.forum.toppage);
                            } else {
                                mw.notify(msg.deleteerror);
                            }
                        }).catch(() => mw.notify(msg.deleteerror));
                    },
                    // =================================================

                    // ========== 書式ツールバーの初期化 ==========
                    initToolbar: () => {
                        const toolbar = document.querySelector('#f-toolbar');
                        if (!toolbar) return;
                        const ta = document.querySelector('#wpTextbox1');

                        // 選択範囲をマークアップで囲むヘルパー
                        const wrapText = (start, end) => {
                            const s = ta.selectionStart, e = ta.selectionEnd;
                            const selected = ta.value.substring(s, e);
                            ta.value = ta.value.substring(0, s) + start + selected + end + ta.value.substring(e);
                            ta.selectionStart = s + start.length;
                            ta.selectionEnd = s + start.length + selected.length;
                            ta.focus();
                        };

                        // 太字・斜体ボタン
                        toolbar.querySelectorAll('.f-tb-btn').forEach(btn => {
                            btn.onclick = () => wrapText(btn.dataset.start, btn.dataset.end);
                        });

                        // 文字色ボタン
                        toolbar.querySelectorAll('.f-tb-color').forEach(btn => {
                            btn.onclick = () => wrapText(`<span style="color:${btn.dataset.color}">`, '</span>');
                        });
                    },
                    // =============================================

                    // ========== 名前設定UIの初期化 ==========
                    initAnonSettings: () => {
                        if (!isAnon) return;
                        const nameInput = document.querySelector('#f-anon-name');
                        const tripInput = document.querySelector('#f-anon-trip');
                        const preview = document.querySelector('#f-trip-preview');
                        const saveBtn = document.querySelector('#f-save-name');
                        if (!nameInput || !tripInput) return;

                        // 保存済み値をフォームに反映
                        nameInput.value = mw.forum.anonName;
                        tripInput.value = mw.forum.anonTripKey;

                        // プレビュー更新関数
                        const updatePreview = async () => {
                            const name = nameInput.value.trim() || mw.forum.anonDefaultName;
                            const key = tripInput.value;
                            if (key) {
                                const trip = await computeTrip(key);
                                preview.textContent = `投稿名プレビュー: ${name}${trip}`;
                            } else {
                                preview.textContent = `投稿名プレビュー: ${name}`;
                            }
                        };
                        nameInput.addEventListener('input', updatePreview);
                        tripInput.addEventListener('input', updatePreview);
                        updatePreview(); // 初期表示

                        // 保存ボタン
                        saveBtn.onclick = async () => {
                            const newName = nameInput.value.trim() || mw.forum.anonDefaultName;
                            const newTripKey = tripInput.value;
                            saveAnonSettings(newName, newTripKey);
                            mw.forum.anonName = newName;
                            mw.forum.anonTripKey = newTripKey;
                            if (newTripKey) {
                                const trip = await computeTrip(newTripKey);
                                mw.forum.username = newName + trip;
                            } else {
                                mw.forum.username = newName;
                            }
                            mw.notify('名前設定を保存しました。次の投稿から反映されます。');
                        };
                    }
                    // =========================================
                };

                const mc = document.querySelector('#mw-content-text>.mw-parser-output');
                if (!mc) return;

                if (mw.config.get('wgPageName') == mw.forum.toppage) {
                    // トップページ処理
                    if (mw.util.getParamValue('newthread') == '1') {
                        mc.innerHTML = msg.createform;
                        document.querySelector("#f-preview").onclick = func.preview;
                        func.initToolbar();
                        mw.loader.using('ext.wikiEditor');
                        document.querySelector('#f-create').onclick = function () {
                            // ホームへ戻るリンクをページ先頭に追加
                            let content = `[[${mw.forum.toppage}|← ${mw.forum.toppage}に戻る]]\n`;
                            if (mw.forum.zeroTemplate) {
                                content += `{{post|System|0|{{subst:#timel:Y/m/d H:i:s}}|4=${mw.forum.zeroTemplate}}}\n`;
                            }
                            // スレッド作成者がログイン済みかどうかで anon フラグを付与
                            const anonParam = isAnon ? '|anon=1' : '';
                            content += `{{post|${mw.forum.username}|1|{{subst:#timel:Y/m/d H:i:s}}|4=${document.querySelector('#wpTextbox1').value}${anonParam}}}`;
                            fetch('/wiki/forum-proxy.php', {
                                method: 'POST',
                                headers: { 'Content-Type': 'application/json' },
                                body: JSON.stringify({
                                    title: `${mw.forum.toppage}/${document.querySelector('#f-threadname').value}`,
                                    text: content,
                                    summary: msg.createthreadsummary,
                                    protect: true
                                })
                            }).then(r => r.json()).then(data => {
                                if (data.result === 'Success') {
                                    location.href = `${mw.config.get('wgArticlePath').replace('$1', mw.forum.toppage)}/${document.querySelector('#f-threadname').value}`;
                                } else {
                                    mw.notify(msg.posterror);
                                }
                            }).catch(() => {
                                mw.notify(msg.posterror);
                            });
                        };
                        return;
                    }
                    mc.innerHTML = msg.loading;
                    func.getthreads().then((res) => {
                        mc.innerHTML = msg.toppage_notice + res[0] + msg.toppage_css;
                        
                        // モバイル用の「スレッドを作成」ボタンを追加
                        const mobileBtn = document.createElement('div');
                        mobileBtn.id = 'f-create-btn-mobile';
                        mobileBtn.innerHTML = msg.create;
                        mc.insertBefore(mobileBtn, mc.firstChild);
                        mobileBtn.querySelector('input').onclick = function () {
                            const url = new URL(window.location.href);
                            url.searchParams.set('newthread', '1');
                            location.href = url;
                        };
                        
                        if (res[1]) {
                            document.querySelector('#mw-content-text').innerHTML += msg.load_more;
                            document.querySelector('#f-loadmore').onclick = function () {
                                func.getthreads(res[1]).then((mres) => {
                                    document.querySelector('#mw-content-text>.mw-parser-output>table>tbody').innerHTML += mres[0];
                                    if (!mres[1]) {
                                        document.querySelector('#f-loadmore').remove();
                                    }
                                });
                            };
                        }
                    });
                    const indicators = document.querySelector('.mw-indicators');
                    if (indicators) {
                        indicators.innerHTML = msg.create;
                        indicators.querySelector("input").onclick = function () {
                            const url = new URL(window.location.href);
                            url.searchParams.set('newthread', '1');
                            location.href = url;
                        };
                    }
                } else {
                    // スレッドページ処理
                    mc.innerHTML += msg.postform;
                    document.querySelector("#f-preview").onclick = func.preview;
                    mw.loader.using('ext.wikiEditor');
                    const indicators = document.querySelector('.mw-indicators');
                    if (indicators) indicators.innerHTML = msg.gotoform;

                    // 名前設定UIを初期化(匿名ユーザーのみ)
                    func.initAnonSettings();

                    // 書式ツールバーを初期化
                    func.initToolbar();

                    // 管理者にスレッド削除ボタンを表示
                    if (isAdmin) {
                        const delThreadBtn = document.createElement('input');
                        delThreadBtn.type = 'button';
                        delThreadBtn.value = 'スレッドを削除';
                        delThreadBtn.className = 'mw-ui-button mw-ui-destructive';
                        delThreadBtn.style.cssText = 'margin-left:.5em;';
                        delThreadBtn.onclick = func.deleteThread;
                        const ind = document.querySelector('.mw-indicators');
                        if (ind) ind.appendChild(delThreadBtn);
                    }

                    document.querySelector('#f-post').onclick = (async () => {
                        document.querySelector('#f-post').disabled = true;
                        const source = (await (await fetch(mw.config.get('wgScript') + `?title=${mw.config.get('wgPageName')}&action=raw`)).text());
                        const lp = source.split('{{post|').length - 1; // 0スレッドを考慮して投稿数+1をIDにする
                        let summary;
                        if (fedit) {
                            summary = `${msg.editsummary} [[${mw.config.get('wgPageName')}#post-${fedit}|#${fedit}]]`;
                        } else if (document.querySelector('#f-reply-cb').checked) {
                            const replyToUser = document.querySelector('[id="' + document.querySelector('#f-reply').value + '-user"]').innerText;
                            summary = `${msg.postsummary} [[${mw.config.get('wgPageName')}#post-${lp}|#${lp}]] ${msg.replysummary} [[User:${replyToUser}|${replyToUser}]]`;
                        } else {
                            summary = `${msg.postsummary} [[${mw.config.get('wgPageName')}#post-${lp}|#${lp}]]`;
                        }

                        // 匿名ユーザーには |anon=1 を付与してテンプレートでリンクを非表示にする
                        const anonParam = isAnon ? '|anon=1' : '';

                        // プロキシ経由でBot名義に編集する
                        const postBody = {
                            title: mw.config.get('wgPageName'),
                            summary: summary
                        };
                        if (fedit) {
                            postBody.text = source.replace(new RegExp(`\\{\\{post\\|(.*?)\\|${fedit}\\|(.*?)\\|4=((.|\n)*?)}}`), `{{post|$1|${fedit}|$2|4=${document.querySelector('#wpTextbox1').value}}}`);
                        } else {
                            postBody.appendtext = `\n{{post|${mw.forum.username}|${lp}|{{subst:#timel:Y/m/d H:i:s}}|4=${document.querySelector('#wpTextbox1').value}${document.querySelector('#f-reply-cb').checked ? '|re=' + document.querySelector('#f-reply').value : ''}${anonParam}}}`;
                        }
                        fetch('/wiki/forum-proxy.php', {
                            method: 'POST',
                            headers: { 'Content-Type': 'application/json' },
                            body: JSON.stringify(postBody)
                        }).then(r => r.json()).then(data => {
                            if (data.result === 'Success') {
                                if (fedit) {
                                    location.href = `${mw.util.getUrl()}#post-${fedit}`;
                                } else {
                                    location.reload();
                                }
                            } else {
                                mw.notify(msg.posterror);
                                document.querySelector('#f-post').disabled = false;
                            }
                        }).catch(() => {
                            mw.notify(msg.posterror);
                            document.querySelector('#f-post').disabled = false;
                        });
                    });

                    if (fedit) {
                        location.hash = '#f-form';
                        document.querySelector('#wpTextbox1').focus();
                        (async () => { (await (await fetch(mw.config.get('wgScript') + `?title=${mw.config.get('wgPageName')}&action=raw`)).text()).replace(new RegExp(`\\{\\{post\\|(.*?)\\|${fedit}\\|(.*?)\\|4=((.|\n)*?)}}`), (m, u, d, t) => { document.querySelector('#wpTextbox1').value = t; }) })();
                    }

                    if (!isAnon) {
                        document.querySelectorAll('.f-u-' + mw.config.get('wgUserName')).forEach(el => {
                            const numText = el.querySelector('.f-number-text').innerText;
                            const postId = numText.replace('#', '');
                            el.innerHTML += msg.editlink.replace('%241', postId);
                        });
                    }
                    if (isAdmin) {
                        document.querySelectorAll('.f-header').forEach(header => {
                            const delSpan = document.createElement('span');
                            delSpan.style.marginLeft = '10px';
                            const delLink = document.createElement('a');
                            delLink.textContent = '削除';
                            delLink.style.color = '#d33';
                            delLink.style.cursor = 'pointer';
                            delLink.onclick = function () {
                                const numText = header.querySelector('.f-number-text').innerText;
                                const postId = numText.replace('#', '');
                                func.deletePost(postId);
                            };
                            delSpan.appendChild(delLink);
                            header.appendChild(delSpan);
                        });
                    }

                    // ========== レスホバープレビュー ==========
                    // >>>N のリンクにマウスを乗せると該当投稿をポップアップ表示する
                    (() => {
                        // ポップアップ用の浮動div(ページに1つだけ作成)
                        const popup = document.createElement('div');
                        popup.id = 'f-hover-popup';
                        popup.style.cssText = [
                            'position:fixed',
                            'z-index:9999',
                            'max-width:480px',
                            'min-width:200px',
                            'background:#fff',
                            'border:2px solid #BBBBBB',
                            'border-radius:3px',
                            'box-shadow:2px 4px 12px rgba(0,0,0,.2)',
                            'pointer-events:none',   // ポップアップ自体はマウスイベントを無視
                            'display:none',
                            'font-size:.9em',
                            'line-height:1.4',
                        ].join(';');
                        document.body.appendChild(popup);

                        let hideTimer = null;

                        const showPopup = (anchorEl, postId) => {
    const target = document.querySelector(`#post-${postId}`);
    if (!target) return;

    // 対象投稿をクローンしてポップアップに表示
    const clone = target.cloneNode(true);
    clone.querySelectorAll('a[href*="fedit"], span[style*="#d33"]').forEach(el => el.remove());
    popup.innerHTML = '';
    popup.appendChild(clone);

    // ポップアップの位置をリンクの近くに配置(モバイル対応)
    const rect = anchorEl.getBoundingClientRect();
    const viewportWidth = window.innerWidth;
    const viewportHeight = window.innerHeight;
    
    // ポップアップの最大幅を画面幅の90%に制限
    const maxWidth = Math.min(480, viewportWidth * 0.9);
    popup.style.maxWidth = maxWidth + 'px';
    
    // 一度表示して実際の高さを取得
    popup.style.display = 'block';
    popup.style.visibility = 'hidden';
    const popupHeight = popup.offsetHeight;
    popup.style.visibility = 'visible';
    
    // 縦位置:下に余裕があれば下、なければ上
    const spaceBelow = viewportHeight - rect.bottom;
    const top = spaceBelow > popupHeight + 10
        ? rect.bottom + 4
        : Math.max(4, rect.top - popupHeight - 4);
    
    // 横位置:画面からはみ出ないように調整
    let left = rect.left;
    if (left + maxWidth > viewportWidth - 10) {
        left = viewportWidth - maxWidth - 10;
    }
    if (left < 10) {
        left = 10;
    }
    
    popup.style.left = left + 'px';
    popup.style.top = top + 'px';
};

                        const hidePopup = () => {
                            popup.style.display = 'none';
                        };

                        // >>>N リンクにホバーイベントを設定
                        document.querySelectorAll('.f-content a[href^="#post-"]').forEach(link => {
                            const postId = link.getAttribute('href').replace('#post-', '');

                            link.addEventListener('mouseenter', () => {
                                clearTimeout(hideTimer);
                                showPopup(link, postId);
                            });
                            link.addEventListener('mouseleave', () => {
                                // 少し遅延させてチラつきを防ぐ
                                hideTimer = setTimeout(hidePopup, 100);
                            });
                        });
                    })();
                    // ==========================================
                }
            } // end startForum
        });
    };

    if (window.mw && window.mw.loader) {
        checkConfig();
    } else {
        window.addEventListener('load', checkConfig);
    }
})();
// </nowiki>
当サイトでは、アクセス解析や利便性向上のためにCookieを使用しています。当サイトを継続して利用することで、Cookieの使用に同意したものとみなされます。