ゲーム開発
Unity
UnrealEngine
C++
Blender
Houdini
ゲーム数学
ゲームAI
グラフィックス
サウンド
アニメーション
GBDK
制作日記
IT関連
ツール開発
フロントエンド関連
サーバサイド関連
WordPress関連
ソフトウェア設計
おすすめ技術書
音楽
DTM
楽器・機材
ピアノ
ラーメン日記
四コマ漫画
その他
おすすめアイテム
おもしろコラム
  • ゲーム開発
    • Unity
    • UnrealEngine
    • C++
    • Blender
    • Houdini
    • ゲーム数学
    • ゲームAI
    • グラフィックス
    • サウンド
    • アニメーション
    • GBDK
    • 制作日記
  • IT関連
    • ツール開発
    • フロントエンド関連
    • サーバサイド関連
    • WordPress関連
    • ソフトウェア設計
    • おすすめ技術書
  • 音楽
    • DTM
    • 楽器・機材
    • ピアノ
  • ラーメン日記
    • 四コマ漫画
      • その他
        • おすすめアイテム
        • おもしろコラム
      1. ホーム
      2. 20231231_02_image_resizer

      【Electron × Vue3】画像をリサイズして任意の場所に保存するツールを作る

      ツール開発JavaScriptフロントエンド関連ElectronVue.jsTypeScriptVite
      2023-12-31

      マイケル
      マイケル
      みなさんこんにちは! マイケルです!
      エレキベア
      エレキベア
      こんにちクマ〜〜
      マイケル
      マイケル
      この度、当ブログをWordPressからNext.jsへ移行したのですが、 執筆環境もこの上ないものにしたい! ・・・ということで、執筆時に使用するツールをいくつか作ってみました!
      マイケル
      マイケル
      今回は、その中の 画像リサイズツール について紹介します!

      20231231_image_resize_01
      ▲作成した画像リサイズツール

      20240101_01_next_elekibear_gif_05
      ▲画像をリサイズ・アップロードできる


      エレキベア
      エレキベア
      おお〜〜〜 よくある画像リサイズを行うツールクマね
      マイケル
      マイケル
      そう、ブログ記事を書く時に画像サイズを調整して 指定フォルダに出力 するだけのシンプルなツールです! 作ったアプリは下記のGitHubにあげています!

      GitHub - electron-image-resizer


      エレキベア
      エレキベア
      今回はElectronを使用して作ったのクマね
      マイケル
      マイケル
      ブログ移行でNext.jsがだいぶ分かるようになったから、 どうせならツール側もフロントエンド技術で作ってみようと手を出してみたよ! 内容はシンプルだから、まだあまり触ったことがない人でも理解しやすいと思います。
      エレキベア
      エレキベア
      楽しみクマ〜〜〜

      ツールの概要と目的

      ツールの概要

      マイケル
      マイケル
      使い方はシンプルで、縦横のサイズをそれぞれ入力して出力ボタンを押下 するだけ! 入力したパスにリサイズされた画像が出力されます。
      20231231_image_resize_04
      ▲リサイズ後、出力ボタンを押下すると指定パスに保存される

      マイケル
      マイケル
      リサイズは 縦横比固定、固定無しで指定可能 になっています。 その他、詳細な使い方についてはGitHubのREADMEをご参照ください!
      20231231_image_resize_03
      ▲入力した値に応じて画像がリサイズされる

      エレキベア
      エレキベア
      これなら簡単に作れそうクマ〜〜

      目的

      マイケル
      マイケル
      ツールを作った目的は、ブログ執筆時の画像リサイズを行いやすくするため です。 Next.jsに移行する前は MarsEdit というツールを使用していたのですが、そちらの画像アップロードに近い形で使用できるよう開発しました。
      20231231_image_resize_06
      ▲MarsEditに搭載されていた画像アップロード機能が使いやすかった

      エレキベア
      エレキベア
      MarsEditにはお世話になったクマ〜〜 今回作るツールは Win/Mac 共用で使える点もいいクマね

      Electron × Vue3での実装

      マイケル
      マイケル
      それでは早速実装の方を見ていきます。 なおElectron、Vueを使用した環境構築については下記記事にまとめていますので、こちらをご参照ください!
      【Electron × Vue3】Electron × Vue3 × TypeScript × Vite でツール開発環境を整える
      2023-12-31
      エレキベア
      エレキベア
      Web開発あるあるクマが環境構築は割と手がかかるクマね

      メインとなるコード全文

      マイケル
      マイケル
      今回、処理のメインとなるVueコード全文は下記になります。
      <script setup lang="ts">
      import { defineComponent, onMounted, reactive, Ref, ref } from 'vue'
      import { useElectronApi } from '../api/electron-api'
      
      const electronApi = useElectronApi()
      
      declare global {
        interface File {
          path: string
        }
      }
      
      onMounted(() => {
        loadAllStoreData()
      })
      
      /**
       * 保存データキー
       */
      const StoreDataKey = {
        IsKeepRatio: 'IsKeepRatio',
        OutputPath: 'OutputPath',
      }
      
      /**
       * 保存データロード処理
       */
      const loadAllStoreData = () => {
        // 保存されているデータがあれば設定する
        electronApi.loadStoreData(StoreDataKey.IsKeepRatio, (storeData) => {
          if (storeData != undefined) {
            isKeepRatio.value = storeData
          }
        })
        electronApi.loadStoreData(StoreDataKey.OutputPath, (storeData) => {
          if (storeData != undefined) {
            outputPath.value = storeData
          }
        })
      }
      
      /**
       * オリジナル画像データ
       */
      const originalImage = reactive({
        name: '',
        extention: '',
        path: '',
        ratio: 0,
        element: null,
      })
      
      /**
       * リサイズ画像情報
       */
      const resizeImageSrc = ref('')
      const resizeImageWidth = ref(0)
      const resizeImageHeight = ref(0)
      
      /**
       * ファイルドロップ処理
       */
      const onDropFile = (event: DragEvent) => {
        if (!event?.dataTransfer) {
          return
        }
        if (event.dataTransfer.files.length === 0) {
          return
        }
        const file = event.dataTransfer.files[0]
        onSetPreviewImage(file)
      }
      
      /**
       * プレビュー画像の設定
       */
      const onSetPreviewImage = (file: File) => {
        // 画像情報設定
        originalImage.name = file.name
        originalImage.extention = file.name.split('.').pop()
        originalImage.path = file.path
        originalImage.element = null
      
        resizeImageSrc.value = URL.createObjectURL(file)
      
        // gifはリサイズできないメッセージ
        message.value = ''
        if (originalImage.extention == 'gif') {
          message.value = 'gif形式のファイルはリサイズに対応していません'
        }
      
        // 画像読み込んで幅を設定
        const reader = new FileReader()
        reader.onloadend = () => {
          if (!reader.result || !(typeof reader.result == 'string')) {
            return
          }
          let loadImage = new Image()
          loadImage.src = reader.result
          loadImage.onload = () => {
            resizeImageHeight.value = loadImage.naturalHeight
            resizeImageWidth.value = loadImage.naturalWidth
            // 横のサイズをベースに比率を計算
            originalImage.ratio = resizeImageWidth.value / resizeImageHeight.value
            originalImage.element = loadImage
          }
        }
        reader.readAsDataURL(file)
      }
      
      /**
       * 画像最大サイズ
       */
      const IMAGE_SIZE_MAX_VALUE = 1200
      
      /**
       * 画像サイズ変更処理
       */
      const onChangeWidthValue = (e: any) => {
        if (!originalImage.element) {
          resizeImageWidth.value = 0
          return
        }
        let inputValue = e.target.value
        if (inputValue >= IMAGE_SIZE_MAX_VALUE) {
          inputValue = IMAGE_SIZE_MAX_VALUE
        }
        resizeImageWidth.value = inputValue
        if (isKeepRatio.value) {
          resizeImageHeight.value = resizeImageWidth.value / originalImage.ratio
        }
        const base64 = onGetResizeImageBase64(originalImage.element, resizeImageWidth.value, resizeImageHeight.value)
        resizeImageSrc.value = base64
      }
      const onChangeHeightValue = (e: any) => {
        if (!originalImage.element) {
          resizeImageHeight.value = 0
          return
        }
        let inputValue = e.target.value
        if (inputValue >= IMAGE_SIZE_MAX_VALUE) {
          inputValue = IMAGE_SIZE_MAX_VALUE
        }
        resizeImageHeight.value = inputValue
        if (isKeepRatio.value) {
          resizeImageWidth.value = resizeImageHeight.value * originalImage.ratio
        }
        const base64 = onGetResizeImageBase64(originalImage.element, resizeImageWidth.value, resizeImageHeight.value)
        resizeImageSrc.value = base64
      }
      
      /**
       * リサイズしたbase64データを取得
       * https://qiita.com/komakomako/items/8efd4184f6d7cf1363f2
       */
      const onGetResizeImageBase64 = (image: HTMLImageElement, width: number, height: number) => {
        const canvas = document.createElement('canvas')
        canvas.width = width
        canvas.height = height
        const context = canvas.getContext('2d')
        if (!context) {
          return ''
        }
        context.drawImage(image, 0, 0, width, height)
        const base64 = canvas.toDataURL('image/png')
        return base64
      }
      
      /**
       * リサイズした画像ファイルを保存
       */
      const onSaveFile = () => {
        if (!originalImage.element) {
          message.value = '画像を読み込んでいません'
          return
        }
        if (!outputPath.value) {
          message.value = '出力フォルダが定義されていません'
          return
        }
      
        message.value = ''
        let outputDirPath: string = outputPath.value
        if (outputDirPath.endsWith('/')) {
          outputDirPath = outputDirPath.slice(0, -1)
        }
        if (originalImage.extention == 'gif') {
          // gifはリサイズできないのでファイルコピー
          const fromPath = originalImage.path
          const fileName = originalImage.name.split('.')[0] + '.gif'
          const toPath = outputDirPath + '/' + fileName
          electronApi.copyFile(fromPath, toPath, (result) => (message.value = result))
        } else {
          // それ以外はpng形式でリサイズして保存
          const fileName = originalImage.name.split('.')[0] + '.png'
          const data = onGetResizeImageBase64(originalImage.element, resizeImageWidth.value, resizeImageHeight.value)
          electronApi.savePngFile(outputDirPath, fileName, data, (result) => (message.value = result))
        }
      }
      
      /**
       * 画像の縦横比固定するか?
       */
      const isKeepRatio = ref(true)
      const onChangeIsKeepRatio = (e: any) => {
        let inputValue = e.target.checked
        electronApi.saveStoreData(StoreDataKey.IsKeepRatio, inputValue)
      }
      
      /**
       * 出力パス
       */
      const outputPath = ref('')
      const onChangeOutputPath = (e: any) => {
        let inputValue = e.target.value
        electronApi.saveStoreData(StoreDataKey.OutputPath, inputValue)
      }
      
      /**
       * メッセージ
       */
      const message = ref('')
      </script>
      
      <template>
        <div class="container">
          <div class="container-item image-area" @dragover.prevent @drop.prevent="onDropFile">
            <img v-if:="resizeImageSrc" class="image-item" :src="resizeImageSrc" />
            <div v-if:="!resizeImageSrc" class="image-drop-box">画像をドラッグ&ドロップしてください。</div>
          </div>
          <label class="container-item image-name-area">{{ originalImage.name }}</label>
          <div class="container-item size-info-area">
            <input
              @input="onChangeWidthValue"
              class="size-input-value-item"
              type="number"
              min="0"
              maxlength="1200"
              placeholder="width"
              v-model.number="resizeImageWidth"
              :disabled="originalImage.extention == 'gif'"
            />
            <span class="size-input-value-px">px</span>
            <div class="size-input-value-between">x</div>
            <input
              @input="onChangeHeightValue"
              class="size-input-value-item"
              type="number"
              min="0"
              maxlength="1200"
              placeholder="height"
              v-model.number="resizeImageHeight"
              :disabled="originalImage.extention == 'gif'"
            />
            <span class="size-input-value-px">px</span>
            <div class="size-input-keep-ratio-area">
              <input @input="onChangeIsKeepRatio" class="size-input-keep-ratio-check" type="checkbox" v-model="isKeepRatio" />
              <label>縦横比<br />固定</label>
            </div>
          </div>
          <div class="container-item output-file-area">
            <input
              @input="onChangeOutputPath"
              class="output-file-value"
              type="text"
              placeholder="出力フォルダ"
              v-model="outputPath"
            />
            <button class="output-file-button" v-on:click="onSaveFile">出力</button>
          </div>
          <div class="container-item message-area">{{ message }}</div>
        </div>
      </template>
      
      <style scoped>
      input {
        border-radius: 4px;
      }
      .container {
        text-align: center;
        display: flex;
        flex-flow: column;
        max-width: 640px;
      }
      .container-item {
        margin-bottom: 24px;
      }
      /** 画像エリア */
      .image-area {
        display: flex;
        justify-content: center;
        align-items: center;
        position: relative;
        height: 280px;
        width: 100%;
      }
      .image-item {
        max-width: 100%;
        max-height: 280px;
      }
      .image-drop-box {
        width: 100%;
        height: 90%;
        display: flex;
        justify-content: center;
        align-items: center;
        color: #777;
        border: 10px dashed #aaa;
        padding: 0px 12px;
        vertical-align: middle;
        text-align: center;
        border-radius: 8px;
      }
      .image-name-area {
        height: 12px;
        display: flex;
        justify-content: center;
        align-items: center;
      }
      /** サイズ情報 */
      .size-info-area {
        display: flex;
        justify-content: space-between;
        align-items: center;
        width: 90%;
        height: 32px;
        margin-left: auto;
        margin-right: auto;
      }
      .size-input-value-px {
        width: 40px;
        display: flex;
        justify-content: left;
        align-items: center;
        margin-left: 8px;
      }
      .size-input-value-between {
        width: 80px;
      }
      .size-input-value-item {
        width: 80%;
        height: 100%;
        text-align: right;
      }
      .size-input-keep-ratio-area {
        width: 240px;
        display: flex;
        justify-content: center;
        align-items: center;
        font-size: 14px;
      }
      .size-input-keep-ratio-check {
        height: 18px;
        width: 18px;
        margin-right: 6px;
      }
      /** 出力パスエリア */
      .output-file-area {
        display: flex;
        justify-content: space-between;
        align-items: center;
        width: 90%;
        height: 32px;
        margin-left: auto;
        margin-right: auto;
      }
      .output-file-value {
        height: 100%;
        width: 100%;
      }
      .output-file-button {
        width: 80px;
        height: 120%;
        font-size: 12px;
        display: flex;
        justify-content: center;
        align-items: center;
        margin-left: 12px;
        box-shadow: 2px 2px 6px #555555;
      }
      /** メッセージ */
      .message-area {
        height: 20px;
      }
      </style>
      
      
      エレキベア
      エレキベア
      Vueはscript、template、styleの順で記述するのだったクマね

      ドラッグ&ドロップで画像を表示する

      マイケル
      マイケル
      ツール挙動としてはまず、ドラッグ&ドロップで画像を表示する仕様でした。
      20231231_image_resize_02
      ▲画像をドラッグ&ドロップすると表示される

      マイケル
      マイケル
      この部分の処理を抜き出すと下記のようになります。 @drop.prevent で受け取ったイベントから画像ファイルを読み込んで表示しています。 また、読み込んだ画像については originalImage変数に保持 します。
      <script setup lang="ts">
      import { defineComponent, onMounted, reactive, Ref, ref } from 'vue'
      
      ・・・
      
      declare global {
        interface File {
          path: string
        }
      }
      
      ・・・
      
      /**
       * オリジナル画像データ
       */
      const originalImage = reactive({
        name: '',
        extention: '',
        path: '',
        ratio: 0,
        element: null,
      })
      
      /**
       * リサイズ画像情報
       */
      const resizeImageSrc = ref('')
      const resizeImageWidth = ref(0)
      const resizeImageHeight = ref(0)
      
      /**
       * ファイルドロップ処理
       */
      const onDropFile = (event: DragEvent) => {
        if (!event?.dataTransfer) {
          return
        }
        if (event.dataTransfer.files.length === 0) {
          return
        }
        const file = event.dataTransfer.files[0]
        onSetPreviewImage(file)
      }
      
      /**
       * プレビュー画像の設定
       */
      const onSetPreviewImage = (file: File) => {
        // 画像情報設定
        originalImage.name = file.name
        originalImage.extention = file.name.split('.').pop()
        originalImage.path = file.path
        originalImage.element = null
      
        resizeImageSrc.value = URL.createObjectURL(file)
      
        // gifはリサイズできないためメッセージを表示
        message.value = ''
        if (originalImage.extention == 'gif') {
          message.value = 'gif形式のファイルはリサイズに対応していません'
        }
      
        // 画像読み込んで幅を設定
        const reader = new FileReader()
        reader.onloadend = () => {
          if (!reader.result || !(typeof reader.result == 'string')) {
            return
          }
          let loadImage = new Image()
          loadImage.src = reader.result
          loadImage.onload = () => {
            resizeImageHeight.value = loadImage.naturalHeight
            resizeImageWidth.value = loadImage.naturalWidth
            // 横のサイズをベースに比率を計算
            originalImage.ratio = resizeImageWidth.value / resizeImageHeight.value
            originalImage.element = loadImage
          }
        }
        reader.readAsDataURL(file)
      }
      
      ・・・略・・・
      
      </script>
      
      <template>
        <div class="container">
          <div class="container-item image-area" @dragover.prevent @drop.prevent="onDropFile">
            <img v-if:="resizeImageSrc" class="image-item" :src="resizeImageSrc" />
            <div v-if:="!resizeImageSrc" class="image-drop-box">画像をドラッグ&ドロップしてください。</div>
          </div>
          <label class="container-item image-name-area">{{ originalImage.name }}</label>
      ・・・略・・・
        </div>
      </template>
      
      エレキベア
      エレキベア
      この辺りはWebでの実装と変わらないクマね

      画像リサイズ処理

      マイケル
      マイケル
      画像のリサイズ処理としては、下記のように数値を入力すると変わる挙動になっています。 縦横比固定のチェックボックスを入れておくと、比率を固定したままリサイズできます。
      20231231_image_resize_03
      ▲入力した値に応じて画像がリサイズされる

      20231231_image_resize_05
      ▲「縦横比固定」にチェックボックスを入れておくと比率を維持したままリサイズ可能

      マイケル
      マイケル
      こちらの処理は下記のようになっています。 canvasに設定してリサイズする手法 を使っていて、下記のQiita記事を参考にさせていただきました。

      ブラウザでローカル画像をリサイズしてアップロード - Qiita

      <script setup lang="ts">
      
      ・・・略・・・
      
      /**
       * 画像最大サイズ
       */
      const IMAGE_SIZE_MAX_VALUE = 1200
      
      /**
       * 画像サイズ変更処理
       */
      const onChangeWidthValue = (e: any) => {
        if (!originalImage.element) {
          resizeImageWidth.value = 0
          return
        }
        let inputValue = e.target.value
        if (inputValue >= IMAGE_SIZE_MAX_VALUE) {
          inputValue = IMAGE_SIZE_MAX_VALUE
        }
        resizeImageWidth.value = inputValue
        if (isKeepRatio.value) {
          resizeImageHeight.value = resizeImageWidth.value / originalImage.ratio
        }
        const base64 = onGetResizeImageBase64(originalImage.element, resizeImageWidth.value, resizeImageHeight.value)
        resizeImageSrc.value = base64
      }
      const onChangeHeightValue = (e: any) => {
        if (!originalImage.element) {
          resizeImageHeight.value = 0
          return
        }
        let inputValue = e.target.value
        if (inputValue >= IMAGE_SIZE_MAX_VALUE) {
          inputValue = IMAGE_SIZE_MAX_VALUE
        }
        resizeImageHeight.value = inputValue
        if (isKeepRatio.value) {
          resizeImageWidth.value = resizeImageHeight.value * originalImage.ratio
        }
        const base64 = onGetResizeImageBase64(originalImage.element, resizeImageWidth.value, resizeImageHeight.value)
        resizeImageSrc.value = base64
      }
      
      /**
       * リサイズしたbase64データを取得
       * https://qiita.com/komakomako/items/8efd4184f6d7cf1363f2
       */
      const onGetResizeImageBase64 = (image: HTMLImageElement, width: number, height: number) => {
        const canvas = document.createElement('canvas')
        canvas.width = width
        canvas.height = height
        const context = canvas.getContext('2d')
        if (!context) {
          return ''
        }
        context.drawImage(image, 0, 0, width, height)
        const base64 = canvas.toDataURL('image/png')
        return base64
      }
      
      ・・・略・・・
      
      </script>
      
      <template>
        <div class="container">
      
      ・・・略・・・
      
          <div class="container-item size-info-area">
            <input
              @input="onChangeWidthValue"
              class="size-input-value-item"
              type="number"
              min="0"
              maxlength="1200"
              placeholder="width"
              v-model.number="resizeImageWidth"
              :disabled="originalImage.extention == 'gif'"
            />
            <span class="size-input-value-px">px</span>
            <div class="size-input-value-between">x</div>
            <input
              @input="onChangeHeightValue"
              class="size-input-value-item"
              type="number"
              min="0"
              maxlength="1200"
              placeholder="height"
              v-model.number="resizeImageHeight"
              :disabled="originalImage.extention == 'gif'"
            />
            <span class="size-input-value-px">px</span>
            <div class="size-input-keep-ratio-area">
              <input @input="onChangeIsKeepRatio" class="size-input-keep-ratio-check" type="checkbox" v-model="isKeepRatio" />
              <label>縦横比<br />固定</label>
            </div>
          </div>
      
      ・・・略・・・
      
        </div>
      </template>
      
      エレキベア
      エレキベア
      canvasでリサイズした画像データをbase64で取得するのクマね
      マイケル
      マイケル
      シンプルな方法はだけど、GIFアニメーション画像に対応できないのは難点だね

      画像ファイル保存処理

      マイケル
      マイケル
      リサイズした後、出力ボタンを押下することで指定のフォルダに保存することができます。
      20231231_image_resize_04
      ▲リサイズ後、出力ボタンを押下すると指定パスに保存される

      マイケル
      マイケル
      こちらの処理は下記のようになっています。 ボタン押下して呼び出しているだけですが、ファイルの保存処理はNode.jsで実行する必要があるためElectronのメインプロセスに記述する必要があります。
      <script setup lang="ts">
      import { defineComponent, onMounted, reactive, Ref, ref } from 'vue'
      import { useElectronApi } from '../api/electron-api'
      
      const electronApi = useElectronApi()
      
      ・・・略・・・
      
      /**
       * リサイズした画像ファイルを保存
       */
      const onSaveFile = () => {
        if (!originalImage.element) {
          message.value = '画像を読み込んでいません'
          return
        }
        if (!outputPath.value) {
          message.value = '出力フォルダが定義されていません'
          return
        }
      
        message.value = ''
        let outputDirPath: string = outputPath.value
        if (outputDirPath.endsWith('/')) {
          outputDirPath = outputDirPath.slice(0, -1)
        }
        if (originalImage.extention == 'gif') {
          // gifはリサイズできないのでファイルコピー
          const fromPath = originalImage.path
          const fileName = originalImage.name.split('.')[0] + '.gif'
          const toPath = outputDirPath + '/' + fileName
          electronApi.copyFile(fromPath, toPath, (result) => (message.value = result))
        } else {
          // それ以外はpng形式でリサイズして保存
          const fileName = originalImage.name.split('.')[0] + '.png'
          const data = onGetResizeImageBase64(originalImage.element, resizeImageWidth.value, resizeImageHeight.value)
          electronApi.savePngFile(outputDirPath, fileName, data, (result) => (message.value = result))
        }
      }
      
      ・・・略・・・
      
      </script>
      
      <template>
        <div class="container">
      
      ・・・略・・・
      
          <div class="container-item output-file-area">
            <input
              @input="onChangeOutputPath"
              class="output-file-value"
              type="text"
              placeholder="出力フォルダ"
              v-model="outputPath"
            />
            <button class="output-file-button" v-on:click="onSaveFile">出力</button>
          </div>
      
      ・・・略・・・
      
        </div>
      </template>
      
      マイケル
      マイケル
      Electron側の処理は下記のようになっています。 main.tsで設定した処理をpreload.tsを経由して呼び出す形になっていて、 contextBridgeに設定することで、クライアント側からは windowオブジェクトから参照 できるようになります。
      
      ・・・
      
      // ----- レンダラープロセスから呼び出される処理 -----
      
      /** ファイル保存関連 */
      const fs = require('fs')
      
      ・・・略・・・
      
      /**
       * pngファイル保存処理
       */
      ipcMain.handle('savePngFile', async (event, fileDir: string, fileName: string, data: string) => {
        try {
          fs.statSync(fileDir)
        } catch (e) {
          return `フォルダが存在しません => ${fileDir}`
        }
      
        const base64Data = data.replace(/^data:image\/png;base64,/, '')
        const outputFilePath = path.join(fileDir, fileName)
        try {
          fs.writeFileSync(outputFilePath, base64Data, 'base64')
        } catch (e) {
          return `ファイルの保存に失敗しました => ${outputFilePath}`
        }
        return `ファイルを保存しました => ${outputFilePath}`
      })
      
      /**
       * ファイルコピー処理 (gifファイル用)
       */
      ipcMain.handle('copyFile', async (event, fromPath: string, toPath: string) => {
        try {
          fs.statSync(fromPath)
        } catch (e) {
          return `ファイルが存在しません => ${fromPath}`
        }
      
        try {
          fs.copyFileSync(fromPath, toPath)
        } catch (e) {
          console.log(e)
          return `ファイルの保存に失敗しました => ${toPath}`
        }
        return `ファイルを保存しました => ${toPath}`
      })
      
      ・・・略・・・
      
      
      const { ipcRenderer, contextBridge } = require('electron')
      
      // レンダラープロセス -> メインプロセス 処理を呼び出すためのブリッジ
      // コンテキストを分離してwindowオブジェクトに設定する
      // https://www.electronjs.org/ja/docs/latest/tutorial/context-isolation
      contextBridge.exposeInMainWorld('electronAPI', {
      
      ・・・略・・・
      
        savePngFile: (fileDir: string, fileName: string, data: string) =>
          ipcRenderer.invoke('savePngFile', fileDir, fileName, data),
        copyFile: (fromPath: string, toPath: string) => ipcRenderer.invoke('copyFile', fromPath, toPath),
      
      ・・・略・・・
      
      })
      
      
      エレキベア
      エレキベア
      メインプロセス側の処理を何もかも実行できるようにしては困るから 必要最低限の処理のみクライアントに公開するクマね
      マイケル
      マイケル
      あとはクライアント側で下記のようにElectron側のAPIを呼び出すラッパーを作成すれば完成です! TypeScriptを使用する場合、windowオブジェクトの型を拡張する必要がある点には注意です。
      
      /**
       * Electronメインプロセス側に定義した処理
       * windowオブジェクトに設定しているため、型定義を拡張する必要がある
       */
      export interface IElectronAPI {
      
      ・・・略・・・
      
        savePngFile: (fileDir: string, fileName: string, data: string) => Promise<string>
        copyFile: (fromPath: string, toPath: string) => Promise<string>
      
      ・・・略・・・
      
      }
      declare global {
        interface Window {
          electronAPI: IElectronAPI
        }
      }
      
      /**
       * Electron API
       * Composition Function
       * @returns
       */
      export const useElectronApi = () => {
      
      ・・・略・・・
      
        const savePngFile = (fileDir: string, fileName: string, data: string, callback: (result: string) => void) => {
          if (!window.electronAPI) {
            callback('current platform is not support electron api.')
            return
          }
          window.electronAPI.savePngFile(fileDir, fileName, data).then(callback)
        }
      
        const copyFile = (fromPath: string, toPath: string, callback: (result: string) => void) => {
          if (!window.electronAPI) {
            callback('current platform is not support electron api.')
            return
          }
          window.electronAPI.copyFile(fromPath, toPath).then(callback)
        }
      
      ・・・略・・・
      
        return {
      
      ・・・略・・・
      
          savePngFile,
          copyFile,
      
      ・・・略・・・
      
        }
      }
      
      
      
      エレキベア
      エレキベア
      これでメインプロセスとレンダラープロセス間のやり取りはバッチリクマね

      入力値等の保存処理

      マイケル
      マイケル
      最後に、入力した値などを保存する方法についても紹介します。 レンダラープロセスで使用する値のみであれば通常のWebと同様、WebStorageの使用でも問題ないですが、ウィンドウサイズといったメインプロセス側の値は別の方法を使用する必要があります。
      マイケル
      マイケル
      メインプロセス側の保存処理として electron-store を使用する方法があります。 今回は レンダラープロセス側の値も含めてこちらで保存処理を一括管理 することで進めます。
      # electron-storeのインストール
      npm install electron-store
      
      エレキベア
      エレキベア
      保存方法がばらけてしまうよりは統一した方がよさそうクマね
      マイケル
      マイケル
      下記がウィンドウサイズを保存する処理の例になります。 storeオブジェクトを介して保存データのやり取りを行っています。
      import { app, BrowserWindow, ipcMain, screen } from 'electron'
      import * as path from 'path'
      
      // 保存データ
      const Store = require('electron-store')
      const store = new Store()
      
      const StoreDataKey = {
        WindowPosition: 'WindowPosition',
        WindowSize: 'WindowSize',
      }
      
      const DefaultWindowSize = {
        width: 800,
        height: 600,
      }
      
      function createWindow() {
        const windowSize = store.get(StoreDataKey.WindowSize) || [DefaultWindowSize.width, DefaultWindowSize.height]
        const windowPosition = store.get(StoreDataKey.WindowPosition) || getCenterPosition()
      
        const mainWindow = new BrowserWindow({
          width: windowSize[0],
          height: windowSize[1],
          x: windowPosition[0],
          y: windowPosition[1],
          webPreferences: {
            preload: path.join(__dirname, 'preload.js'),
          },
        })
      
        mainWindow.loadFile(path.join(__dirname, '../index.html'))
      
        mainWindow.on('close', () => {
          // ウィンドウ情報を保存
          store.set(StoreDataKey.WindowPosition, mainWindow.getPosition())
          store.set(StoreDataKey.WindowSize, mainWindow.getSize())
        })
      
        // Open the DevTools.
        // mainWindow.webContents.openDevTools();
      }
      
      app.whenReady().then(() => {
        createWindow()
      
        app.on('activate', function () {
          if (BrowserWindow.getAllWindows().length === 0) createWindow()
        })
      })
      
      app.on('window-all-closed', () => {
        if (process.platform !== 'darwin') {
          app.quit()
        }
      })
      
      /**
       * ウィンドウの中央の座標を返却
       *
       * @return {array}
       */
      function getCenterPosition() {
        const { width, height } = screen.getPrimaryDisplay().workAreaSize
        const x = Math.floor((width - DefaultWindowSize.width) / 2)
        const y = Math.floor((height - DefaultWindowSize.height) / 2)
        return [x, y]
      }
      
      ・・・略・・・
      
      
      エレキベア
      エレキベア
      使い方はシンプルクマね
      マイケル
      マイケル
      次にレンダラープロセスから保存処理を呼び出すための設定です。 こちらは先ほどと同様、main.ts、preload.tsで公開した関数をElectron側の処理を呼び出すラッパークラスで定義しました。
      
      ・・・略・・・
      
      // ----- レンダラープロセスから呼び出される処理 -----
      
      ・・・略・・・
      
      /** データ保存関連 */
      ipcMain.handle('saveStoreData', async (event, key: string, value: any) => {
        store.set(key, value)
      })
      
      ipcMain.handle('loadStoreData', async (event, key: string) => {
        return store.get(key)
      })
      
      ipcMain.handle('clearStoreData', async (event) => {
        store.clear()
      })
      
      
      const { ipcRenderer, contextBridge } = require('electron')
      
      // レンダラープロセス -> メインプロセス 処理を呼び出すためのブリッジ
      // コンテキストを分離してwindowオブジェクトに設定する
      // https://www.electronjs.org/ja/docs/latest/tutorial/context-isolation
      contextBridge.exposeInMainWorld('electronAPI', {
      
      ・・・略・・・
      
        saveStoreData: (key: string, value: any) => ipcRenderer.invoke('saveStoreData', key, value),
        loadStoreData: (key: string) => ipcRenderer.invoke('loadStoreData', key),
        clearStoreData: () => ipcRenderer.invoke('clearStoreData'),
      })
      
      
      /**
       * Electronメインプロセス側に定義した処理
       * windowオブジェクトに設定しているため、型定義を拡張する必要がある
       */
      export interface IElectronAPI {
      
      ・・・略・・・
      
        saveStoreData: (key: string, value: any) => Promise<void>
        loadStoreData: (key: string) => Promise<any>
        clearStoreData: () => Promise<void>
      }
      declare global {
        interface Window {
          electronAPI: IElectronAPI
        }
      }
      
      /**
       * Electron API
       * Composition Function
       * @returns
       */
      export const useElectronApi = () => {
      
      ・・・略・・・
      
      
        const saveStoreData = (key: string, value: any) => {
          window.electronAPI?.saveStoreData(key, value)
        }
      
        const loadStoreData = (key: string, callback: (result: any) => void): any => {
          if (!window.electronAPI) {
            callback(null)
            return
          }
          window.electronAPI.loadStoreData(key).then(callback)
        }
      
        const clearStoreData = () => {
          window.electronAPI?.clearStoreData()
        }
      
        return {
      
      ・・・略・・・
      
          saveStoreData,
          loadStoreData,
          clearStoreData,
        }
      }
      
      
      マイケル
      マイケル
      あとはレンダラープロセス側で必要なタイミングで処理を呼び出すようにすれば完了です。 今回は縦横比のチェックボックスと出力パスを保存対象としました。
      <script setup lang="ts">
      import { defineComponent, onMounted, reactive, Ref, ref } from 'vue'
      
      ・・・略・・・
      
      onMounted(() => {
        loadAllStoreData()
      })
      
      /**
       * 保存データキー
       */
      const StoreDataKey = {
        IsKeepRatio: 'IsKeepRatio',
        OutputPath: 'OutputPath',
      }
      
      /**
       * 保存データロード処理
       */
      const loadAllStoreData = () => {
        // 保存されているデータがあれば設定する
        electronApi.loadStoreData(StoreDataKey.IsKeepRatio, (storeData) => {
          if (storeData != undefined) {
            isKeepRatio.value = storeData
          }
        })
        electronApi.loadStoreData(StoreDataKey.OutputPath, (storeData) => {
          if (storeData != undefined) {
            outputPath.value = storeData
          }
        })
      }
      
      ・・・略・・・
      
      /**
       * 画像の縦横比固定するか?
       */
      const isKeepRatio = ref(true)
      const onChangeIsKeepRatio = (e: any) => {
        let inputValue = e.target.checked
        electronApi.saveStoreData(StoreDataKey.IsKeepRatio, inputValue)
      }
      
      /**
       * 出力パス
       */
      const outputPath = ref('')
      const onChangeOutputPath = (e: any) => {
        let inputValue = e.target.value
        electronApi.saveStoreData(StoreDataKey.OutputPath, inputValue)
      }
      
      ・・・略・・・
      
      </script>
      
      ・・・略・・・
      
      
      エレキベア
      エレキベア
      これで完成クマ〜〜〜〜

      おわりに

      マイケル
      マイケル
      というわけで今回はElectronを使用して簡単なツールを作ってみました! どうだったかな?
      エレキベア
      エレキベア
      メインプロセスとレンダラープロセスのやり取りは特殊クマが、それ以外はWeb開発と同じ感覚で作れて感動したクマ
      マイケル
      マイケル
      環境さえ作ってしまえば気軽に作れるから、これからも積極的に活用していきたいね!
      マイケル
      マイケル
      それでは今日はこの辺で! アデューー!!
      エレキベア
      エレキベア
      クマ〜〜〜〜〜

      【Electron × Vue3】画像をリサイズして任意の場所に保存するツールを作る 〜完〜


      ツール開発JavaScriptフロントエンド関連ElectronVue.jsTypeScriptVite
      2023-12-31

      関連記事
      【Unity】Timeline × Excelでスライドショーを効率よく制作する
      2024-10-31
      【ゲーム数学】第九回 p5.jsで学ぶゲーム数学「フーリエ解析」
      2024-05-12
      【Node.js】廃止されたAmazonアソシエイト画像リンクをAmazon Product Advertising API経由で復活させる
      2024-01-08
      【都会のエレキベア】ブログを大幅リニューアル!WordPressからNext.jsに移行するまでの流れをまとめる
      2024-01-01
      【Next.js】第四回 WordPressブログをNext.jsに移行する 〜サーバ移行・SEO・広告設定編〜
      2023-12-31
      【Next.js】第三回 WordPressブログをNext.jsに移行する 〜Markdown執筆環境構築編〜
      2023-12-31
      【Next.js】第二回 WordPressブログをNext.jsに移行する 〜WordPressデータの移行・表示編〜
      2023-12-31
      【Next.js】第一回 WordPressブログをNext.jsに移行する 〜全体設計、環境構築編〜
      2023-12-31