2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Paint.NET の選択範囲テキストを編集する Fluent UI React v9 アプリを作ってみた

Posted at

はじめに

Paint.NET - Free Software for Digital Photo Editing の選択範囲を編集するアプリケーションを作りました。

この記事は以下の技術事例紹介です。見よう見まねで試していたものです。もっと良い方法があるかもしれません。

  • ロジック寄りの話
    • Paint.NET の「選択範囲自体をコピー」で得られる情報
    • Paint.NET の選択範囲を編集する
  • アプリケーション寄りの話
    • 開発環境用意 (Node.js + VS Code + プラグイン)
    • TypeScript + React でアプリを作る (create-react-app)
    • gh-pages でデプロイする
    • Fluent UI React v9 を組み込む
    • Fluent UI React v9 の状態管理 (React.useReducer())
    • クリップボードにテキストをコピー・ペーストする
    • フォームで数値入力
    • Undo / Redo
    • 選択範囲編集のロジック実装と単体テスト
    • React で Canvas 2D context を書き換える
    • canvas の再描画を減らす

この組み合わせは誰得すぎると思っています。つまみ食いで何か 1つでもお役に立てば幸いです。

使用例

  • 画面キャプチャーしたあと、ダイアログなどの特定の場所だけをぴったり切り取りたい。周辺のものができるだけ映りこんでほしくない
  • 640 * 480 などの決まったサイズに切り取りたい

たとえば AtCoder ABC300 の結果を Twitter に張り付けたいとします。

フリーハンドで大きめに選択してコピーすると、枠線の外側が画像に入ります。マウスやタッチパネルでぴったり角を選ぶのは大変です。

image.png

代わりに Paint.NET の自動選択ツールでざくっと上下左右の端部分を選び、このツールで四角形化します。

image.png
image.png
image.png

周辺の映り込みがなくなりました。

ざっくりと矩形選択したあとに、画面を拡大して「選択範囲の移動」ツールで調整すればだいたい解決する話です。

作ろうと思った理由

使用例のようなことをよく行います。Corel PaintShop Pro を使っていたときには、Python スクリプトで行っていました。

最近使っている Paint.NET は選択範囲編集系が弱く、プラグインで行えないかと探していました。そうしたところ以下のスレッドを見つけました。

標準機能として選択範囲の編集がなくても、JSON で取り出せるので、スクリプト言語などで好きに編集できますよとのこと。競技プログラミングみたいなロジックを使った、ちょっと実用性もあるアプリが書けるなら面白そうだなと思い、試してみました。 1

開発言語は何でも良かったです。インストールせずに使えて、クリップボード経由の値がやり取りしやすそうという理由で TypeScript にしてみました。

ついでに、どうせ TypeScript なら、次期 Microsoft Teams で使われそうな Fluent UI React v9 も気になっているから試してみよう、という感じです。

ロジック寄りの話

Paint.NET の「選択範囲自体をコピー」で得られる情報

たとえば次のように、2つのポリゴンの通過点列が得られます。内外判定はポリゴンの重なり数の偶奇性で行われます。2つのポリゴンが重なった内側の四角は、選択範囲ではない「穴」となっています。

{
  "polygonList": [
    "367,331,363,331,363,326,367,326",
    "369,324,361,324,361,334,371,334"
  ]
}

[(367,331), (363,331), (363,326), (367,326)], 
[(369,324), (361,324), (361,334), (371,334)]


image.png

Canvas パス塗りつぶしの evenodd のイメージです。

polygonList の定義はマニュアルに書いていません。例を調べたところではこんな感じです。

  • 整数でなく実数が入ることもある
    • 実数の場合はアンチエイリアスがかかる
    • 実数計算での誤差らしい値が入っていることもあります
  • Paint.NET の選択範囲は 1つのループ内で交差しない
    • 点接触はありえます
  • ループ方向 左回り・右回りは内外判定に関係しない 2
  • ループ出現順も内外判定に関係しない
  • ループの始終点に同じ座標が入っていても良いし、離れていても良い
    • ループ 1つの場合には始終点に同じ座標が入るらしいです

Paint.NET の選択範囲を編集する

四角形にする

image.png

すべての点の x 最大最小値と、y 最大最小値を求めます。(xmin, ymin) - (xmax, ymax) の矩形にします。いわゆる Bounding Box です。

穴の除去

image.png

ポリゴン内に点が入っているかを高速に調べる問題が、先月の AtCoder ABC296-G で出ていました。これと似ています。 3

ループの適当な 1 点から上下に直線を伸ばし、ほかのループとの干渉数を調べます。上下ともに奇数なら内側、そうでなければ外側です。

ループの数が多いと組み合わせ数が爆発します。ループごとに bounding box を計算し、ボックスが大きい順に調べてほかのボックスに完全に含まれるなら内側の可能性ありと詳細に調べることで、計算量を抑えられます。

この通り実装して、単体テストを書きます。

アプリケーション寄りの話

開発環境用意 (Node.js + VS Code + プラグイン)

以下 2つをインストールします:

追加で、以下 3つの VS Code プラグインを入れました。

TypeScript + React でアプリを作る (create-react-app)

create-react-app の説明通り、新規プロジェクトを作成します。TypeScript を使えるように --template typescript オプションを付けます。

npx create-react-app paintdotnet-selection-editor --template typescript

npm run start 実行すると、ブラウザでページが表示されます。

npm run start

image.png

ここからアプリを作っていきます。

gh-pages でデプロイする

まだ何も作っていませんが、アプリを公開できることを確認します。

GitHub 上に gh-pages を使ってデプロイする方法が、先ほど実行した create-react-app の説明ページに丁寧に書かれています。制限はありますが、public リポジトリで開発しているちょっとした WEB アプリでしたら、この方法で十分そうです。

npm install --save gh-pages
package.json
  "scripts": {
+   "predeploy": "npm run build",
+   "deploy": "gh-pages -d build",
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
+ "homepage": "https://hossy3.github.io/paintdotnet-selection-editor"

その後、適当なタイミングでデプロイコマンドを実行します。これで gh-pages ブランチに公開用のページが作られます。

npm run deploy

リポジトリ設定 Pages で gh-pages を公開する設定にします。

image.png

ここまでで、 https://hossy3.github.io/paintdotnet-selection-editor が公開されました。

パブリックリポジトリで gh-pages の内容をそのまま公開だと、誰かが怪しいコードを gh-pages に入れるような危険がない? と気になりました。 GitHub Docs を見ると、その場合はおそらくサイトが更新されなさそうです。

注: ブランチから公開しようとしていて、サイトが自動的に公開されていない場合は、管理者アクセス許可と検証済みの電子メール アドレスを持つユーザーが公開ソースにプッシュしていることを確認してください。

GitHub Actions を使うと、main ブランチに更新があったときに自動デプロイすることもできるようです。こちらは行っていません。

Fluent UI React v9 を組み込む

Fluent UI React v9 の説明通り、ライブラリをインストールします。

npm install @fluentui/react-components

同ページの通り index.tsx, App.tsx を書き換えます。

index.tsx
import React from "react";
import ReactDOM from "react-dom";
import { FluentProvider, teamsLightTheme } from "@fluentui/react-components";

import App from "./App";

ReactDOM.render(
  <FluentProvider theme={teamsLightTheme}>
    <App />
  </FluentProvider>,
  document.getElementById("root")
);
App.tsx
import React from 'react';
import { Button } from '@fluentui/react-components';

function App() {
  return (
    <Button appearance="primary">Get started</Button>
  );
}

これで、create-react-app で作ったページが、ボタン 1つのページに差し替わります。

npm run start

image.png

ブラウザの DevTools を開くと、引っ掛かる警告が表示されています。 Fluent UI React v9 の既知の問題らしいですので、そのうち修正されることを待つことにします。

Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot

UI コンポーネントを配置する

ボタン以外にも UI コンポーネントが多数使えます。API を見て、コンポーネントを WEB ページに貼り付けます。

image.png

この時点では張りぼてです。でもコンポーネントを貼り付けただけで、なんだか動きそうな気分になります。

Fluent UI React v9 のコンポーネントは 2023年 5月時点では「できたものから提供している」という感じで、まだ一通り揃っているとは言えないようです。提供されているツールバーも、ドロップダウンを直接は扱えないなど、使い勝手に気になるところがありました。 4

Fluent UI React v9 の状態管理 (React.useReducer())

「穴の除去」ボタンを押したときに、テキストボックスの中を読み取ってロジックを適用したいです。しかし React ですから、状態を UI コンポーネントに問い合わせるのは不自然です。

テキストボックスの情報を外側で使用するには、つぎの 2択が多そうです。

  1. テキストボックスの状態変更はテキストボックスにお任せする。 onChange() イベントで利用側に通知することで、利用側もテキストボックスの状態を知ることができる。
    • Textarea サンプルの Uncontrolled に対応
    • 利点: 簡単に使える
    • 欠点: 値を外から変更できない。初期値 defaultValue だけ。
  2. テキストボックスの状態を利用側が管理する。onChange() イベントで利用側に通知するところは同じ。テキストボックスは利用側から渡された value プロパティーをそのまま表示する。
    • Textarea サンプルの Controlled に対応
    • 利点: 値を外から変更できる
    • 欠点: 状態管理、状態の妥当性確認を自前で行わないといけない

今回はテキストボックスの内容を穴を除去した結果に差し替えたいというように、外側から変更するケースがあるため、2番にしました。

テキストボックスの内容が変わると、「穴があるか」などのほかの状態も連動して変わります。このあたりの処理は UI とセットでは扱いたくないところです。reducer にお任せするようにしました。

App.tsx
const App = () => {
  const [state, dispatch] = React.useReducer(reducer, initialState);
  // :
  return (
    // :
    <Textarea
      onChange={(_, data) => {
        dispatch({ type: "set_selection", payload: { text: data.value } });
      }}
      value={state.selectionText}
    />
    // :
  );
}

クリップボードにテキストをコピー・ペーストする

テキストボックスに [Ctrl]+[C] [Ctrl]+[V] してください、でも十分そうですが、一応用意しました。

昔は document.execCommand("copy") などを使っていました。今はクリップボード API がおすすめのようです。

コピー (クリップボードに書き込む)

onClick={() => {
  navigator.clipboard.writeText(state.selectionText);
}}

navigator.clipboard.writeText() でクリップボードにテキスト情報を書き込めます。先ほどの state を渡せば良いです。

ペースト (クリップボードから読み込む)

onClick={() => {
  navigator.clipboard.readText().then((text) => {
    dispatch({ type: "set_selection", payload: { text } });
  });
}}

navigator.clipboard.readText() でクリップボードのテキスト情報を問い合わせできます。非同期に返ってくる結果を受けて、先ほどの dispatch にテキストを渡せば良いです。

最初にこのサイトでクリップボードから情報を読み込む際に、ブラウザが安全かどうか問い合わせるダイアログを出していました。まあクリップボードを監視し続けると悪いことができてしまいそうですから。 MS Edge の場合はブラウザの「Cookie とサイトのアクセス許可」設定から変更できます。

image.png

フォームで数値入力

image.png

貼り付ける部品の数は多いですけれど、同じように扱えます。今回は選択範囲のように複数の状態が連動しませんから、 useReducer ではなく単純に useState にしてみました。

BoxFormDialog.tsx
export const BoxFormDialog = (props: BoxFormDialogProps) =>
  props.open ? <BoxFormDialogImpl {...props} /> : null;

const BoxFormDialogImpl = (props: BoxFormDialogProps) => {
  const [x, setX] = React.useState(
    props.box != null ? Math.round(props.box[0]) : 0
  );
  // :
  return (
    // :
    <SpinButton
      value={x}
      onChange={(_, data) => onChange(data, setX)}
    />

ダイアログの開閉状態が変わったときにフォームの値を書き換えるには、上のように仮想 DOM から一度除いてまた初期化するほか、下のように useEffect を使う方法もあります。どちらが良いかはよく分かりません。

BoxFormDialog-another.tsx
export const BoxFormDialogImpl = (props: BoxFormDialogProps) => {
  const [x, setX] = React.useState(
    props.box != null ? Math.round(props.box[0]) : 0
  );
  React.useEffect(
    () => setX(props.box != null ? Math.round(props.box[0]) : 0),
    [props.box]
  );

Undo / Redo

ブラウザのテキストボックスでテキスト編集しているときは、ふつうに Undo / Redo できます。しかし、このテキストを外部から変更すると、Undo / Redo 情報が失われます。ブラウザのテキストボックスの Undo / Redo と同じようなことを TypeScript でできるように…… と思うと頭が痛くなります。

今回はざっくり「ボタン操作後にやり直したい」ができるということだけに割り切って、次のようにしました。

  • 「Undo/Redo」以外のテキストボックスを変更するボタンを押したとき
    • 「変更前」「変更後」の両方の状態をスナップショットとして覚える
      • 前後のスナップショットが同じ選択範囲を指す場合は保存を省略する
    • Redo バッファーがもしあればクリアする
  • 「Undo/Redo」ボタンを押したとき
    • スナップショットを移動する
  • テキストボックスを操作したとき
    • なにもしない (テキストボックスにお任せ)

image.png

Undo/Redo で状態そのままではなく、操作履歴を覚えるという方法もあります。そうすると覚える量が少なくて済みます。代わりに穴埋めなどの計算を Redo 時に再度行うことになります。

履歴を一つ前後に動くだけではなく、ドロップダウンで複数確認できて一気に飛べるようになると便利だと思いました。状態管理的には同じようにできるはずです。 Fluent UI React v9 のツールバーでドロップダウンを出すのが手間でしたので、行っていません。

選択範囲編集のロジック実装と単体テスト

「ロジック寄りの話」をそのまま実装するだけです。テストケースを作るのは手間です。

VS Code に Jest プラグインを入れると、単体テストが通過したところにチェックマークが付きます。開発中に気軽に確認できます。

image.png

React で Canvas 2D context を書き換える

選択範囲をプレビューできるように、 <canvas> 要素を使いました。

image.png

<canvas> は描画状態を自前で持っています。JavaScript で描画状態を更新します。Fluent UI React v9 コンポーネントのように props を渡すだけでは描画できません。

そこで、props が変わったときだけ useEffect<canvas> を再描画するようにしてみました。だいたいこんな感じです。evenodd オプションで穴部分の塗りつぶしを取り消しています。

PreviewCanvas.tsx
  React.useEffect(() => {
    const ctx = canvasRef.current?.getContext("2d");
    ctx.clearRect(0, 0, width + 1, height + 1);

    const region = new Path2D();
    for (const polygon of props.polygonList) {
      const i_max = polygon.length / 2;
      region.moveTo(polygon[0], polygon[1]);
      for (let i = 1; i < i_max; i++) {
        region.lineTo(polygon[i * 2], polygon[i * 2 + 1]);
      }
      region.closePath();
    }
    ctx.fill(region, "evenodd");
    ctx.stroke(region);
    ctx.save();
  }, [
    canvasRef,
    width,
    height,
    props.polygonList,
  ]);

props.polygonList はテキストボックスから組み立てられる、canvas 描画に使いやすい 2次元配列です。

canvas の再描画を減らす

このままだと canvas が頻繁に再描画されます。useEffect 発動条件に props.polygonList を入れているためです。

たとえばテキストボックスの最後に改行を追加した場合を考えます。意味的には選択範囲は変わりません。同じ polygonList が reducer で組み立てられるはずです。

しかし JavaScript 的には中身が同じでも、別のものを指していれば別オブジェクトとみなされます。組み立てなおすと別オブジェクトです。そして再描画が走ります。

そこで、reducer の世界で 「前と同じ選択範囲になったときは state.polygonList を変更しない」 というような処理を書きました。 Jest の .toEqual(value) みたいなことです。

appState.ts
export const reducer = (state: State, action: Action): State => {
  // :
  const polygonList = toPolygonList(selectionText);
  if (polygonListEquals(polygonList, state.polygonList)) {
    return {
      ...state,
      selectionText,
    };
  }
  // :
  return {
    ...state,
    selectionText,
    polygonList,
  };

そしてコンポーネント組み込み元でメモ化します。そうすれば reducer でアプリの状態が変わっても canvas 側は state.polygonList が変わらなければ再描画対象になりません。

App.tsx
  const previewCanvas = React.useMemo(
    () => (
      <PreviewCanvas polygonList={state.polygonList} />
    ),
    [state.polygonList]
  );

……なんだかとてもトリッキーなことをしている感じです。React の関数型の世界と、 canvas の手続き型の世界を繋ごうとするとこんな話になるのかなと。

自前で作るより、ライブラリにお任せする方が良かったかもしれません。

最後に

選択範囲の編集で私が欲しかった機能は、ここまででだいたい揃いました。 1か月ほど使っています。ついでに Fluent UI React v9 など気になっていたけれど機会のなかった技術に触れることもでき、良かったです。

このアプリは Paint.NET と往復して、ツールバーで操作する面倒さがあります。矩形選択したいだけなら、Paint.NET を使わなくてもブラウザに張り付けた画像の処理だけで十分だったのかもしれません。またの宿題にします。

ここまでで 「作ってみた」 誰得記事を終わります。

  1. 競技プログラミングがアプリケーション開発の役に立つかどうか、よく議論になっています。私としては「競技プログラミングは楽しいし、アプリケーション開発でも使う場所を見つければ良いのでは」派です。この記事のように。

  2. 左回りは外周、右回りは内周(穴) と扱うものも見たことがあります

  3. ABC296-G とまったく同じではありません。ループ方向が分からないこと、入力が凸多角形とは限らないというところは制約が外れて少し難しくなっています。でも巨大座標と接触性を考えなくて良いですから、競プロの問題としてみると易しいと思います。

  4. Fluent UI React v9 は Microsoft 365 で使われるということで、今後より使いやすくなると思います。今時点で考えると、もうしばらく待ちかなという感じがしました。

2
1
0

Register as a new user and use Qiita more conveniently

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?