ゲーム開発
Unity
UnrealEngine
C++
ゲーム数学
ゲームAI
サウンド
アニメーション
GBDK
制作日記
3DCG
Houdini
Blender
USD
グラフィックス
テクノロジ
ツール開発
フロントエンド関連
サーバサイド関連
ソフトウェア設計
ハードウェア関連
おすすめ技術書
音楽
DTM
楽器・機材
ピアノ
その他
都会のエレキベア
ラーメン日記
四コマ漫画
おすすめアイテム
おもしろコラム
  • ゲーム開発
    • Unity
    • UnrealEngine
    • C++
    • ゲーム数学
    • ゲームAI
    • サウンド
    • アニメーション
    • GBDK
    • 制作日記
  • 3DCG
    • Houdini
    • Blender
    • USD
    • グラフィックス
  • テクノロジ
    • ツール開発
    • フロントエンド関連
    • サーバサイド関連
    • ソフトウェア設計
    • ハードウェア関連
    • おすすめ技術書
  • 音楽
    • 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
記事をSNSで共有する
X
Facebook
LINE
はてなブックマーク
Pocket
LinkedIn
Reddit

著者の各種アカウント
フォローいただけると大変励みになります!
X
GitHub

関連記事
【Three.js】カスタムシェーダーでトゥーン+背面法アウトラインを実装する
2026-02-15
【Three.js】Three.js入門 - シーン構築・モデル読み込み・ポストプロセスまで
2026-02-15
【Astro】Astroの使い方と複数UIフレームワーク(React、Vue、Svelte)を組み合わせるサンプル
2026-02-01
【Houdini21.0】3Dビル群っぽいブログヘッダー画像を作成する
2026-01-10
【VSCode】ドラッグ&ドロップで画像ファイルをリサイズ・保存する拡張機能を作る
2025-11-22
【Unity】Timeline × Excelでスライドショーを効率よく制作する
2024-10-31
【ゲーム数学】第九回 p5.jsで学ぶゲーム数学「フーリエ解析」
2024-05-12
【Node.js】廃止されたAmazonアソシエイト画像リンクをAmazon Product Advertising API経由で復活させる
2024-01-08