【kintone】PDFプレビュー機能を作ってみた!

添付ファイルにあるPDFをプレビューしたいと思うこと多々ありますよね。

なので今回はレコード詳細画面だけではなく、レコード一覧画面でもPDFをプレビューできるカスタマイズをご紹介します。

使用するライブラリ

以下のライブラリはCybozu CDNから引用している。

Customize Editor で書いていく

今日もCustomize Editor プラグインを使って書いていく!

といっても作りはシンプルなのでパパっと👇のpreview.js と preview.css を書いた。

Preview.js

JavaScript
(($) => {
  "use strict";
	
  // 	添付ファイルフィールド取得
	const getFileFields = () => {
	  const fields = [];
	  const fieldList = cybozu.data.page.SCHEMA_DATA.table.fieldList;
	  for (const id in fieldList) {
	    if (fieldList[id].type != "FILE") continue;
	    fields.push(fieldList[id].var);
	  }
	  return fields;
	}
  
  // パラメータ取得
	const getParam = (link, key) => {
    const url = new URL(link);
    const params = url.searchParams;
    return params.get(key);
	};
  
  // ファイルダウンロード
  const download = async (fileKey) => {
		const fileUrl = `${kintone.api.url("/k/v1/file.json", true)}?fileKey=${fileKey}`;
		try {
			const headers = {
				"X-Requested-With": "XMLHttpRequest"
			};
			const resp = await fetch(fileUrl, {
				method: "GET",
				headers
			});
			const blob = await resp.blob();
			const url = window.URL || window.webkitURL;
			const blobUrl = url.createObjectURL(blob);
			return blobUrl;
		} catch (err) {
			console.log(err);
			return null;
		}
  };
  
  // プレビュー
  const preview = async (files) => {
    const $preview = $(`
      <div id="_preview">
        <div class="__preview_content">
          <div class="__preview_header">
            <div class="__name">${files[0].name}</div>
            <div class="__prev disabled"><i class="fa-solid fa-chevron-left"></i></div>
            <div class="__next"><i class="fa-solid fa-chevron-right"></i></div>
            <div class="__download"><i class="fa-solid fa-download"></i></div>
            <div class="__close"><i class="fa-regular fa-circle-xmark"></i></div>
          </div>
          <div class="__iframes"></div>
        </div>
        <div class="__background __close"></div>
      </div>
    `).appendTo(document.body);
  
    // 1ファイルの場合
    if (files.length == 1) {
      $preview.find(".__prev, .__next").remove();
    }
  
    // ファイル追加
    for (const [i, file] of files.entries()) {
      const url = await download(file.fileKey);
      const params = "#toolbar=0&navpanes=0";
      const $iframe = $(`<iframe data-index=${i}></iframe>`)
        .attr("src", `${url}${params}`)
        .attr("data-url", url)
        .attr("data-name", file.name)
        .appendTo($preview.find(".__iframes"));
      if (i != 0) $iframe.hide();
    }
  
    let index = 0;
    $preview.on("click", ".__next", (evt) => {
      // 次のファイル
      const max = files.length - 1;
      const nextIndex = index + 1;
      if (index > max) return;
      if (nextIndex == max) {
        $preview.find(".__next").addClass("disabled");
      }
      $preview.find(".__prev").removeClass("disabled");
      index = nextIndex;
      const $target = $preview.find(`iframe[data-index=${nextIndex}]`);
      $preview.find("iframe").hide();
      $preview.find(".__name").text($target.attr("data-name"));
      $target.show();
    })
    .on("click", ".__prev", (evt) => {
      // 前のファイル
      const nextIndex = index - 1;
      if (index < 0) return;
      if (nextIndex == 0) {
        $preview.find(".__prev").addClass("disabled");
      }
      $preview.find(".__next").removeClass("disabled");
      index = nextIndex;
      const $target = $preview.find(`iframe[data-index=${nextIndex}]`);
      $preview.find("iframe").hide();
      $preview.find(".__name").text($target.attr("data-name"));
      $target.show();
    })
    .on("click", ".__download", () => {
      // ダウンロード
      const url = $preview.find(`iframe[data-index=${index}]`).attr("data-url");
      const name = $preview.find(`iframe[data-index=${index}]`).attr("data-name");
      const a = document.createElement("a");
      a.href = url;
      a.download = name;
      a.click();
      a.remove();
    })
    .on("click", ".__close", () => {
      // 閉じる
      $preview.remove();
    });
  };
  
	// 一覧画面
	kintone.events.on("app.record.index.show", (e) => {
		const records = e.records;
  
    for (const code of getFileFields()) {
      // 添付ファイルフィールド要素を取得
  	  const elements = kintone.app.getFieldElements(code);
  	  if (!elements) return;
    
      for (const element of elements) {
        if (!$(element).find("a").length) continue;

        const link = $(element).find("a").attr("href");
        if (!link) continue;

        const id = getParam(link, "record");
        const record = records.find(x => x.$id.value == id);
  
        // PDFがあれば取得
        const files = record[code].value.filter(x => x.contentType == "application/pdf");
        if (files.length) {
          const $button = $(`<button><i class="fa-solid fa-magnifying-glass"></i></button>`);
          $button.data("files", files);
          $(element).addClass("__preview").append($button);
    
          $button.on("click", (evt) => {
            preview(files);
          });
        }
      }
    }
		return e;
	});
  
	// 詳細画面
	kintone.events.on("app.record.detail.show", (e) => {
		const record = e.record;
    for (const code of getFileFields()) {
      const element = kintone.app.record.getFieldElement(code);
      if (!$(element).find("a").length) continue;
  
        // PDFがあれば取得
      const files = record[code].value.filter(x => x.contentType == "application/pdf");
      if (files.length) {
        const $button = $(`<button><i class="fa-solid fa-magnifying-glass"></i>プレビュー</button>`);
        $button.data("files", files);
        $(element).addClass("__preview").append($button);

        $button.on("click", () => {
          preview(files);
        });
      }
    }
		return e;
	});
})(jQuery);

preview.css

CSS
@charset "UTF-8";

/* 一覧画面 */
td.__preview {
  position: relative;
  padding-right: 30px;
}
td.__preview ul {
  width: 100%;
}
td.__preview li {
  width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
td.__preview button {
  display: flex;
  align-items: center;
  position: absolute;
  top: 0;
  right: 0;
  width: 30px;
  height: calc(100% - 1px);
  background: #fdfeff;
  border: none;
  border-left: 1px solid #e3e7e8;
  cursor: pointer;
  user-select: none;
}
td.__preview button:hover {
  background: #f7f7f7;
}
td.__preview button svg {
  color: #3498db;
  font-size: 18px;
  pointer-events: none;
}

/* 詳細画面 */
div.control-value-gaia.__preview > button {
  margin-top: 8px;
  padding: 0 24px;
  width: 100%;
  height: 40px;
  background: #fdfeff;
  box-shadow: none;
  box-sizing: border-box;
  border: 1px solid #e3e7e8;
  color: #3498db;
  cursor: pointer;
  font-size: 14px;
  line-height: 40px;
  user-select: none;
}
div.control-value-gaia.__preview > button:hover {
  background: #f7f7f7;
}
div.control-value-gaia.__preview > button svg {
  margin-right: 4px;
  pointer-events: none;
}

/* プレビュー */
#_preview {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  z-index: 300;
}
#_preview * {
  box-sizing: border-box;
}
#_preview .__preview_content {
  position: fixed;
  padding: 0 20px 20px 20px;
  width: 90vw;
  height: 95vh;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: rgba(0,0,0,0.6);
  border-radius: 16px;
  z-index: 302;
}
#_preview .__background {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0,0,0,0.3);
  z-index: 301;
}
#_preview .__preview_header {
  display: flex;
  column-gap: 8px;
  padding: 10px 0;
  width: 100%;
  height: 60px;
  white-space: nowrap;
}
#_preview .__preview_header .__prev,
#_preview .__preview_header .__next,
#_preview .__preview_header .__download,
#_preview .__preview_header .__close {
  position: relative;
  width: 40px;
  height: 40px;
  cursor: pointer;
}
#_preview .__preview_header .__prev svg,
#_preview .__preview_header .__next svg,
#_preview .__preview_header .__download svg,
#_preview .__preview_header .__close svg {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  color: #eee;
  font-size: 30px;
  pointer-events: none;
}
#_preview .__preview_header .__prev.disabled,
#_preview .__preview_header .__next.disabled {
  pointer-events: none;
}
#_preview .__preview_header .__prev.disabled svg,
#_preview .__preview_header .__next.disabled svg {
  color: #888;
}
#_preview .__preview_header .__name {
  margin-right: auto;
  width: calc(90vw - 250px);
  color: #eee;
  overflow: hidden;
  text-overflow: ellipsis;
  line-height: 40px;
  white-space: nowrap;
  user-select: none;
}
#_preview .__iframes {
  width: 100%;
  height: calc(95vh - 80px);
}
#_preview .__iframes iframe {
  width: 100%;
  height: 100%;
  border: none;
}

コードをざっくり解説

まずは添付ファイルのフィールドコードを取得する関数。直接指定してもいいのだが、添付ファイルが複数ある場合などに、自動で取得するようにしたかったので、cybozu.dataの中にあるフィールド情報から添付ファイルフィールドだけに絞ってフィールドをコードを取得するようになっている。

JavaScript
// 添付ファイルフィールド取得
const getFileFields = () => {
	const fields = [];
	const fieldList = cybozu.data.page.SCHEMA_DATA.table.fieldList;
	for (const id in fieldList) {
	  if (fieldList[id].type != "FILE") continue;
	  fields.push(fieldList[id].var);
	}
  return fields;
};

URLからパラメータを取得する関数。これは一覧画面にて添付ファイルのURLにレコード番号があったので、それを取得する用の関数。

JavaScript
// パラメータ取得
const getParam = (link, key) => {
  const url = new URL(link);
  const params = url.searchParams;
  return params.get(key);
};

ファイルをダウンロードBlobURLで返却する関数。

JavaScript
// ファイルダウンロード
const download = async (fileKey) => {
	const fileUrl = `${kintone.api.url("/k/v1/file.json", true)}?fileKey=${fileKey}`;
	try {
		const headers = {
			"X-Requested-With": "XMLHttpRequest"
		};
		const resp = await fetch(fileUrl, {
			method: "GET",
			headers
		});
		const blob = await resp.blob();
		const url = window.URL || window.webkitURL;
		const blobUrl = url.createObjectURL(blob);
		return blobUrl;
	} catch (err) {
		console.log(err);
		return null;
	}
};

プレビューを表示する関数。

JavaScript
// プレビュー
const preview = async (files) => {
  const $preview = $(`
    <div id="_preview">
      <div class="__preview_content">
        <div class="__preview_header">
          <div class="__name">${files[0].name}</div>
          <div class="__prev disabled"><i class="fa-solid fa-chevron-left"></i></div>
          <div class="__next"><i class="fa-solid fa-chevron-right"></i></div>
          <div class="__download"><i class="fa-solid fa-download"></i></div>
          <div class="__close"><i class="fa-regular fa-circle-xmark"></i></div>
        </div>
        <div class="__iframes"></div>
      </div>
      <div class="__background __close"></div>
    </div>
  `).appendTo(document.body);
  
  // 1ファイルの場合
  if (files.length == 1) {
    $preview.find(".__prev, .__next").remove();
  }
  
  // ファイル追加
  for (const [i, file] of files.entries()) {
    const url = await download(file.fileKey);
    const params = "#toolbar=0&navpanes=0";
    const $iframe = $(`<iframe data-index=${i}></iframe>`)
      .attr("src", `${url}${params}`)
      .attr("data-url", url)
      .attr("data-name", file.name)
      .appendTo($preview.find(".__iframes"));
    if (i != 0) $iframe.hide();
  }
  
  let index = 0;
  $preview.on("click", ".__next", (evt) => {
    // 次のファイル
    const max = files.length - 1;
    const nextIndex = index + 1;
    if (index > max) return;
    if (nextIndex == max) {
      $preview.find(".__next").addClass("disabled");
    }
    $preview.find(".__prev").removeClass("disabled");
    index = nextIndex;
    const $target = $preview.find(`iframe[data-index=${nextIndex}]`);
    $preview.find("iframe").hide();
    $preview.find(".__name").text($target.attr("data-name"));
    $target.show();
  })
  .on("click", ".__prev", (evt) => {
    // 前のファイル
    const nextIndex = index - 1;
    if (index < 0) return;
    if (nextIndex == 0) {
      $preview.find(".__prev").addClass("disabled");
    }
    $preview.find(".__next").removeClass("disabled");
    index = nextIndex;
    const $target = $preview.find(`iframe[data-index=${nextIndex}]`);
    $preview.find("iframe").hide();
    $preview.find(".__name").text($target.attr("data-name"));
    $target.show();
  })
  .on("click", ".__download", () => {
    // ダウンロード
    const url = $preview.find(`iframe[data-index=${index}]`).attr("data-url");
    const name = $preview.find(`iframe[data-index=${index}]`).attr("data-name");
    const a = document.createElement("a");
    a.href = url;
    a.download = name;
    a.click();
    a.remove();
  })
  .on("click", ".__close", () => {
    // 閉じる
    $preview.remove();
  });
};

一覧画面表示イベント。添付ファイルフィールドにPDFがあればプレビューボタンを配置。

JavaScript
// 一覧画面
kintone.events.on("app.record.index.show", (e) => {
  const records = e.records;

  for (const code of getFileFields()) {
    // 添付ファイルフィールド要素を取得
    const elements = kintone.app.getFieldElements(code);
    if (!elements) return;
  
    for (const element of elements) {
      if (!$(element).find("a").length) continue;

      const link = $(element).find("a").attr("href");
      if (!link) continue;

      const id = getParam(link, "record");
      const record = records.find(x => x.$id.value == id);
      
      // PDFがあれば取得
      const files = record[code].value.filter(x => x.contentType == "application/pdf");
      if (files.length) {
        const $button = $(`<button><i class="fa-solid fa-magnifying-glass"></i></button>`);
        $button.data("files", files);
        $(element).addClass("__preview").append($button);
  
        $button.on("click", (evt) => {
          preview(files);
        });
      }
    }
  }
  return e;
});

詳細画面表示イベント。添付ファイルフィールドにPDFがあればプレビューボタンを配置。

JavaScript
// 詳細画面
kintone.events.on("app.record.detail.show", (e) => {
  const record = e.record;
  for (const code of getFileFields()) {
    const element = kintone.app.record.getFieldElement(code);
  
    // PDFがあれば取得
    const files = record[code].value.filter(x => x.contentType == "application/pdf");
    if (files.length) {
      const $button = $(`<button><i class="fa-solid fa-magnifying-glass"></i>プレビュー</button>`);
      $button.data("files", files);
      $(element).addClass("__preview").append($button);

      $button.on("click", () => {
        preview(files);
      });
    }
  }
  return e;
});

以上です。

あまり細かくは説明してませんが、コード内のコメントを見ていただければと思いますm(__)m

2024.07.22