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>
</>);
}
基本的に、チュートリアルで紹介されている通りですが、
- 管理画面での設定に、コンポーネントを利用しました
- jsxのcanvasを操作するために、useRef, useEffectというものを利用しました
- また、requestAnimationFrameを使うためにも、useRefを使いました
本来ならば、<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クラスに持たせるようにしました。
ただ、こういう方法が良いのかは分かりませんでした。
完成
多少ゴチャゴチャしましたが、管理画面、表示画面としては無事、完成しました。
感想
管理画面がreact.jsで、閲覧者に表示される画面は普通のJavaScriptなので、同じようなスクリプトを2回書かなければならず、変更作業が大変そうだなと思いました。
さらに動的ブロックにしたい場合は、render.phpにも書かなければならず、JavaScriptとPHPを横断することになるので、メンテナンスは大変そうです。
もう少しきれいな書き方ができないか、模索してみようと思います。