0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-08-25

WordPressのカスタムブロックに関するチュートリアルを読んで、何か作ってみたいと思いました。

環境

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

作るもの

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

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

  • 背景色
  • 泡の色
  • 泡の数
  • 泡のスピード
<!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>

手順

チュートリアルを参考に、進めていきます。

ひな形を作る

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

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

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

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

block.jsonを編集

block.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)の両方で使いたいためです。

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が難しく、なかなか手間取りました。

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>
  </>);
}

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

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

save.jsを編集

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

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に書きます。

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

感想

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

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

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?