WordPress のカスタムブロックで、泡がシュワシュワするだけのブロックをつくる

[WordPressのカスタムブロックに関するチュートリアル](https://ja.wordpress.org/team/handbook/block-editor/getting-started/tutorial/)を読んで、何か作ってみたいと思いました。

## 環境

- WSL2
- Ubuntu, apache2, MySQL, PHP
- WordPress 6.6.1
- node.js v20.16.0
- @wordpress/create-block 4.49.0

## 作るもの

ICSメディアさんの[JavaScriptで取り組むクリエイティブコーディングパーティクル表現入門](https://ics.media/entry/18835/)という記事を参考に、以下のようなスクリプトを書きました。

これを、WordPressのカスタムブロックに変えていきます。
その際、以下の内容を、WordPressのブロックエディタ側から設定変更できるようにしたいです。

- 背景色
- 泡の色
- 泡の数
- 泡のスピード

```html
<!doctype html>
<html lang="ja">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>test</title>
    <style>
        body {
            margin: 0;
            height: 0;
            overflow: hidden;
        }
        canvas {
            width: 100%;
            height: auto;
            background: black;
        }
    </style>
</head>
<body>
    <h1>particle test</h1>
    <canvas width="1600" height="900"></canvas>
    <script>
        class Particle {
            constructor(idx, { particleLength }) {
                this.x;
                this.y;
                this.scale = 25;
                this.diffIdx = 1 - (idx / particleLength);
                this.fillType = (Math.random() * 2) | 0;
            }
            draw(context, { rgbColor }) {
                context.restore();
                // 点滅ロジック
                const alpha = Math.random() * 0.2 + 0.8;
                context.beginPath();
                context.arc(
                    this.x,
                    this.y,
                    this.scale,
                    0,
                    Math.PI * 2,
                    false,
                );
                if (this.fillType === 0) {
                    context.fillStyle = `rgb(${rgbColor} / ${alpha})`;
                    context.fill();
                } else {
                    context.strokeStyle = `rgb(${rgbColor} / ${alpha})`;
                    context.lineWidth = 5;
                    context.stroke();
                }
                context.closePath();
            }
        }
        class Particles {
            constructor(canvas, attributes) {
                this.ref;
                this.context = canvas.getContext('2d');
                this.width = canvas.width;
                this.height = canvas.height;
                this.attributes = this.initAttributes(attributes);
                this.array = this.createNewParticles(canvas, this.attributes);
                // thisが変わってしまうので、ここだけfunctionではなくアロー関数
                this.tick = (timestamp) => {
                    this.update(timestamp);
                    this.ref = window.requestAnimationFrame(this.tick);
                }
            }
            initAttributes(attributes) {
                const checkProperty = (obj, propString, propDefault) => {
                    if (!obj.hasOwnProperty(propString)) {
                        obj[propString] = propDefault;
                    }
                }
                checkProperty(attributes, 'emitType', 'normal');
                checkProperty(attributes, 'particleColor', '#f00');
                checkProperty(attributes, 'particleLength', 70);
                checkProperty(attributes, 'particleSpeed', 2);
                attributes.rgbColor = this.change2RGB(attributes.particleColor);
                attributes.particleLife = 3000 / attributes.particleSpeed;
                return attributes;
            }
            createNewParticles(canvas, attributes) {
                const { particleLength, emitType } = attributes;
                const array = new Array(particleLength).fill(null).map(
                    (_, idx) => (new Particle(idx, attributes))
                );
                array.forEach(particle => {
                    particle.x = this.width * this.createRandom(emitType);
                });
                return array;
            }
            update(timestamp) {
                this.context.clearRect(0, 0, this.width, this.height);
                this.context.globalCompositeOperation = "overlay";
                const life = this.attributes.particleLife;

                this.array.forEach((particle, idx) => {
                    const diffTime = timestamp + life * particle.diffIdx;
                    const diffTimeRate = (diffTime % life) / life;
                    particle.y = this.height * (.75 - diffTimeRate);
                    if (particle.y < 0) {
                        particle.x = this.width * this.createRandom(attributes.emitType);
                    } else {
                        particle.scale = 25 * (1 - diffTimeRate / .75);
                        particle.draw(this.context, this.attributes);
                    }
                });
            }
            start() {
                this.ref = window.requestAnimationFrame(this.tick);
            }
            cancel(ref) {
                return window.cancelAnimationFrame(ref);
            }
            createRandom(emitType) {
                switch (emitType) {
                    case "center":
                        // 中央に配置
                        return (new Array(6).fill(null).map(_ => Math.random()).reduce((p, c) => p + c) / 6);
                    case "edge":
                        // 両端に配置
                        const base = .05 + (Math.random() ** 3) * .45;
                        return (Math.random() < .5) ? base : 1 - base;
                    case "normal":
                    default:
                        // 一様分布
                        return .05 + Math.random() * .9;
                }
            }
            change2RGB(hex) {
                hex = hex.replace('#', '');
                let hexArray;
                if (hex.length === 3) {
                    hexArray = hex.split('').map(x => `${x}${x}`)
                } else {
                    hexArray = [];
                    for (let i = 0; i < 6; i += 2) {
                        hexArray.push(hex.slice(i, i + 2));
                    }
                }
                return hexArray.map(x => parseInt(x, 16)).join(' ');
            }
        }
        const canvas = document.querySelector("canvas");
        const attributes = {
            emitType: 'normal',
            particleColor: '#f00',
            particleLength: 100,
            particleSpeed: 2,
        }
        const p = new Particles(canvas, attributes);
        p.start();
    </script>
</body>
</html>
```

## 手順

[チュートリアル](https://ja.wordpress.org/team/handbook/block-editor/getting-started/tutorial/)を参考に、進めていきます。

### ひな形を作る

```
npx @wordpress/create-block@latest particle-canvas-block
```

チュートリアルでは、動的ブロックから説明されていますが、今回作るものは静的ブロックで十分なので、`--variant=dynamic` は不要です。
これにより、render.php等が出力されません。

わたしのPCが古いせいかもしれませんが、ダウンロードには時間がかかります。

準備できたら、WordPressの管理画面から、プラグインを有効にしておきます。

### block.jsonを編集

block.jsonでは、管理画面で設定したい変数等を定義できます。
とりあえず、以下のようにしました。

```json
{
	"$schema": "https://schemas.wp.org/trunk/block.json",
	"apiVersion": 3,
	"name": "create-block/particle-canvas-block",
	"version": "0.1.0",
	"title": "Particle Canvas Block",
	"category": "widgets",
	"icon": "smiley",
	"description": "泡がシュワシュワするだけのブロック",
	"example": {},
	"attributes": {
		"emitType": {
			"type": "string",
			"default": "normal"
		},
		"particleColor": {
			"type": "string",
			"default": "#f00"
		},
		"particleSpeed": {
			"type": "integer",
			"default": 1
		},
		"particleLength": {
			"type": "integer",
			"default": 100
		}
	},
	"supports": {
		"color": {
			"background": true,
			"text": false
		},
		"html": false
	},
	"textdomain": "particle-canvas-block",
	"editorScript": "file:./index.js",
	"editorStyle": "file:./index.css",
	"style": "file:./style-index.css",
	"viewScript": "file:./view.js"
}
```

attributesで変数を設定しておきます。
emitTypeは、enumにした方が良かったかもしれません。

### particle.jsを準備する

Particle、Particlesというクラスを用意しました。
管理画面側（edit.js）と、表示画面側（save.js）の両方で使いたいためです。

```js
export { Particles };

class Particle {
  constructor(idx, { particleLength }) {
    this.x;
    this.y;
    this.scale = 25;
    this.diffIdx = 1 - (idx / particleLength);
    this.fillType = (Math.random() * 2) | 0;
  }
  draw(context, { rgbColor }) {
    context.restore();
    // 点滅ロジック
    const alpha = Math.random() * 0.2 + 0.8;
    context.beginPath();
    context.arc(
      this.x,
      this.y,
      this.scale,
      0,
      Math.PI * 2,
      false,
    );
    if (this.fillType === 0) {
      context.fillStyle = `rgb(${rgbColor} / ${alpha})`;
      context.fill();
    } else {
      context.strokeStyle = `rgb(${rgbColor} / ${alpha})`;
      context.lineWidth = 5;
      context.stroke();
    }
    context.closePath();
  }
}
class Particles {
  constructor(canvas, attributes) {
    this.ref;
    this.context = canvas.getContext('2d');
    this.width = canvas.width;
    this.height = canvas.height;
    this.attributes = this.initAttributes(attributes);
    this.array = this.createNewParticles(canvas, this.attributes);
    // thisが変わってしまうので、ここだけfunctionではなくアロー関数
    this.tick = (timestamp) => {
      this.update(timestamp);
      this.ref.current = window.requestAnimationFrame(this.tick);
    }
  }
  initAttributes(attributes) {
    const checkProperty = (obj, propString, propDefault) => {
      if (!obj.hasOwnProperty(propString)) {
        obj[propString] = propDefault;
      }
    }
    checkProperty(attributes, 'emitType', 'normal');
    checkProperty(attributes, 'particleColor', '#f00');
    checkProperty(attributes, 'particleLength', 70);
    checkProperty(attributes, 'particleSpeed', 2);
    attributes.rgbColor = this.change2RGB(attributes.particleColor);
    attributes.particleLife = 3000 / attributes.particleSpeed;
    return attributes;
  }
  createNewParticles(canvas, attributes) {
    const { particleLength, emitType } = attributes;
    const array = new Array(particleLength).fill(null).map(
      (_, idx) => (new Particle(idx, attributes))
    );
    array.forEach(particle => {
      particle.x = this.width * this.createRandom(emitType);
    });
    return array;
  }
  update(timestamp) {
    this.context.clearRect(0, 0, this.width, this.height);
    this.context.globalCompositeOperation = "overlay";
    const life = this.attributes.particleLife;

    this.array.forEach((particle) => {
      const diffTime = timestamp + life * particle.diffIdx;
      const diffTimeRate = (diffTime % life) / life;
      particle.y = this.height * (.75 - diffTimeRate);
      if (particle.y < 0) {
        particle.x = this.width * this.createRandom(this.attributes.emitType);
      } else {
        particle.scale = 25 * (1 - diffTimeRate / .75);
        particle.draw(this.context, this.attributes);
      }
    });
  }
  start() {
    this.ref.current = window.requestAnimationFrame(this.tick);
  }
  cancel() {
    return window.cancelAnimationFrame(this.ref);
  }
  createRandom(emitType) {
    switch (emitType) {
      case "center":
        // 中央に配置
        return (new Array(6).fill(null).map(_ => Math.random()).reduce((p, c) => p + c) / 6);
      case "edge":
        // 両端に配置
        const base = .05 + (Math.random() ** 3) * .45;
        return (Math.random() < .5) ? base : 1 - base;
      case "normal":
      default:
        // 一様分布
        return .05 + Math.random() * .9;
    }
  }
  change2RGB(hex) {
    hex = hex.replace('#', '');
    let hexArray;
    if (hex.length === 3) {
      hexArray = hex.split('').map(x => `${x}${x}`)
    } else {
      hexArray = [];
      for (let i = 0; i < 6; i += 2) {
        hexArray.push(hex.slice(i, i + 2));
      }
    }
    return hexArray.map(x => parseInt(x, 16)).join(' ');
  }
}
```

### edit.jsを編集

edit.jsでは、管理画面での動作を設定します。
react.jsが難しく、なかなか手間取りました。

```jsx
import { __ } from '@wordpress/i18n';
import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
import { PanelBody, RadioControl, ColorPalette, RangeControl } from '@wordpress/components';
import { useRef, useEffect } from 'react';
import { Particles } from './particle.js';
// 右側の設定部分だけ切り出し
function MyInspectorControls({ attributes, setAttributes }) {
  const { emitType, particleColor, particleSpeed, particleLength } = attributes;
  return (<InspectorControls>
    <PanelBody title='各種設定'>
      <RadioControl
        label='泡の出る場所'
        options={[
          { label: '一様', value: 'normal' },
          { label: '中央', value: 'center' },
          { label: '両端', value: 'edge' }
        ]}
        selected={ emitType ?? 'normal' }
        onChange={(value) =>
          setAttributes({ emitType:  value })
        }
      />
      <ColorPalette
        colors={[
          { color: '#f00', name: 'Red' },
          { color: '#fff', name: 'White' },
          { color: '#00f', name: 'Blue' }
        ]}
        label='泡の色'
        value = { particleColor ?? '#f00' }
        onChange={(value) =>{
          if (value === undefined) {
            value = '#f00';
          }
          return setAttributes({ particleColor: value })
        }}
      />
      <RangeControl
        label="泡のスピード（1-3）"
        initialPosition={2}
        max={3}
        min={1}
        step={1}
        value = { particleSpeed ?? 2 }
        onChange={(value) =>
          setAttributes({ particleSpeed: value })
        }
      />
      <RangeControl
        label="泡の数（10-100）"
        initialPosition={70}
        max={100}
        min={10}
        step={30}
        value = { particleLength ?? 70 }
        onChange={(value) =>
          setAttributes({ particleLength: value })
        }
      />
    </PanelBody>
  </InspectorControls>);
}
export default function Edit({ attributes, setAttributes }) {
  const { emitType, particleColor, particleSpeed, particleLength } = attributes;
  const canvasRef = useRef(null);
  const reqAnimeRef = useRef(null);
  useEffect(()=>{
    const canvas = canvasRef.current;
    const p = new Particles(canvas, attributes);
    p.ref = reqAnimeRef;
    p.start();
    return ()=>{p.cancel();}
  }, [emitType, particleColor, particleSpeed, particleLength]);

  return (<>
    <MyInspectorControls attributes={attributes} setAttributes={setAttributes}></MyInspectorControls>
    <div {...useBlockProps()}>
      <canvas style={{ width: "100%", height: "auto" }} 
        ref={canvasRef} 
        width={1600} height={900}></canvas>
    </div>
  </>);
}
```

基本的に、チュートリアルで紹介されている通りですが、

- 管理画面での設定に、[コンポーネント](https://wordpress.github.io/gutenberg/?path=/docs/docs-introduction--page)を利用しました
- jsxの[canvasを操作するために、useRef, useEffectというものを利用しました](https://qiita.com/Atsu_Tech/items/77fe0cf14283963edbcc)
- また、[requestAnimationFrameを使うためにも、useRefを使いました](https://zenn.dev/yend724/articles/20211119-x1fph5dvdldsx4po)

本来ならば、`<canvas {...useBlockProps()} ～` と書きたかったのですが、これをすると管理画面でcanvasが選択できなかったため、divで囲いました。
何か方法があるのかもしれません。

### save.jsを編集

save.jsでは、サイトの閲覧者が受け取る表示画面を作ります。

```jsx
import { useBlockProps } from '@wordpress/block-editor';
export default function save({ attributes }) {
	const { emitType, particleColor, particleSpeed, particleLength } = attributes;
	return (
		<div { ...useBlockProps.save() }>
			<canvas style={{width:"100%",height:"auto"}} 
			  data-emit-type={emitType??'center'} 
			  data-particle-color={particleColor??'White'} 
			  data-particle-speed={particleSpeed??2} 
			  data-particle-length={particleLength??70} 
			  width={1600} height={900}></canvas>
		</div>
	);
}
```

表示画面側で実行されるJavaScriptは、save.jsには書けません。
（そもそもreact.jsが、`script`タグの直書きを許してくれません）

そこで、attributesの受け渡しは、datasetを使いました。
そして、実行するJavaScriptは、次のview.jsに書きます。
（view.jsは、block.jsonの"viewScript"で定義されています）

### view.jsを編集

上記の通り、表示画面で動くJavaScriptは、view.jsに書きます。

```js
import { Particles } from './particle.js';

document.addEventListener('DOMContentLoaded', ()=>{
  const canvases = document.querySelectorAll('div.wp-block-create-block-particle-canvas-block > canvas');
  canvases.forEach(canvas => {
    const emitType = canvas.dataset.emitType ?? 'center',
      particleColor = canvas.dataset.particleColor ?? '#fff',
      particleSpeed = parseInt(canvas.dataset.particleSpeed) ?? 2, 
      particleLength = parseInt(canvas.dataset.particleLength) ?? 70;
    const attributes = { emitType, particleColor, particleSpeed, particleLength };
    const p = new Particles(canvas, attributes);
    p.ref = {current: null}
    p.start();
  });
}, false);
```

edit.jsと、view.jsに、それぞれ操作を書いてしまうと、メンテナンスが大変そうだったので、極力処理はParticlesクラスに持たせるようにしました。
ただ、こういう方法が良いのかは分かりませんでした。

## 完成

多少ゴチャゴチャしましたが、管理画面、表示画面としては無事、完成しました。

![管理画面.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3310353/01db1883-e868-fffb-36c9-a102dcd10a9d.jpeg)

## 感想

管理画面がreact.jsで、閲覧者に表示される画面は普通のJavaScriptなので、同じようなスクリプトを2回書かなければならず、変更作業が大変そうだなと思いました。
さらに動的ブロックにしたい場合は、render.phpにも書かなければならず、JavaScriptとPHPを横断することになるので、メンテナンスは大変そうです。

もう少しきれいな書き方ができないか、模索してみようと思います。