1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

続:プリザンターのPDFプレビュー ― 3つの不具合修正とモーダル強化

1
Posted at

はじめに

拡張機能だけでPDFプレビューを実装してみるで紹介した PDF プレビュー機能には、ちょっとした不具合が 3 つあります。

  1. 一覧画面を下にスクロールして追加の行が読み込まれたとき(action=gridrows)、新しく表示された行の PDF ファイルにプレビューアイコンが付かない
  2. 一覧画面からモーダルで編集画面を開いたとき、埋込ビューワが表示されない
  3. 横長の PDF を埋込ビューワで表示したとき、縦横比が崩れる

また、一覧画面のプレビューアイコンから開くモーダルビューワにはページ送りしかなく、埋込ビューワにあるフィットモード切替やズームが使えません。これも合わせて改善します。

この記事では、それぞれの原因と修正方法を紹介します。

バージョン 1.5.1.0 以降を対象にしています

原因

問題 1:追加読み込みでアイコンが出ない

元の実装では、一覧画面のアイコン付与処理 initList() を初回ロード時に 1 回だけ呼んでいます。

if (action === 'index') initList(pdfjsLib);

プリザンターの一覧画面では、スクロールやページ操作で追加の行が読み込まれると action=gridrows の Ajax リクエストが実行され、新しい <tr> が DOM に追加されます。しかし、initList() は初回ロード時にしか呼ばれないため、後から追加された行には PDF プレビューアイコンが付与されません。

編集画面(action=edit)ではファイル操作後の再構築のために $(document).ajaxComplete()initEdit() を再実行していますが、一覧画面にはこの仕組みがありませんでした。

// 編集画面:ajaxComplete で再構築している ✅
if (action === 'edit') {
  initEdit(pdfjsLib);
  var timer = null;
  $(document).ajaxComplete(function () {
    clearTimeout(timer);
    timer = setTimeout(function () {
      initEdit(pdfjsLib);
    }, 300);
  });
}
// 一覧画面:初回のみ ❌
if (action === 'index') initList(pdfjsLib);

問題 2:モーダル編集画面で埋込ビューワが表示されない

プリザンターでは、一覧画面からレコードをクリックするとモーダルダイアログで編集画面が開きます。このとき、編集フォームは Ajax で読み込まれて DOM に追加されます。

しかし、元の実装では initEdit() の実行条件が action === 'edit' のみです。モーダルで開いた場合、ページ自体の actionindex のままなので initEdit() が呼ばれず、編集画面の埋込ビューワが表示されません。

// action が 'index' のままなので、このブロックには入らない
if (action === 'edit') {
  initEdit(pdfjsLib);
  // ...
}

問題 3:横長 PDF で縦横比が崩れる

埋込ビューワの canvas に適用している CSS が原因です。

.pdf-embed canvas {
  max-width: 100%;
  box-shadow: 0 1px 6px var(--base-shadow);
}

max-width: 100% によって、コンテナより幅が大きい canvas は横方向に縮小されます。しかし <img> タグと違い、<canvas> タグでは max-width だけでは高さが自動的に連動しません。横幅だけが縮まって高さはそのままなので、横長の PDF で縦横比が崩れます。

修正方法

修正は 5 点です。

1. initList() に重複防止を追加する

initList() を複数回呼んでもアイコンが重複しないように、処理済みのリンクにマーカー属性を付けてスキップするようにします。

  function initList(pdfjsLib) {
    document
      .querySelectorAll('a[href*="/binaries/"][href*="/download"]')
      .forEach(function (a) {
        if (!/\.pdf$/i.test(a.textContent.trim())) return;
+       if (a.dataset.pdfIcon) return;
+       a.dataset.pdfIcon = '1';
        var btn = document.createElement('span');
        btn.className = 'material-symbols-outlined pdf-preview-btn';
        btn.textContent = 'preview';
        btn.title = 'PDF プレビュー';
        btn.onclick = function (e) {
          e.preventDefault();
          e.stopPropagation();
          openModal(
            { name: a.textContent.trim(), url: a.getAttribute('href') },
            1,
            pdfjsLib
          );
        };
        a.after(btn);
      });
  }

data-pdf-icon 属性が付いているリンクはすでにアイコンを追加済みなのでスキップします。これにより、initList() を何度呼んでもアイコンが二重に生成されることはありません。

2. 一覧画面にも ajaxComplete を追加する

編集画面と同じパターンで、Ajax 完了後に initList() を再実行します。

- if (action === 'index') initList(pdfjsLib);
+ if (action === 'index') {
+   initList(pdfjsLib);
+   var listTimer = null;
+   $(document).ajaxComplete(function () {
+     clearTimeout(listTimer);
+     listTimer = setTimeout(function () {
+       initList(pdfjsLib);
+     }, 300);
+   });
+ }

300ms のデバウンスを入れることで、短時間に複数の Ajax が完了しても initList() の実行は 1 回にまとまります。

3. ajaxCompleteinitEdit() も呼ぶ

モーダルで編集画面が開かれたときに埋込ビューワを初期化するため、ajaxComplete ハンドラで initEdit() も合わせて呼びます。

  if (action === 'index') {
    initList(pdfjsLib);
    var listTimer = null;
    $(document).ajaxComplete(function () {
      clearTimeout(listTimer);
      listTimer = setTimeout(function () {
        initList(pdfjsLib);
+       initEdit(pdfjsLib);
      }, 300);
    });
  }

initEdit() は編集フォームの DOM 要素が存在するときだけビューワを構築します。モーダルが開いていないときは対象要素がないため何もしません。また、編集画面側で既に利用している関数なので、複数回呼んでも問題ありません。

4. canvas に height: auto を追加する

横長 PDF で縦横比が崩れる問題は、CSS で height: auto を追加するだけで解汀できます。

  .pdf-embed canvas {
    max-width: 100%;
+   height: auto;
    box-shadow: 0 1px 6px var(--base-shadow);
  }

max-width: 100% で横幅が縮小されたとき、height: auto があれば canvas の描画バッファの縦横比に従って高さも自動的にスケールされます。<img> タグではデフォルトでこの動作になりますが、<canvas> タグでは明示的に指定する必要があります。

5. モーダルにフィットモードとズームを追加する

モーダルビューワにはページ送りしかないため、埋込ビューワと同じフィットモード切替(ページ全体・幅・高さ・ズーム)とズームコントロールを追加します。変更は 3 箇所です。

  1. buildModal(): フィットモードボタン・ズームボタン・ローディングスピナーを HTML に追加し、クリックイベントで vs.fit を切り替えて render() を再実行する
  2. openModal(): モーダルを開くたびに UI 状態をリセットし、vs オブジェクトに fitzoomwraploader を追加する。render() はこれらのプロパティを参照してスケールを計算するため、追加するだけでフィットモードが機能する
  3. CSS: ズーム・高さフィットモードでキャンバスがコンテナを超える場合に max-width の制限を解除し、ローディングスピナー用に position: relative を追加する

render() 関数はすでに vs.fitvs.zoom に基づいてスケールを計算しているため、vs にこれらのプロパティを追加してコントロール UI を用意するだけで、埋込ビューワと同じ表示制御がモーダルでも動作します。

修正後のコード全体

修正を反映したスクリプトとスタイルの全体は以下のとおりです。

ExtendedScripts/PdfViewer.js
$(function () {
  // PDF.js の設定(バージョンを固定して動作の安定性を確保)
  var PDFJS_VER = '4.10.38';
  var DIST = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@' + PDFJS_VER;

  // モード設定: 'include' | 'all' | 'exclude'
  //   include : フィールドCSSに pdf-viewer がある項目だけ対象(既定)
  //   all     : すべての添付ファイル項目を対象
  //   exclude : フィールドCSSに no-pdf-viewer がある項目を除外
  var MODE = 'include';
  var CSS_INCLUDE = 'pdf-viewer';
  var CSS_EXCLUDE = 'no-pdf-viewer';

  // 表示モードの初期状態: true = 折りたたみ / false = 展開(既定)
  var DEFAULT_COLLAPSED = false;

  // フィットモード: 'page' | 'width' | 'height' | 'zoom'
  //   page   : ページ全体を収める(既定)
  //   width  : 横幅にフィット
  //   height : 縦幅にフィット
  //   zoom   : 指定倍率で表示
  var DEFAULT_FIT = 'page';

  // ズーム倍率の初期値(%)と増減幅
  var DEFAULT_ZOOM = 100;
  var ZOOM_STEP = 25;

  // 編集画面・一覧画面のみ対象
  var action = $p.action();
  if (action !== 'edit' && action !== 'index') return;

  // PDF.js を CDN から動的に読み込む
  import(DIST + '/build/pdf.min.mjs')
    .then(function (pdfjsLib) {
      pdfjsLib.GlobalWorkerOptions.workerSrc =
        DIST + '/build/pdf.worker.min.mjs';

      if (action === 'edit') {
        initEdit(pdfjsLib);
        // ファイル追加・削除・更新後にビューアーを再構築
        var timer = null;
        $(document).ajaxComplete(function () {
          clearTimeout(timer);
          timer = setTimeout(function () {
            initEdit(pdfjsLib);
          }, 300);
        });
      }
      if (action === 'index') {
        initList(pdfjsLib);
        // 追加読み込み(gridrows)後にアイコンを再付与
        // モーダル編集画面の埋込ビューワを初期化
        var listTimer = null;
        $(document).ajaxComplete(function () {
          clearTimeout(listTimer);
          listTimer = setTimeout(function () {
            initList(pdfjsLib);
            initEdit(pdfjsLib);
          }, 300);
        });
      }
    })
    .catch(function (e) {
      console.error('PDF.js の読み込みに失敗しました:', e);
    });

  /* ========== 編集画面 ========== */

  function initEdit(pdfjsLib) {
    // 既存のビューアーを除去して再構築
    document.querySelectorAll('.pdf-embed-field').forEach(function (el) {
      var embed = el.querySelector('.pdf-embed');
      if (embed && embed._vs && embed._vs.doc) embed._vs.doc.destroy();
      el.remove();
    });

    document
      .querySelectorAll('.control-attachments')
      .forEach(function (input) {
        // モードに応じて対象項目を判定
        var field = input.closest('[id$="Field"]');
        if (!shouldAttach(field)) return;

        var files = findPdfFiles(input);
        if (files.length === 0) return;
        buildEmbedViewer(field, files, pdfjsLib);
      });
  }

  // モードに応じて対象項目かどうかを判定する
  function shouldAttach(field) {
    if (!field) return false;
    if (MODE === 'all') return true;
    if (MODE === 'exclude') return !field.classList.contains(CSS_EXCLUDE);
    return field.classList.contains(CSS_INCLUDE); // include(既定)
  }

  // hidden input の JSON から PDF だけを抽出する
  function findPdfFiles(input) {
    var files = [];
    try {
      var list = JSON.parse(input.value || '[]');
    } catch (e) {
      return files;
    }
    list.forEach(function (att) {
      if (att.Deleted) return;
      if (!att.Name || !/\.pdf$/i.test(att.Name)) return;
      files.push({
        name: att.Name,
        url: '/binaries/' + att.Guid + '/download'
      });
    });
    return files;
  }

  // 埋め込みビューアーを生成する
  function buildEmbedViewer(field, pdfFiles, pdfjsLib) {
    // 添付ファイル項目の直後に field-wide の wrapper を作成
    var wrapper = document.createElement('div');
    wrapper.className = 'field-wide pdf-embed-field';
    // 空の field-label でラベル幅を揃える
    var label = document.createElement('p');
    label.className = 'field-label';
    label.appendChild(document.createElement('label'));
    wrapper.appendChild(label);
    var fc = document.createElement('div');
    fc.className = 'field-control';
    var cn = document.createElement('div');
    cn.className = 'container-normal';
    fc.appendChild(cn);
    wrapper.appendChild(fc);

    var el = document.createElement('div');
    el.className = 'pdf-embed';

    // タブバー(ファイルタブ+トグルボタン)
    var tabBar = document.createElement('div');
    tabBar.className = 'pdf-tabs';

    var btnToggle = document.createElement('button');
    btnToggle.type = 'button';
    btnToggle.className = 'pdf-toggle-btn';
    btnToggle.textContent = DEFAULT_COLLAPSED ? '' : '';
    btnToggle.title = 'プレビューの表示切替';

    // キャンバス
    var wrap = document.createElement('div');
    wrap.className = 'pdf-canvas-wrap';
    var canvas = document.createElement('canvas');
    // ローディング
    var loader = document.createElement('div');
    loader.className = 'pdf-loader';
    loader.innerHTML = '<div class="pdf-spinner"></div>';
    wrap.append(canvas, loader);

    // コントロールバー
    var ctrl = document.createElement('div');
    ctrl.className = 'pdf-controls';
    var btnPrev = iconBtn('chevron_left');
    var btnNext = iconBtn('chevron_right');
    var info = document.createElement('span');
    info.className = 'pdf-page-info';
    var btnOpen = iconBtn('open_in_new');
    btnOpen.title = '別ウィンドウで表示';

    // フィットモードボタン
    var fitGroup = document.createElement('div');
    fitGroup.className = 'pdf-fit-group';
    var fitModes = [
      ['page', 'fit_screen', 'ページ'],
      ['width', 'width', ''],
      ['height', 'height', '高さ'],
      ['zoom', 'percent', '%']
    ];
    fitModes.forEach(function (m) {
      var b = document.createElement('button');
      b.type = 'button';
      b.className = 'pdf-fit-btn' + (m[0] === DEFAULT_FIT ? ' active' : '');
      var ico = document.createElement('span');
      ico.className = 'material-symbols-outlined';
      ico.textContent = m[1];
      b.appendChild(ico);
      b.title = m[2];
      b.dataset.fit = m[0];
      fitGroup.appendChild(b);
    });

    // ズームコントロール
    var zoomGroup = document.createElement('div');
    zoomGroup.className = 'pdf-zoom-group';
    var btnZoomOut = iconBtn('remove');
    btnZoomOut.title = '縮小';
    var zoomInfo = document.createElement('span');
    zoomInfo.className = 'pdf-zoom-info';
    zoomInfo.textContent = DEFAULT_ZOOM + '%';
    var btnZoomIn = iconBtn('add');
    btnZoomIn.title = '拡大';
    zoomGroup.append(btnZoomOut, zoomInfo, btnZoomIn);
    zoomGroup.style.display = DEFAULT_FIT === 'zoom' ? '' : 'none';

    ctrl.append(btnPrev, info, btnNext, fitGroup, zoomGroup, btnOpen);

    el.append(tabBar, wrap, ctrl);
    if (DEFAULT_COLLAPSED) el.classList.add('collapsed');
    cn.appendChild(el);
    field.after(wrapper);

    // ビューアーの状態管理
    var vs = {
      idx: 0,
      page: 1,
      pages: 0,
      doc: null,
      busy: false,
      fit: DEFAULT_FIT,
      zoom: DEFAULT_ZOOM,
      files: pdfFiles,
      canvas: canvas,
      wrap: wrap,
      loader: loader,
      el: el,
      info: info
    };
    el._vs = vs;

    // タブ生成
    pdfFiles.forEach(function (f, i) {
      var tab = document.createElement('button');
      tab.type = 'button';
      tab.className = 'pdf-tab' + (i === 0 ? ' active' : '');
      tab.textContent = f.name;
      tab.onclick = function () {
        tabBar.querySelectorAll('.pdf-tab').forEach(function (t, j) {
          t.classList.toggle('active', j === i);
        });
        loadDoc(vs, i, pdfjsLib);
      };
      tabBar.appendChild(tab);
    });
    tabBar.appendChild(btnToggle);

    // ボタンイベント
    btnPrev.onclick = function () {
      turnPage(vs, -1);
    };
    btnNext.onclick = function () {
      turnPage(vs, 1);
    };
    btnOpen.onclick = function () {
      openModal(vs.files[vs.idx], vs.page, pdfjsLib);
    };
    btnToggle.onclick = function () {
      var collapsed = el.classList.toggle('collapsed');
      btnToggle.textContent = collapsed ? '' : '';
    };
    // フィットモードボタン
    fitGroup.onclick = function (e) {
      var btn = e.target.closest('.pdf-fit-btn');
      if (!btn) return;
      fitGroup.querySelectorAll('.pdf-fit-btn').forEach(function (b) {
        b.classList.toggle('active', b === btn);
      });
      vs.fit = btn.dataset.fit;
      el.classList.remove('pdf-fit-width', 'pdf-fit-height', 'pdf-fit-zoom');
      if (vs.fit === 'width') el.classList.add('pdf-fit-width');
      if (vs.fit === 'height') el.classList.add('pdf-fit-height');
      if (vs.fit === 'zoom') el.classList.add('pdf-fit-zoom');
      zoomGroup.style.display = vs.fit === 'zoom' ? '' : 'none';
      render(vs);
    };
    // ズーム操作
    btnZoomIn.onclick = function () {
      vs.zoom = Math.min(vs.zoom + ZOOM_STEP, 500);
      zoomInfo.textContent = vs.zoom + '%';
      if (vs.fit === 'zoom') render(vs);
    };
    btnZoomOut.onclick = function () {
      vs.zoom = Math.max(vs.zoom - ZOOM_STEP, 25);
      zoomInfo.textContent = vs.zoom + '%';
      if (vs.fit === 'zoom') render(vs);
    };
    // 初期フィットモードを適用
    if (DEFAULT_FIT === 'width') el.classList.add('pdf-fit-width');
    if (DEFAULT_FIT === 'height') el.classList.add('pdf-fit-height');
    if (DEFAULT_FIT === 'zoom') el.classList.add('pdf-fit-zoom');

    // 最初の PDF を読み込む
    loadDoc(vs, 0, pdfjsLib);
  }

  /* ========== 一覧画面 ========== */

  function initList(pdfjsLib) {
    document
      .querySelectorAll('a[href*="/binaries/"][href*="/download"]')
      .forEach(function (a) {
        if (!/\.pdf$/i.test(a.textContent.trim())) return;
        if (a.dataset.pdfIcon) return;
        a.dataset.pdfIcon = '1';
        var btn = document.createElement('span');
        btn.className = 'material-symbols-outlined pdf-preview-btn';
        btn.textContent = 'preview';
        btn.title = 'PDF プレビュー';
        btn.onclick = function (e) {
          e.preventDefault();
          e.stopPropagation();
          openModal(
            { name: a.textContent.trim(), url: a.getAttribute('href') },
            1,
            pdfjsLib
          );
        };
        a.after(btn);
      });
  }

  /* ========== PDF 読み込み・描画 ========== */

  function loadDoc(vs, idx, pdfjsLib) {
    if (vs.doc) {
      vs.doc.destroy();
      vs.doc = null;
    }
    vs.idx = idx;
    vs.page = 1;
    vs.info.textContent = '読み込み中...';
    if (vs.loader) vs.loader.classList.remove('hidden');

    pdfjsLib
      .getDocument({
        url: vs.files[idx].url,
        cMapUrl: DIST + '/cmaps/',
        cMapPacked: true,
        standardFontDataUrl: DIST + '/standard_fonts/'
      })
      .promise.then(function (pdf) {
        vs.doc = pdf;
        vs.pages = pdf.numPages;
        render(vs);
      })
      .catch(function () {
        vs.info.textContent = '読み込み失敗';
      });
  }

  function render(vs) {
    if (!vs.doc || vs.busy) return;
    vs.busy = true;
    vs.info.textContent = vs.page + ' / ' + vs.pages;

    vs.doc.getPage(vs.page).then(function (pg) {
      var vp = pg.getViewport({ scale: 1 });
      var wrapEl = vs.wrap || vs.canvas.parentElement;
      var maxW = wrapEl.clientWidth - 16;
      var maxH = wrapEl.clientHeight - 16;
      var scale;

      switch (vs.fit) {
        case 'width':
          scale = maxW / vp.width;
          break;
        case 'height':
          scale = maxH > 0 ? maxH / vp.height : 1;
          break;
        case 'zoom':
          scale = (vs.zoom || 100) / 100;
          break;
        default: // page
          scale = Math.min(maxW / vp.width, maxH > 0 ? maxH / vp.height : 1.5);
          break;
      }
      vp = pg.getViewport({ scale: scale });
      vs.canvas.width = vp.width;
      vs.canvas.height = vp.height;

      pg.render({
        canvasContext: vs.canvas.getContext('2d'),
        viewport: vp
      }).promise.then(function () {
        vs.busy = false;
        if (vs.loader) vs.loader.classList.add('hidden');
      });
    });
  }

  function turnPage(vs, delta) {
    var n = vs.page + delta;
    if (n >= 1 && n <= vs.pages) {
      vs.page = n;
      render(vs);
    }
  }

  /* ========== モーダル ========== */

  function openModal(file, startPage, pdfjsLib) {
    var modal = document.getElementById('pdf-modal');
    if (!modal) modal = buildModal();

    modal.querySelector('.pdf-modal-title').textContent = file.name;
    modal.style.display = 'flex';
    var body = modal.querySelector('.pdf-modal-body');

    // フィットモード UI リセット
    body.classList.remove('pdf-fit-width', 'pdf-fit-height', 'pdf-fit-zoom');
    if (DEFAULT_FIT === 'width') body.classList.add('pdf-fit-width');
    if (DEFAULT_FIT === 'height') body.classList.add('pdf-fit-height');
    if (DEFAULT_FIT === 'zoom') body.classList.add('pdf-fit-zoom');
    modal.querySelectorAll('.pdf-fit-btn').forEach(function (b) {
      b.classList.toggle('active', b.dataset.fit === DEFAULT_FIT);
    });
    var zoomGroup = modal.querySelector('.pdf-zoom-group');
    zoomGroup.style.display = DEFAULT_FIT === 'zoom' ? '' : 'none';
    modal.querySelector('.pdf-zoom-info').textContent = DEFAULT_ZOOM + '%';
    modal.querySelector('.pdf-loader').classList.remove('hidden');

    var vs = {
      idx: 0,
      page: startPage || 1,
      pages: 0,
      doc: null,
      busy: false,
      fit: DEFAULT_FIT,
      zoom: DEFAULT_ZOOM,
      files: [file],
      canvas: modal.querySelector('canvas'),
      wrap: body,
      loader: modal.querySelector('.pdf-loader'),
      info: modal.querySelector('.pdf-page-info')
    };
    modal._vs = vs;

    pdfjsLib
      .getDocument({
        url: file.url,
        cMapUrl: DIST + '/cmaps/',
        cMapPacked: true,
        standardFontDataUrl: DIST + '/standard_fonts/'
      })
      .promise.then(function (pdf) {
        vs.doc = pdf;
        vs.pages = pdf.numPages;
        render(vs);
      })
      .catch(function () {
        vs.info.textContent = '読み込み失敗';
      });

    // キーボード操作
    var onKey = function (e) {
      if (e.key === 'Escape') closeModal();
      if (e.key === 'ArrowLeft') turnPage(vs, -1);
      if (e.key === 'ArrowRight') turnPage(vs, 1);
    };
    document.addEventListener('keydown', onKey);
    modal._onKey = onKey;
  }

  function buildModal() {
    var m = document.createElement('div');
    m.id = 'pdf-modal';
    m.innerHTML =
      '<div class="pdf-modal-overlay"></div>' +
      '<div class="pdf-modal-dialog">' +
      '<div class="pdf-modal-header">' +
      '<span class="pdf-modal-title"></span>' +
      '<button type="button" class="pdf-modal-close">' +
      '<span class="material-symbols-outlined">close</span>' +
      '</button>' +
      '</div>' +
      '<div class="pdf-modal-body">' +
      '<canvas></canvas>' +
      '<div class="pdf-loader"><div class="pdf-spinner"></div></div>' +
      '</div>' +
      '<div class="pdf-modal-controls">' +
      '<button type="button" class="pdf-modal-prev">' +
      '<span class="material-symbols-outlined">chevron_left</span>' +
      '</button>' +
      '<span class="pdf-page-info"></span>' +
      '<button type="button" class="pdf-modal-next">' +
      '<span class="material-symbols-outlined">chevron_right</span>' +
      '</button>' +
      '<div class="pdf-fit-group"></div>' +
      '<div class="pdf-zoom-group">' +
      '<button type="button" class="pdf-ctrl-btn pdf-zoom-out">' +
      '<span class="material-symbols-outlined">remove</span>' +
      '</button>' +
      '<span class="pdf-zoom-info">' + DEFAULT_ZOOM + '%</span>' +
      '<button type="button" class="pdf-ctrl-btn pdf-zoom-in">' +
      '<span class="material-symbols-outlined">add</span>' +
      '</button>' +
      '</div>' +
      '</div>' +
      '</div>';

    document.body.appendChild(m);

    // フィットモードボタン生成
    var fitGroup = m.querySelector('.pdf-fit-group');
    var fitModes = [
      ['page', 'fit_screen', 'ページ'],
      ['width', 'width', ''],
      ['height', 'height', '高さ'],
      ['zoom', 'percent', '%']
    ];
    fitModes.forEach(function (mode) {
      var b = document.createElement('button');
      b.type = 'button';
      b.className = 'pdf-fit-btn' + (mode[0] === DEFAULT_FIT ? ' active' : '');
      var ico = document.createElement('span');
      ico.className = 'material-symbols-outlined';
      ico.textContent = mode[1];
      b.appendChild(ico);
      b.title = mode[2];
      b.dataset.fit = mode[0];
      fitGroup.appendChild(b);
    });

    m.querySelector('.pdf-modal-overlay').onclick = closeModal;
    m.querySelector('.pdf-modal-close').onclick = closeModal;
    m.querySelector('.pdf-modal-prev').onclick = function () {
      turnPage(m._vs, -1);
    };
    m.querySelector('.pdf-modal-next').onclick = function () {
      turnPage(m._vs, 1);
    };

    // フィットモード切替
    var body = m.querySelector('.pdf-modal-body');
    var zoomGroup = m.querySelector('.pdf-zoom-group');
    zoomGroup.style.display = DEFAULT_FIT === 'zoom' ? '' : 'none';

    fitGroup.onclick = function (e) {
      var btn = e.target.closest('.pdf-fit-btn');
      if (!btn || !m._vs) return;
      fitGroup.querySelectorAll('.pdf-fit-btn').forEach(function (b) {
        b.classList.toggle('active', b === btn);
      });
      m._vs.fit = btn.dataset.fit;
      body.classList.remove('pdf-fit-width', 'pdf-fit-height', 'pdf-fit-zoom');
      if (m._vs.fit === 'width') body.classList.add('pdf-fit-width');
      if (m._vs.fit === 'height') body.classList.add('pdf-fit-height');
      if (m._vs.fit === 'zoom') body.classList.add('pdf-fit-zoom');
      zoomGroup.style.display = m._vs.fit === 'zoom' ? '' : 'none';
      render(m._vs);
    };

    // ズーム操作
    m.querySelector('.pdf-zoom-in').onclick = function () {
      if (!m._vs) return;
      m._vs.zoom = Math.min(m._vs.zoom + ZOOM_STEP, 500);
      m.querySelector('.pdf-zoom-info').textContent = m._vs.zoom + '%';
      if (m._vs.fit === 'zoom') render(m._vs);
    };
    m.querySelector('.pdf-zoom-out').onclick = function () {
      if (!m._vs) return;
      m._vs.zoom = Math.max(m._vs.zoom - ZOOM_STEP, 25);
      m.querySelector('.pdf-zoom-info').textContent = m._vs.zoom + '%';
      if (m._vs.fit === 'zoom') render(m._vs);
    };

    return m;
  }

  function closeModal() {
    var m = document.getElementById('pdf-modal');
    if (!m) return;
    m.style.display = 'none';
    if (m._vs && m._vs.doc) {
      m._vs.doc.destroy();
      m._vs.doc = null;
    }
    if (m._onKey) {
      document.removeEventListener('keydown', m._onKey);
    }
  }

  /* ========== ユーティリティ ========== */

  function iconBtn(name) {
    var b = document.createElement('button');
    b.type = 'button';
    b.className = 'pdf-ctrl-btn';
    var s = document.createElement('span');
    s.className = 'material-symbols-outlined';
    s.textContent = name;
    b.appendChild(s);
    return b;
  }
});
ExtendedStyles/PdfViewer.css
/* ===== 埋め込みビューアー(編集画面) ===== */
.pdf-embed-field {
  padding-top: 0;
  min-height: 0;
}

.pdf-embed {
  border: 1px solid var(--base-border);
  border-radius: 4px;
  overflow: hidden;
}

.pdf-embed.collapsed .pdf-canvas-wrap,
.pdf-embed.collapsed .pdf-controls {
  display: none;
}

.pdf-embed .pdf-tabs {
  display: flex;
  align-items: center;
  border-bottom: 1px solid var(--base-border);
  background: var(--base-bg-light);
  overflow-x: auto;
}

.pdf-embed .pdf-tab {
  padding: 6px 14px;
  border: none;
  background: none;
  cursor: pointer;
  font-size: 12px;
  white-space: nowrap;
  border-bottom: 2px solid transparent;
  color: var(--scrollbar-thumb);
  transition: color 0.2s, border-color 0.2s;
}

.pdf-embed .pdf-tab:hover {
  color: var(--base-text);
}

.pdf-embed .pdf-tab.active {
  color: var(--primaryColor);
  border-bottom-color: var(--primaryColor);
}

.pdf-embed .pdf-canvas-wrap {
  position: relative;
  display: flex;
  justify-content: center;
  padding: 8px;
  background: var(--page-bg);
  overflow: auto;
}

.pdf-embed:not(.pdf-fit-width):not(.pdf-fit-height):not(.pdf-fit-zoom) .pdf-canvas-wrap {
  aspect-ratio: 2 / 1;
}

.pdf-embed canvas {
  max-width: 100%;
  height: auto;
  box-shadow: 0 1px 6px var(--base-shadow);
}

/* ===== ローディング ===== */
.pdf-loader {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--base-dark-layer);
  z-index: 1;
  transition: opacity 0.2s;
}

.pdf-loader.hidden {
  opacity: 0;
  pointer-events: none;
}

.pdf-loader .pdf-spinner {
  width: 40px;
  height: 40px;
  border: 4px solid var(--scrollbar-thumb);
  border-right-color: transparent;
  border-radius: 50%;
  animation: pdf-rotate 1s linear infinite;
}

@keyframes pdf-rotate {
  to {
    transform: rotate(1turn);
  }
}

.pdf-embed .pdf-controls {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 4px 8px;
  background: var(--base-bg-light);
  border-top: 1px solid var(--base-border);
}

.pdf-ctrl-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px;
  border-radius: 4px;
  color: var(--base-text);
}

.pdf-ctrl-btn:hover {
  background: var(--btn-normal-hover);
}

.pdf-page-info {
  font-size: 13px;
  min-width: 60px;
  text-align: center;
}

.pdf-toggle-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px 8px;
  border-radius: 4px;
  color: var(--base-text);
  margin-left: auto;
  font-size: 12px;
}

.pdf-toggle-btn:hover {
  background: var(--btn-normal-hover);
}

.pdf-fit-group {
  display: inline-flex;
  border: 1px solid var(--control-border);
  border-radius: 4px;
  overflow: hidden;
}

.pdf-fit-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 4px;
  border: none;
  border-right: 1px solid var(--control-border);
  background: var(--base-bg);
  color: var(--base-text);
  cursor: pointer;
}

.pdf-fit-btn .material-symbols-outlined {
  font-size: 18px;
}

.pdf-fit-btn:last-child {
  border-right: none;
}

.pdf-fit-btn:hover {
  background: var(--btn-normal-hover);
}

.pdf-fit-btn.active {
  background: var(--primaryColor);
  color: var(--invert-text);
}

.pdf-zoom-group {
  display: inline-flex;
  align-items: center;
  gap: 2px;
}

.pdf-zoom-info {
  font-size: 12px;
  min-width: 40px;
  text-align: center;
  color: var(--base-text);
}

/* ===== プレビューボタン(一覧画面) ===== */
.pdf-preview-btn {
  font-size: 18px !important;
  vertical-align: middle;
  cursor: pointer;
  color: var(--warning-color);
  margin-left: 4px;
}

.pdf-preview-btn:hover {
  opacity: 0.8;
}

/* ===== モーダル ===== */
#pdf-modal {
  position: fixed;
  inset: 0;
  z-index: 9998;
  display: none;
  align-items: center;
  justify-content: center;
}

#pdf-modal .pdf-modal-overlay {
  position: fixed;
  inset: 0;
  background: var(--u-modal-bg);
}

#pdf-modal .pdf-modal-dialog {
  position: relative;
  background: var(--base-bg);
  border-radius: 8px;
  width: 95vw;
  max-width: 1400px;
  height: 92vh;
  display: flex;
  flex-direction: column;
  box-shadow: 0 8px 32px var(--base-shadow);
  z-index: 1;
}

#pdf-modal .pdf-modal-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 16px;
  border-bottom: 1px solid var(--base-border);
}

#pdf-modal .pdf-modal-title {
  font-weight: bold;
  font-size: 14px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

#pdf-modal .pdf-modal-close {
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px;
  display: flex;
  color: var(--base-text);
}

#pdf-modal .pdf-modal-close:hover {
  opacity: 0.7;
}

#pdf-modal .pdf-modal-body {
  flex: 1;
  overflow: auto;
  display: flex;
  justify-content: center;
  padding: 12px;
  background: var(--page-bg);
  position: relative;
}

#pdf-modal .pdf-modal-body canvas {
  max-width: 100%;
  height: auto;
  box-shadow: 0 2px 8px var(--base-shadow);
}

#pdf-modal .pdf-modal-body.pdf-fit-zoom canvas,
#pdf-modal .pdf-modal-body.pdf-fit-height canvas {
  max-width: none;
}

/* ===== モーダルコントロール ===== */
#pdf-modal .pdf-modal-controls {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  padding: 8px 16px;
  border-top: 1px solid var(--base-border);
}

#pdf-modal .pdf-modal-controls button {
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px;
  display: flex;
  color: var(--base-text);
  border-radius: 4px;
}

#pdf-modal .pdf-modal-controls button:hover {
  background: var(--btn-normal-hover);
}

/* ===== レスポンシブ対応 ===== */
@media (max-width: 1024px) {
  .pdf-embed-field > .field-label {
    display: none;
  }
}

@media (max-width: 768px) {
  #pdf-modal .pdf-modal-dialog {
    width: 100vw;
    height: 100vh;
    max-width: none;
    border-radius: 0;
  }

  #pdf-modal .pdf-modal-header {
    padding: 8px 12px;
  }

  #pdf-modal .pdf-modal-body {
    padding: 4px;
  }

  #pdf-modal .pdf-modal-controls {
    padding: 6px 8px;
  }

  .pdf-embed .pdf-tab {
    padding: 4px 10px;
    font-size: 11px;
  }
}

まとめ

  • 一覧画面の追加読み込み(action=gridrows)で新しい行が追加されたとき、PDF プレビューアイコンが表示されない問題を修正しました
  • 一覧画面からモーダルで編集画面を開いたとき、埋込ビューワが表示されない問題を修正しました
  • 横長の PDF で縦横比が崩れる問題を、canvas の CSS に height: auto を追加して修正しました
  • initList()data-pdf-icon 属性による重複防止チェックを追加し、複数回呼んでも安全にしました
  • 一覧画面にも $(document).ajaxComplete() を追加し、Ajax 完了後に initList()initEdit() を再実行するようにしました
  • 編集画面で initEdit() に適用していた ajaxComplete パターンと同じ構造を一覧画面にも適用しただけなので、既存の動作への影響はありません
  • モーダルビューワにフィットモード切替(ページ全体・幅・高さ・ズーム)とズームコントロールを追加し、埋込ビューワと同等の操作を可能にしました
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?