Vite

Electron

node.js

viteはnode.jsのversion 20.19以上がないと動かないので、node -v で確認し、公式サイトからダウンロードする

npm

node.jsをアップデートするとnpmもアップデートされますが、PowerShellで下記のエラーが出ます

npm : このシステムではスクリプトの実行が無効になっているため、ファイル C:\Program Files\nodejs\npm.ps1 を読み込むことができません。

PowerShellのセキュリティ設定でスクリプトの実行を許可するために、

スタートメニューで「PowerShell」と入力して「Window PowerShell」と管理者として実行し、以下のコマンドをPowerShellに貼り付けてEnter

Set-ExecutionPolicy RemoteSigned

確認メッセージが出たら「Y」を入力してEnter

設定が反映されたか確認するため、PowerShellを閉じて再度開き、npm run start すること

バンドル

ReactではJSXとJSファイルを使うことがあるため、ブラウザでJavascriptを実行する前に、JSXをJSに変換してからブラウザが実行しやすいように一つのファイルにまとめる作業をしています

このプロセスをバンドルと呼び、create-react-appではwebpackというバンドラーが使われています

electron単体ではJSXをJSに変換できないため、App.jsxを変換できません

(※App.jsxの<HashRouter>の<で文法エラーになります)

そのため、バンドラーをインストールせずにnpm run startすると、画面描画されず、

developerツールを開くと「Uncaught SyntaxError : Unexpected token ‘<‘」というエラーがで起こります

Electronでデスクトップアプリを作る場合、webpackでもいいのですが、viteのほうが設定が楽なので、viteを利用するのが一般的です

インストール

npm install react react-dom

// index.html以外に複数のページをつくる場合
npm install react-router-dom

npm install -D electron

// viteとviteでreactを動かすためのプラグイン
npm install -D vite @vitejs/plugin-react

// 複数のスクリプト(viteとelectron)を同時に起動させるためのモジュール
npm install -D concurrently

// localhost:5173の起動完了を待ってからElectronを起動させるためのモジュール
npm install -D wait-on

// server.jsでVision APIを呼び出す場合
npm install express body-parser @google-cloud/vision
npm install cors
npm install jsdom

ディレクトリ構成

electronproject
|---package.json
|---package-lock.json
|---vite.config.js
|---preload.js
|---index.html
|---main.js
|---public
|   |---sample.mp4
|---asset
|   |---icon.png
|---src
    |---App.jsx
    |---main.jsx
    |---style.css
    |---pages
    |   |---Home.jsx
    |   |---About.jsx
    |---asset
        |---house.png
/// VISION APIを使う場合 ///
|---server
    |---server.js
    |---visionAPIのJSONファイル

package.jsonには “react”: “^18.2.0″のようにバージョンの範囲を指定できるが、チームで運用する場合はバージョンが違うと動かないことがあるので、package-lock.jsonでバージョンをロックすることがある

vite.config.js

viteの設定を書くファイル

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  root: '.',
});

package.json

{
  "name": "electronproject",
  "version": "1.0.0",
  "main": "main.js",  <-- electronフォルダ内にあればelectron/main.jsに変更
  "scripts": {
    "dev": "vite",
    "electron": "wait-on http://localhost:5173 && electron .",
    "start": "concurrently \"npm run dev\" \"npm run electron\"",
    "build": "vite build"
  },
  "dependencies": {
    "@google-cloud/vision": "^5.3.4",
    "body-parser": "^2.2.0",
    "cors": "^2.8.5",
    "express": "^5.1.0",
    "react": "^19.2.0",
    "react-dom": "^19.2.0",
    "react-router-dom": "^7.9.4"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^5.0.4",
    "concurrently": "^9.2.1",
    "electron": "^38.3.0",
    "vite": "^7.1.10",
    "wait-on": "^9.0.1"
  }
}

npm install -D は npm install –save-dev と同じく、開発時にだけ必要なパッケージをインストールするためのオプション

npm install -D したものはdevDependenciesに記載される

‘start’: ‘electron .’ によりnpm run start したときにプロジェクトフォルダ直下(= . )をカレントディレクトリとして起動する

main.js

const { app, BrowserWindow } = require('electron');
const path = require('path');

const createWindow = () => {
  const win = new BrowserWindow({
    width: 1000,
    height: 1200,

    // ウィンドウを透明にしたい場合
    transparent: true,
    background: #00000000,

    // ウィンドウを常に最前面で表示したい場合
    alwaysOnTop: true,

    // ウィンドウアイコンの変更
    // プロジェクトフォルダ直下にassetフォルダを用意してそこにicon.pngを入れること
    // console.log(__dirname)でmain.jsがあるフォルダの絶対パスを返す
    // C:\Users\yourname\Desktop\electronproject と返される
    icon: path.join(__dirname, 'asset', 'icon.png'),

    // IPC通信をする場合
    webPreferences: {
      preload: path.join(__dirname, 'preload.js')
    },
  });

  
  if (!app.isPackaged) {
    // 開発モードではViteサーバーに接続
    // httpsにするとnpm run startしても「このlocalhostページが見つかりません」と表示される
    win.loadURL('http://localhost:5173');
  } else {
    // 本番用にビルドされたindex.htmlをロード
    win.loadFile(path.join(__dirname, '../dist/index.html'));
  }


  // 外部リンクをElectronのWebViewではなく、Googleなどの外部ブラウザで開きたい場合
  win.webContents.setWindowOpenHandler(({ url }) => {
    shell.openExternal(url);
    return { action: "deny" };
  });
};

app.whenReady().then(() => {
  createWindow();

  // パソコン起動時にアプリが自動起動するように設定
  app.setLoginItemSettings({
    openAtLogin: true,
  });

  // MacOSの場合の起動方法らしい
  app.on('active', () => {
    if (BrowserWindow.getAllWindows().length === 0) createWindow();
  });

});

// WindowOSではウィンドウを閉じたらアプリを終了する
// macOS(darwin)はウィンドウを閉じてもアプリが終了しない仕組みになっているらしい
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit();
});

src/main.jsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
// CSSはこのファイルで読み込むこと
import './style.css';

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

src/App.jsx

import React from 'react';
import { HashRouter, Routes, Route, Link } from 'react-router-dom';
import Home from './pages/Home.jsx';
import About from './pages/About.jsx';

// 画像を使う場合はインポートするのが一般的
import house from './asset/house.png';

export default function App() {
  return (
    // ひとつのアプリでHashRouterやBrowserRouterは1回しか書いてはいけない
    <HashRouter>
      // classはJavascriptの予約語(クラス定義に使われる)なのでclassNameをつかう
      <div className="wrap">
        <nav>
          // aタグではなくLinkタグを使うことで、リロードせずに差分のみを変更できる
          <Link to="/"><img src={house} alt="house" width="50" /></Link>
          <Link to="/about">About</Link>
        </nav>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
          // *は用意されていないURL全般を指す
          <Route path="*" element={<p>NOT FOUND PAGE</p>} />
        </Routes>
      </div>
    </HashRouter>
  );
}

index.html

このファイルがないと当然、npm run start しても「このlocalhostページが見つかりません」と表示されます

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>Electron + React + Vite</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

src/pages/Home.jsx

import React from 'react';

export default function Home() {
  return (
    <div>
      <h1>Home Page</h1>
      <button onClick={() => alert('ボタンが押されました')}>CLICK ME</button>
    </div>
  );
}

src/pages/About.jsx

import React from 'react';
import { useRef, useState, useEffect } from 'react';

export default function About() {
  const videoRef = useRef(null);
  const canvasRef = uesRef(null);
  const [result, setResult] = useState(null);

  // URLから<title>を取得
  // 配列なので初期値は[]
  const [productNames, setProductNames] = useState([]);

  // キャプチャ範囲を手動選択する
  // selectionに選択範囲{x,y,width,height}を保存
  // startPosにドラッグ開始時のマウス位置を相対座標で保存
  const [selection, setSelection] = useState(null);
  const [isDragging, setIsDragging] = useState(null);  <-- falseが正解らしい
  const [startPos, setStartPos] = useState({ x: 0, y: 0});

  // 選択した範囲をプレビューする
  // previewにDataURL(data:image/jpeg;base64...)を保存
  const [preview, setPreview] = useState(null);

  // 動画のコントロールバーの領域でMouseUpすると
  // コントロールバーが優先されてhandleMouseUp関数が作動しない(=ドラッグが終了しない)
  // そのため、ドラッグ中は<video>タグに上にオーバーレイを敷いてcontrols=falseにしておく
  const [showOverlay, setShowOverlay] = useState(false);

  // 「範囲を選択」ボタンでモードオンして、createPreviewでモードOFFする
  const enableSelectionMode = () => {
    setPreview(null);
    setShowOverlay(true);
  }

  // ドラッグ開始
  const handleMouseDown = (e) => {
    const video = videoRef.current;
    if (!video) return;

    // <video>タグの大きさ・位置を取得
    const rect = video.getBoundingClientRect();

    // e.clientX/Y(ウィンドウ内でのマウス位置)からrect.left/top(ウィンドウ内での<video>タグの左上を引いて
    // <video>タグ左上を基準としたマウスの位置をstartPosに保存
    setStartPos({ x: e.clientX - rect.left, y: e.clientY - rect.top });
    setIsDragging(true);
  }

  // ドラッグ中
  const handleMouseMove = (e) => {
    const video = videoRef.current;
    if (!video || !isDragging) return;
    const rect = video.getBoundingClientRect();

    // マウスが<video>タグの右下にはみ出した場合、ドラッグ範囲を<video>タグ内に収める
    const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
    const y = Math.max(0, Math.min(rect.height, e.clientY - rect.top));

    // 左下→右下のドラッグ or 右下→左上のドラッグ の両方に対応するためにMath.absをつかう
    setSelection({
      x: Math.min(startPos.x, x),
      y: Math.min(startPos.y, y),
      width: Math.abs(x - startPos.x),
      height: Math.abs(y - startPos.y)
    });
  }

  // ドラッグ終了
  const handleMouseUp = () => {
    if (isDragging) {
      setIsDragging(false);
    }
    const overlay = document.getElementById("overlay");
    if (overlay) {
      overlay.style.cursor = "default";
      overlay.style.pointerEvents = "note";
    }
  }

  // プレビュー作成
  const createPreview = () => {
    const video = videoRef.current;
    if (!video || !selection) return;

    // 動画の解像度が1920*1080pxでも、表示幅は640*360pxかもしれない
    // つまり、表示上でマウスを1px動くと、実際の動画では3px動く(scaleX = 3)
    const rect = video.getBoundingClientRect();
    const scaleX = video.videoWidth / rect.width;
    const scaleY = video.videoHeight / rect.height;
    
    // canvasをつくる(既存のcanvasRef.currentがあればそれを使う)
    // selectionはマウスドラッグで選んだ「表示サイズ基準」であり、
    // x,y,width,heightを実際の動画のピクセルになおすためにスケールを掛ける
    // そしてゼロや負数にならないようにMath.max(1, ...)して最低1px確保する
    const canvas = canvasRef.current || document.createElement("canvas");
    canvas.width = Math.max(1, Math.round(selection.width * scaleX));
    canvas.height = Math.max(1, Math.round(selection.height * scaleY));
    const ctx = canvas.getContext("2d");

    // 選択範囲を描画
    // ctx.drawImage(画像、切り抜き開始X、切り抜き開始Y、切り抜き幅、切り抜き高さ、
            描画先X、描画先Y、描画先の幅、描画先の高さ);
    try {
      ctx.drawImage(
        video,
        Math.round(selection.x * scaleX),
        Math.round(selection.y * scaleY),
        Math.max(1, Math.round(selection.width * scaleX)),
        Math.max(1, Math.round(selection.height * scaleY)),
        0,
        0,
        Math.max(1, Math.round(selection.width * scaleX)),
        Math.max(1, Math.round(selection.height * scaleY)), 
      );
    } catch (err) {
      console.error("切り抜きに失敗しました", err);
      return;
    }

    // 画像をBase64形式に変換
    const dataURL = canvas.toDataURL("image/jpeg", 0.9);
  
    // プレビューをセット
    setPreview(dataURL);
    // プレビューを作成したのでオーバーレイを消す
    setShowOverlay(false);
  }


  // ===============================
  // URLから<title>をFetch
  // ===============================
  const fetchProductName = async (urls) => {
    try {
      const res = await fetch("http://localhost:3000/get-title", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ urls })
      });
      return await res.json();
    } catch (err) {
      console.error("製品名取得エラー", err);
      return [];
    }
  } 


  // =============================================================
  // server.jsに画像を送信して返り値をfetchする(サーバーから取得する)
  // =============================================================
  const analyze = async () => {
    if (!preview) {
      alert("まずキャプチャを作成してください");
      return;
    }
    try {
      const res = await fetch("http:localhost:3000/analyze", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ image: preview })
      });

      // 
      const jsondata = await res.json();
      console.log("解析結果: ", jsondata);
      setResult(jsondata);

      // visuallySimilarImagesのURL上位5件を取得してstateに保存 
      if (jsondata.visuallySimilarImages?.length > 0) {
        const urls = jsondata.visuallySimilarImages.slice(0, 5).map((img) => img.url);
        const productTitles = await fetchProductName(urls);
        console.log("推定製品名: ", productTitles);
        setProductName(prodctTitles);
      }
    } catch (err) {
      console.error("FETCHエラー", err);
    }
  }


  return (
    <div>
      <div id="container">
        <video
          ref={videoRef}
          src="/sample2.mp4"  // public直下ならこの指定
          controls={!showOverlay}
          style={{ width: "80%", borderRadius: "10px" }}
        /><br />

        {/* 透明オーバーレイ */}
        {showOverlay &&
          <div
            id="overlay"
            onMouseDown={handleMouseDown}
            onMouseMove={handleMouseMove}
            onMouseUp={handleMousep}
            style={{
                            position: "absolute",
                            top: 0,
                            left: 0,
                            width: "100%",
                            height: "100%",
                            cursor: "crosshair",
                            zIndex: 10,
                            backgroundColor: "rgba(0,0,0,0)", // 完全透明
                        }}
          >
            {/* 選択矩形を親要素に重ねる */}
            {selection &&
              <div
                style={{
                  position: "absolute",
                  left: selection.x,
                  top: selection.y,
                  width: selection.width,
                  height: selection.height,
                  border: "2px dashed red",
                  pointerEvents: "none",
                }}
              />
            }
          </div>
        }

        <div>
          <button onClick={enableSelectionMode}>範囲を選択</button>
          <button onClick={createPreview} disabled={!selection}>選択範囲をプレビュー</button>
          <button onClick={analyze} disabled={!preview}>解析する (GoogleVisionAPI)</button>
        </div>
        <canvas ref={canvasRef} style={{ display: "none" }} />

        {/* プレビュー表示 */}
        {preview && (
          <div id="preview-area">
            <h2>選択範囲プレビュー</h2>
            <img src={preview} alt="capture preview" />
          </div>
        )}
      </div>

      {/* Vision API 解析結果 */}
      {result && (
        <div>
          <h2>解析結果</h2>
          <pre>{JSON.stringify(result, null, 2)}</pre>
        </div>
      )}

      {productNames.length > 0 && (
        <div>
          <h2>推定製品名</h2>
          <ul>
            {productNames.map((productName, index) => {
              const googleSearchUrl = `https://lens.google.com/uploadbyurl?url=${encodeURIComponent(productName.url)}`;
              return (
                <li key={index}>
                  <img src={productName.url} />
                  <p>{productName.title}</p>
                  <a href={googleSearchUrl} target="_blank">Google画像検索</a>
                </li>
              );
            })}
          </ul>
        </div>
      )}
    </div>
  );
}


※
1つ前のページに戻るボタンを作りたい場合は、
const navigate = useNavigate();
<button onClick={() => navigate(-1)}>BACK</button>

※
useNavigateはRouteコンポーネント内部でしか使えない
App.jsxでRoute外部で使おうとするとエラーが発生する

visuallySimilarImagesのURLは上位に出てきたものほど類似度が高い

style.css

#container {
  position: relative;
  display: inline-block;
}


#preview-area {
  margin-top: 20px;
}
#preview-area img {
  max-width: 60%;
  border-radius: 8px;
}

server/server.js

// expressはnode.jsで使われる軽量サーバー
import express from "express";
// HTTPリクエストのbodyであるJSONを扱うミドルウェア
import bodyParser from "body-parser";
// Vision APIを呼び出すためにクライアント
import { ImageAnnotatorClient } from "@google-cloud/vision";
// ファイルパスを扱うユーティリティ
import path from "path";
import { fileURLToPath } from "url";
// cors許可
import cors from "cors";
import fetch from "node-fetch";
import { JSDOM } from "jsdom";


// =========================================
// VISION APIのJSONファイルを絶対パスで読み込む
// =========================================
// ESモジュール環境(import)では__dirnameがないので
// 前文で現在のファイルの絶対パスを取り、それを基に__dirnameをつくる
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
process.env.GOOGLE_APPLICATION_CREDENTIALS = path.join(__dirname, "airy-dialect-391300-76fdb975eaba.json");


// Vision APIを呼び出すためのオブジェクト
const visionClient = new ImageAnnotatorClient();


// ====================
// expressサーバーの作成
// ====================
const app = express();
// ローカルホスト5173からローカルホスト3000にアクセスするためcors許可
app.use(cors({
  origin: "http://localhost:5173",
  methods: ["GET", "POST", "OPTIONS"],
  allowedHeaders: ["Content-Type"]
}));
app.use(bodyParser.json({ limit: "10mb" }));
app.options("analyze", cors());


// ===================
// Vision APIで解析する
// ===================
app.post("/analyze", async (req, res) => {
  try {
    // base64の画像データを送り、解析タイプWEB_DETECTIONで解析する
    // maxResultsは返す候補数の上限
    const { image } = req.body;
    const base64 = image.replace(/^data:image\/\w+;base64,/, "");
    const [result] = await visionClient.annotateImage({
      image: { content: base64 },
      features: [{ type: "WEB_DETECTION", maxResults: 5 }]
    });
    // Vision APIからレスポンスを取得する
    res.json(result.webDetection);
  } catch (error) {
    console.error(error);
    res.status(500).json({ error: error.message });
  }
});


// =======================
// 単一URLから<title>を取得
// =======================
async function fetchTitle(url) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 8000);

  try {
    const res = await fetch(url, { signal: controller.signal });
    const html = await res.text();
    const dom = new JSDOM(html);
    const title = dom.window.document.querySelector("title")?.textContent || "";
    return { url, title };
  } catch (err) {
    console.error("タイトル取得エラー", err.message);
    return { url, title: null, error: true };
  }
}

// ===================
// 複数URLをまとめて処理
// ===================
app.post("/get-title", async (req, res) => {
  const { urls } = req.body;
  if (!urls || !Array.isArray(urls) ) {
    return res.status(400).json({ error: "url配列がありません" });
  }
  const results = await Promise.all(urls.map(fetchTitle));
  res.json(results);
});



app.listen(3000, () => console.log("Vision API server running on http://localhost:3000"));

ローカルサーバーの立ち上げ

// expressサーバーの立ち上げ
node server/server.js

// 別のターミナルを開いてviteサーバーの立ち上げ
// ホットリロード対応
npm run start

補足:Redux

大規模サイトの場合、フォルダ階層が3階層以上になるので、stateの状態を参照・変更するためにReduxを使っていたが、

中小規模のサイトならuseStateやuseEffectで済むため、現在はほとんど使われていない

BACK