/** * ========================================================================== * Advanced File Preview System(画像・文書ファイルプレビュー&拡大表示エンジン) * * 【機能概要】 * 1. SPIRAL上の添付ファイルリンク(_file/ /area/)を自動検知し、直下にサムネイルを表示 * 2. 画像ファイルはトリミングサムネイル、文書(PDF/Excel等)は専用アイコンを生成 * 3. 画像クリックでズーム・ドラッグ・回転操作が可能な拡大モーダルを起動 * 4. アコーディオン内等で不要な通信が発生しないよう IntersectionObserver による完全遅延読み込みを適用 * 5. MutationObserver による動的コンテンツ(Ajax/アコーディオン)の自動追従検知 * ========================================================================== */ (() => { 'use strict'; // ---------------------------------------------------------------------- // 1. 定数・静的データ定義 // ---------------------------------------------------------------------- /** * 高級感・奥行きのあるモダンリッチUI用インラインSVGアイコン(Data URI) * 外部通信なし・完全自立型・ダーク/ライト双方で映える立体デザイン * @type {Map} */ const FILE_ICONS = new Map([ // PDF: 深みのあるクリムゾンレッド × グロス立体グラデーション ["pdf", "data:image/svg+xml;utf8," + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "PDF" + ""], // Excel (xlsx): エメラルド〜フォレストグリーン × グリッドアクセント ["xlsx", "data:image/svg+xml;utf8," + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "XLSX" + ""], // Excelマクロ (xlsm): 深緑 × 上品なゴールドアクセント ["xlsm", "data:image/svg+xml;utf8," + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "XLSM" + ""], // Word (docx): オーシャン〜ロイヤルブルー ["docx", "data:image/svg+xml;utf8," + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "DOCX" + ""], // PowerPoint (pptx): サンセットオレンジ〜アンバー ["pptx", "data:image/svg+xml;utf8," + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "PPTX" + ""], // ZIP: ダークアンバー × ファスナー意匠のモダンアイコン ["zip", "data:image/svg+xml;utf8," + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "ZIP" + ""], // CSV / TXT: クールスレートグレー ["csv", "data:image/svg+xml;utf8," + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "CSV" + ""], ["txt", "data:image/svg+xml;utf8," + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "TXT" + ""] ]); /** 画像ファイル判定用の正規表現(jpeg, png, gif, bmp に対応) */ const IMAGE_REGEX = /\.(jpe?g|png|gif|bmp)$/i; // ---------------------------------------------------------------------- // 2. 状態管理変数 (State) // ---------------------------------------------------------------------- /** @type {HTMLImageElement|null} 現在モーダル内で操作対象となっている画像エレメント */ let currentImg = null; /** @type {boolean} requestAnimationFrame の多重予約を防止する排他制御フラグ */ let rafPending = false; /** モーダル画像のリアルタイム変形パラメータ(拡大率・角度・移動座標・ドラッグ状態) */ const imgState = { scale: 1, rotation: 0, translateX: 0, translateY: 0, dragging: false, offsetX: 0, offsetY: 0 }; // ---------------------------------------------------------------------- // 3. スタイル & モーダルDOM初期化関数 // ---------------------------------------------------------------------- /** * プレビューおよびモーダル表示に必要な専用CSSスタイルを 内へ注入(二重読み込み防止機能付き) */ function injectStyles() { if (document.getElementById("previewThumbStyle")) return; const style = document.createElement("style"); style.id = "previewThumbStyle"; style.textContent = ` /* サムネイルを親の横並び要素から切り離し、ブロック要素として改行配置 */ .preview-wrapper { display: block; width: 100%; margin: 10px 0; clear: both; } /* サムネイル外枠(基本サイズ:フォーム等での確認用) */ .preview-thumb { display: inline-flex !important; align-items: center; width: 30%; /* ★ 幅は親要素の30% */ min-width: 140px; /* ★ スマホで小さくなりすぎるのを防ぐガード */ max-width: 240px; /* ★ 大画面PCで巨大化しすぎるのを防ぐガード */ aspect-ratio: 4 / 3; /* ★ 常に4:3の縦横比を維持 */ padding: 6px; border-radius: 12px; cursor: pointer; background: transparent; box-shadow: inset 2px 2px 4px rgba(0, 0, 0, 0.15), inset -2px -2px 4px rgba(255, 255, 255, 0.6); transition: box-shadow 0.15s ease, background-color 0.15s ease; vertical-align: middle; box-sizing: border-box; overflow: hidden; text-decoration: none; } /* 一覧表(.smp-table)の中にある場合は既存テーブル指定を上書きしてコンパクト化 */ .smp-table .preview-thumb, .is-list-screen .preview-thumb { width: 100px !important; /* ★ 一覧表内では固定幅 */ height: 70px !important; /* ★ 一覧表内では固定高 */ aspect-ratio: auto !important; /* ★ 比率固定を解除 */ min-width: auto !important; max-width: none !important; padding: 4px; border-radius: 8px; } /* サムネイル内部の画像・アイコン要素 */ .preview-thumb img { width: 100% !important; height: 100% !important; border-radius: 8px; object-fit: cover; display: block; pointer-events: none; /* ★ 拡大・縮小のアニメーションを追加 */ transition: transform 0.15s cubic-bezier(0.2, 0, 0.2, 1); will-change: transform; } /* PDF/Excel等の文書アイコン用(トリミングせず枠内に綺麗に収める) */ .preview-thumb.preview-thumb-icon img { object-fit: contain; padding: 4px; } /* 外枠全体に対するホバー・フォーカス時の発光エフェクト */ .preview-thumb:focus { outline: none; box-shadow: inset 1px 1px 3px rgba(0, 0, 0, 0.08), 0 0 12px rgba(51, 65, 85, 0.35); } /* ホバー時に「中の画像だけ」を拡大 */ .preview-thumb:hover img, .preview-thumb:focus img, .preview-thumb:focus-visible img { transform: scale(1.1); } /* モーダル表示用背景・レイアウトスタイル */ .modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.4); display: none; align-items: center; justify-content: center; z-index: 9999; cursor: zoom-out; } #rotateControls { position: fixed; top: max(20px, env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); z-index: 10000; background: rgba(13, 17, 23, 0.8); backdrop-filter: blur(4px); padding: 6px 16px; border-radius: 50px; border: 1px solid rgba(255, 255, 255, 0.15); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); pointer-events: auto; white-space: nowrap; max-width: 90vw; display: flex; align-items: center; gap: 8px; } #rotateControls button { display: inline-flex; align-items: center; justify-content: center; height: 32px; line-height: 1; margin: 0; padding: 0 12px; font-size: 1rem; font-weight: 500; border: none; background: transparent; color: #fff; cursor: pointer; transition: color 0.05s linear, text-shadow 0.05s linear; } #rotateControls button:hover, #rotateControls button:focus-visible { outline: none; color: #50c6ff; text-shadow: 0 0 8px rgba(80, 198, 255, 0.5); } #rotateControls button#modalCloseBtn { border-left: 1px solid rgba(255, 255, 255, 0.2); padding-left: 16px; color: #ffbaba; border-radius: 0; } #rotateControls button#modalCloseBtn:hover, #rotateControls button#modalCloseBtn:focus-visible { color: #ff5e5e; } .modal-content { background: transparent; padding: 0; width: 100vw; height: 100vh; display: flex; align-items: center; justify-content: center; position: relative; pointer-events: none; } .modal-content img { box-shadow: 0 20px 50px rgba(0,0,0,0.7); border-radius: 4px; pointer-events: auto; max-width: 95%; max-height: 95%; margin: auto; } `; document.head.appendChild(style); } /** * モーダル用オーバーレイ要素を取得、未存在時は 直下に生成してイベント登録 * @returns {HTMLElement} モーダルオーバーレイ要素 */ function getOrCreateModal() { let modal = document.getElementById("modal"); if (!modal) { modal = document.createElement("div"); modal.id = "modal"; modal.className = "modal-overlay"; modal.innerHTML = ``; document.body.appendChild(modal); // 画像以外の背景領域タップで即座にモーダルを閉じる modal.addEventListener("click", e => { if (e.target.tagName !== 'IMG') closeModal(); }); } return modal; } // ---------------------------------------------------------------------- // 4. モーダル表示 & 変形制御(回転・ズーム・ドラッグ) // ---------------------------------------------------------------------- /** * モーダル表示を終了し、操作パネルの破棄と参照メモリのクリーンアップを実行 */ function closeModal() { const modal = document.getElementById("modal"); if (modal) modal.style.display = "none"; document.getElementById("rotateControls")?.remove(); // 操作パネルの破棄 currentImg = null; // メモリリーク防止のため参照解除 } /** * ディスプレイ描画レート (requestAnimationFrame) に同期してCSS Transformを一括適用 */ function requestUpdate() { if (rafPending) return; rafPending = true; requestAnimationFrame(() => { if (currentImg) { // translate(位置) -> scale(拡大) -> rotate(回転) の順でGPU変形命令を実行 currentImg.style.transform = `translate(${imgState.translateX}px, ${imgState.translateY}px) scale(${imgState.scale}) rotate(${imgState.rotation}deg)`; } rafPending = false; }); } /** * 画像を指定角度ぶん回転(座標は中央に自動リセット) * @param {number} deg - 追加する回転角度(例: 90, -90) */ function rotateImg(deg) { if (!currentImg) return; imgState.rotation = (imgState.rotation + deg + 360) % 360; // 0〜360度に正規化 imgState.translateX = imgState.translateY = 0; // 回転時は位置を初期化 requestUpdate(); } /** * 上部フローティング操作パネル(左回転・右回転・閉じる)の安全な動的生成 */ function ensureRotateButtons() { if (document.getElementById("rotateControls")) return; const wrap = document.createElement("div"); wrap.id = "rotateControls"; wrap.innerHTML = ``; document.body.appendChild(wrap); // イベント委譲によりボタン操作を一括受給 wrap.addEventListener("click", e => { e.stopPropagation(); const btn = e.target.closest("button"); if (!btn) return; if (btn.id === "rotateLeft") rotateImg(-90); if (btn.id === "rotateRight") rotateImg(90); if (btn.id === "modalCloseBtn") closeModal(); }); } /** * モーダル内画像へのズーム(ホイール)およびインタラクティブ・ドラッグ機能の割り当て * @param {HTMLImageElement} img - 対象画像エレメント */ function enableImageZoom(img) { // 状態パラメータのリセット Object.assign(imgState, { scale: 1, rotation: 0, translateX: 0, translateY: 0 }); img.style.cursor = "grab"; img.style.transformOrigin = "center center"; // 1. マウスホイールによる拡大縮小(0.5倍〜5倍制限、背景スクロール防止) img.onwheel = e => { e.preventDefault(); imgState.scale = Math.min(Math.max(0.5, imgState.scale + (e.deltaY > 0 ? -0.1 : 0.1)), 5); requestUpdate(); }; // 2. ドラッグによる画像位置移動 img.onmousedown = e => { e.preventDefault(); imgState.dragging = true; imgState.offsetX = e.clientX - imgState.translateX; imgState.offsetY = e.clientY - imgState.translateY; img.style.cursor = "grabbing"; // 移動中の追従リスナー const move = ev => { if (!imgState.dragging) return; imgState.translateX = ev.clientX - imgState.offsetX; imgState.translateY = ev.clientY - imgState.offsetY; requestUpdate(); }; // ドラッグ終了時の解除処理 const up = () => { imgState.dragging = false; img.style.cursor = "grab"; window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); }; window.addEventListener("mousemove", move); window.addEventListener("mouseup", up); }; requestUpdate(); } /** * モーダルを開いて内部コンテンツを表示 * @param {string} content - モーダル内に挿入するHTML文字列(タグ) */ function showInModal(content) { const modal = getOrCreateModal(); const modalContent = document.getElementById("modalContent"); modalContent.innerHTML = content; modal.style.display = "flex"; const img = modalContent.querySelector("img"); if (img) { img.style.transition = "none"; // マウス追従をスムーズにするためCSSアニメーションを一時無効化 currentImg = img; enableImageZoom(img); ensureRotateButtons(); } } // ---------------------------------------------------------------------- // 5. サムネイルエレメントの組み立て工場 (Factory) // ---------------------------------------------------------------------- /** * 画像用(span要素)またはアイコン用(a要素)のプレビュー構造を生成(完全遅延読み込み対応) * @param {string} src - 画像またはアイコンのURL * @param {string} alt - 代替テキスト(ファイル名) * @param {boolean} [isIcon=false] - 文書アイコンかどうかの識別フラグ * @param {string|null} [downloadUrl=null] - 文書ファイルの場合のダウンロード先URL * @returns {HTMLElement} 完成したサムネイル外枠要素 */ function makeThumb(src, alt, isIcon = false, downloadUrl = null) { const wrapper = isIcon ? document.createElement("a") : document.createElement("span"); wrapper.className = isIcon ? "preview-thumb preview-thumb-icon" : "preview-thumb"; wrapper.tabIndex = 0; if (isIcon && downloadUrl) { wrapper.href = downloadUrl; wrapper.rel = "noopener noreferrer"; } const img = document.createElement("img"); img.alt = alt; img.dataset.src = src; img.className = "lazy-thumb"; wrapper.appendChild(img); return wrapper; } /** * アコーディオン展開等で実際に要素が画面内(表示領域)に入った瞬間だけ画像を読み込む視界監視エンジン */ const lazyImageObserver = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; if (img.dataset.src) { img.src = img.dataset.src; delete img.dataset.src; } observer.unobserve(img); } }); }, { rootMargin: "50px 0px" }); // ---------------------------------------------------------------------- // 6. 全自動リンク解析 & プレビュー埋め込み処理 // ---------------------------------------------------------------------- /** * 画面内の全ファイルリンク(SPIRAL形式)をスキャンし、直下にサムネイルを自動生成 */ function processFilePreviews() { const links = document.querySelectorAll("a[href*='_file/'], a[href*='/area/']"); links.forEach(link => { const parentCell = link.closest('td, dd, div') || link.parentElement; if (parentCell && parentCell.querySelector('.preview-wrapper')) return; const url = link.getAttribute("href"); if (!url) return; const filename = link.textContent.trim(); const ext = filename.split(".").pop().toLowerCase(); const isImage = IMAGE_REGEX.test(filename) || IMAGE_REGEX.test(url); const isIcon = FILE_ICONS.has(ext); if (!isImage && !isIcon) return; const previewWrapper = document.createElement("div"); previewWrapper.className = "preview-wrapper"; if (isImage) { const thumb = makeThumb(url, filename, false); const openModal = () => { const targetSrc = thumb.querySelector("img").src || url; showInModal(``); }; thumb.addEventListener("click", openModal); thumb.addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); openModal(); } }); previewWrapper.appendChild(thumb); } else if (isIcon) { const thumb = makeThumb(FILE_ICONS.get(ext), filename, true, url); previewWrapper.appendChild(thumb); } link.after(previewWrapper); const createdImg = previewWrapper.querySelector("img.lazy-thumb"); if (createdImg) { lazyImageObserver.observe(createdImg); } }); } // ---------------------------------------------------------------------- // 7. 初期化 & モジュール公開 & 動的監視 // ---------------------------------------------------------------------- function init() { injectStyles(); getOrCreateModal(); processFilePreviews(); document.addEventListener("keydown", e => { const modal = document.getElementById("modal"); if (e.key === "Escape" && modal && modal.style.display === "flex") { closeModal(); } }); let mutationTimer = null; const observer = new MutationObserver(() => { if (mutationTimer) clearTimeout(mutationTimer); mutationTimer = setTimeout(() => { processFilePreviews(); }, 100); }); observer.observe(document.body, { childList: true, subtree: true }); } window.spRefreshFilePreviews = processFilePreviews; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();