WordPress 投稿リストブロック開発
こんにちは
今回は本サイトで使用しているプラグインとして「投稿リストを表示するブロック」を作成していきます
※本記事はMacを使用したWordPressのローカル環境であり、Node.jsのインストール環境での開発を想定した記事です

このブロックを挿入すると、まずは記事最新リストが6件カードデザインでレンダリングされ、さらにサイドパネルからカテゴリー、タグごとのリストを生成できるようにします
また、記事の表示数の設定や日付の有無、投稿の種類ごとのラベル表示の有無、そしてデザイン設定など様々な設定を設けることで使いやすい、ユーザビリティの向上を目標にしました
投稿ブロック制作の概要
まずはどういったアプローチでこのブロックを作成するか、といった指針を決めます
以下にまとめました
- 投稿のデータをREST APIで取得する
- 基本は最新の投稿だがカテゴリーやタグも選択可能にする
- インスペクターにて追加設定を保存する
- 保存した設定をレンダリング時に反映する
こんな感じで行きたいと思います
まずは環境構築からです
create-blockで環境構築
今回もプラグインファイルとしてブロックの環境構築を行います
まずは作業用フォルダに移動してターミナルに以下を打ち込みます
ターミナル
npx @wordpress/create-block
コマンドを打ち込むと対話モードにて、ターミナルとのやり取りが始まるので適宜設定していきます。
設定内容は以下にまとめました
オプション | 説明 | デフォルト |
---|---|---|
slug | ファイルの出力先ディレクトリ名に使用される文字列 | esnext-example |
namespace | 名前空間。ブロックをユニークに識別できる文字列 | create-block |
title | プラグインの名前(プラグインヘッダの Plugin Name)及びブロックの表示タイトル(registerBlockType の第2パラメータの title) | ESNext Example |
description | ブロックの短い説明を指定。プラグインヘッダの Description 及び egisterBlockType の第2パラメータの description。 | Example block written with …(省略) |
dashicon | ブロックのアイコン。registerBlockType の第2パラメータの icon。 | smiley |
category | カテゴリー。registerBlockType の第2パラメータの category。 | widgets |
plugin author | プラグインヘッダに記載されるプラグインの作者。 | The WordPress Contributors |
plugin’s license | プラグインヘッダに記載されるプラグインのライセンス(short name) | GPL-2.0-or-later |
link to license | プラグインヘッダに記載されるライセンスの全文へのリンク | https://www.gnu.org/licenses/gpl-2.0.html |
version | プラグインヘッダに記載されるプラグインのバージョン | 0.1.0 |
これでしばらくするとプラグインとしての環境構築が完了するので管理画面から有効化します
まずはプラグインとブロック登録のスクリプトをJSとPHPに記述していきます
post-list
<?php /** * Plugin Name: Oja Post List Block * Description: 記事リストを表示するブロックです * Requires at least: 5.8 * Requires PHP: 7.0 * Version: 0.1.0 * Author: ojako * License: GPL-2.0-or-later * License URI: https://www.gnu.org/licenses/gpl-2.0.html * Text Domain: post-list * * @package oja */ new OjaPostListBlock; class OjaPostListBlock { public function __construct() { // ブロック登録 add_action( 'init', array($this, 'oja_post_list_block_init')); } public function oja_post_list_block_init() { if ( !function_exists('register_block_type')) { return; } $dir = dirname( __FILE__ ); $script_asset_path = "$dir/build/index.asset.php"; $index_js = 'build/index.js'; $script_asset = require( $script_asset_path ); wp_register_script( 'oja-post-list-script', plugins_url( $index_js, __FILE__ ), $script_asset['dependencies'], $script_asset['version'] ); $editor_css = 'build/index.css'; wp_register_style( 'oja-post-list-editor-style', plugins_url( $editor_css, __FILE__ ), array(), filemtime( "$dir/$editor_css" ) ); $style_css = 'build/style-index.css'; wp_register_style( 'oja-post-list-style', plugins_url( $style_css, __FILE__ ), array(), filemtime( "$dir/$style_css" ) ); register_block_type( "oja/post-list", array( 'editor_script' => 'oja-post-list-script', 'editor_style' => 'oja-post-list-editor-style', 'style' => 'oja-post-list-style', 'render_callback' => 'post_list_render_func', 'attributes' => [ 'postListType' => [ 'type' => 'string', 'default' => 'post' ], 'isTimeStamp' => [ 'type' => 'boolean', 'default' => true ], 'isPostLabel' => [ 'type' => 'boolean', 'default' => true ], 'showPostNumber' => [ 'type' => 'number', 'default' => 6 ], 'postDesign' => [ 'type' => 'string', 'default' => 'cade' ], 'postOrder' => [ 'type' => 'string', 'default' => 'DESC' ], 'postOrderBy' => [ 'type' => 'string', 'default' => 'date' ], 'termId' => [ 'type' => 'number', 'default' => -1 ] ] ) ); } } // class //ダイナミックブロックによるレンダリング function post_list_render_func($attributes, $content) { //画像ブロックレンダリング require_once dirname(__FILE__) . '/src/views/post_list_render.php'; return post_list_render($attributes, $content); }
attributes
以外はほぼ決り文句なようなもので、以下のindex.js
と共に私はいつもコピペして名前だけ変更してます
index.js
import { registerBlockType } from "@wordpress/blocks"; import "./style.scss"; import Edit from "./edit"; registerBlockType("oja/post-list", { title: "Oja Post List Block", description: "記事リストをブロックとして表示します", category: "common", icon: "smiley", keywords: ["post", "oja", "list"], supports: { customClassName: false, anchor: false, html: false, }, edit: Edit, save: () => { return null; }, });
今回はPHPでレンダリングするダイナミックブロックであるため、JSでのattributes属性は不要です
customClassName
は高度な設定自体を非表示にします
ここからは、より厳密にワークフローを組み立てたいと思います
WP REST API
WordPressでREST APIを使用したいと思います。
RESTful API(REST API)とは、Webシステムを外部から利用するためのプログラムの呼び出し規約(API)の種類の一つで、RESTと呼ばれる設計原則に従って策定されたもの、、、、
ということで噛み砕いてみると「ガイドラインに沿った情報のやり取り」ということですが、
WordPressもこれを用意してくれていて、ブロックエディターハンドブックによるとapiFetch()
を使用するのが良さそうです。これはfetchリクエストを作成するwindow.fetch
のラッパーのようです
カスタム投稿の場合
カスタム投稿タイプ・カスタムタクソノミーで使用する場合は初期設定が必要ですregister_post_type
、register_taxonomy
関数を使うときに、引数のパラメータに設定します
'show_in_rest' => true,
これでREST APIが使用できるようになりました
apiFetch()
まずはモジュールを読み込みます
import apiFetch from "@wordpress/api-fetch";
そして、カスタム投稿のスラッグを指定してFetchします
apiFetch 例文
const newBlogs = [{ label: "すべて", value: -1 }]; apiFetch({ path: "/wp/v2/blogs" }).then((blogs) => { blogs.forEach((blog) => { newBlogs.push({ label: blog["title"]["rendered"], value: blog["id"] }); }); });
apiFetch
の返り値はPromiseインスタンスであるため、then
で繋いでいきます
コールバックのblogs
をコンソールに出力してみるとわかりやすいです
環境変数path:
には現在のサイトのRESTAPIルートURLが設定されます
投稿データが取得することが確認できたら、次はサイドバーの設定を行います
Inspectorを設定する
まず、プラグインPHPファイルで設定しておいたattributes
の役割を確認します
- postListType,投稿のタイプ(投稿、カテゴリー、タグ)を条件分岐
- isTimeStamp,日付を表示するかフラグ
- isPostLabel,ラベルを表示するかフラグ
- showPostNumber,投稿の表示数
- postDesign,デザイン設定
- termId,タクソノミーが選択されていた場合絞り込み
- postOrder,昇順?降順?
- postOrderBy,表示基準
設計として、PHPであらかじめなんとなく定義しておいた属性値は、実際に組み立てる際に追加したり詳細を決めたりします
まず最初に設定してもらうべきはpostListTypeで投稿、カテゴリー、タグを選択してもらい投稿であれば即時レンダリング。 そして投稿以外であればどのタクソノミーで表示するかを選択してもらえば良さそうです
ということで、「postListTypeが投稿以外であればtermIdを選択してもらう」とします
また、postOrder、postOrderBy、showPostNumberは最終的にレンダリングする際にここで設定した文字列をそのままWP_Query
のパラメータとして使用することが想定されるため、これに合わせます
これ踏まえてedit.jsを組み立てます
edit.js(全文)
import { InspectorControls } from "@wordpress/block-editor"; import ServerSideRender from "@wordpress/server-side-render"; import { PanelBody, PanelRow, RadioControl, SelectControl, ToggleControl, } from "@wordpress/components"; import SelectTaxonomy from "./components/SelectTaxonomy"; import PostNumber from "./components/PostNumber"; import apiFetch from "@wordpress/api-fetch"; import { useBlockProps } from "@wordpress/block-editor"; import "./editor.scss"; export default function Edit(props) { const { attributes: { postListType, isTimeStamp, isPostLabel, showPostNumber, postDesign, postOrder, postOrderBy, termId, }, className, setAttributes, } = props; return [ <InspectorControls> <PanelBody title="投稿リスト設定" initialOpen={true}> <PanelRow> <RadioControl className="ojaPostListType" label="投稿のタイプ" selected={postListType} options={[ { label: "最新の投稿", value: "post" }, { label: "カテゴリー", value: "category" }, { label: "タグ", value: "tag" }, ]} onChange={(val) => setAttributes({ postListType: val })} /> </PanelRow> {postListType !== "post" && ( <PanelRow> <SelectTaxonomy postListType={postListType} termId={termId} setAttributes={setAttributes} /> </PanelRow> )} </PanelBody> <PanelBody title="表示設定" initialOpen={true}> <PanelRow> <PostNumber showPostNumber={showPostNumber} setAttributes={setAttributes} /> </PanelRow> <PanelRow> <ToggleControl label={isTimeStamp ? "日付を表示する" : "日付を表示しない"} checked={isTimeStamp} onChange={(val) => setAttributes({ isTimeStamp: val })} /> </PanelRow> <PanelRow> <ToggleControl label={ isPostLabel ? "投稿にラベルを表示する" : "ラベルは表示しない" } checked={isPostLabel} onChange={(val) => setAttributes({ isPostLabel: val })} /> </PanelRow> <PanelRow> <RadioControl className="ojaPostListDesign" label="表示タイプ (デザイン)" selected={postDesign} options={[ { label: "カード", value: "cade" }, { label: "シンプル", value: "simple" }, { label: "テキスト", value: "text" }, ]} onChange={(val) => setAttributes({ postDesign: val })} /> </PanelRow> <PanelRow> <SelectControl label="表示基準" value={postOrderBy} options={[ { label: "日付順", value: "date" }, { label: "更新順", value: "modified" }, { label: "ランダム", value: "rand" }, { label: "タイトル順", value: "title" }, { label: "著者順", value: "author" }, ]} onChange={(val) => setAttributes({ postOrderBy: val })} /> </PanelRow> <PanelRow> <SelectControl label="表示順" value={postOrder} options={[ { label: "昇順", value: "ASC" }, { label: "降順", value: "DESC" }, ]} onChange={(val) => setAttributes({ postOrder: val })} /> </PanelRow> </PanelBody> </InspectorControls>, <div className={className}> <ServerSideRender block={props.name} attributes={{ postListType, isTimeStamp, isPostLabel, showPostNumber, postDesign, termId, postOrder, postOrderBy, }} className="oja-server-siderender" /> </div>, ]; }
最初のRadioControlで投稿のタイプを設定します。ここでのvalue値は3パターンに分岐できれば良いというもので特に問題ありません。その属性値に応じて<SelectTaxonomy>というコンポーネントを作成しています
SelectTaxonomyコンポーネント
ここではpostListTypeの状態を判定してタクソノミーを出し分けるロジックを組んで行きます
import SelectTaxonomy from "./components/SelectTaxonomy";
上記のディレクトリでコンポーネントを作成していきますが、ここは適宜お好みで変更して下さい
components/SelectTaxonomy.js
import { SelectControl } from "@wordpress/components"; import apiFetch from "@wordpress/api-fetch"; const categories = [{ label: "選択して下さい", value: -1 }]; apiFetch({ path: "/wp/v2/oja_cat?per_page=-1_fields=name,slug,id" }).then( (cates) => { cates.forEach((cate) => { categories.push({ label: cate["name"], value: cate["id"] }); }); } ); const postTags = [{ label: "選択して下さい", value: -1 }]; apiFetch({ path: "/wp/v2/oja_tags?per_page=-1_fields=name,slug,id" }).then( (tags) => { tags.forEach((tag) => { postTags.push({ label: tag["name"], value: tag["id"] }); }); } ); const SelectTaxonomy = (props) => { const { termId, postListType, setAttributes } = props; return ( <SelectControl label={postListType === "category" ? "カテゴリーを選択" : "タグを選択"} value={termId} options={postListType === "category" ? categories : postTags} onChange={(val) => setAttributes({ termId: val })} /> ); }; export default SelectTaxonomy;
ここではタクソノミーを取得するためのapiFetch
を実行していますapiFetch
のパラメータですがper_page=-1
は全件取得、
パラメータは大量に取得される情報に対して制限をかけてパフォーマンスチューニングを行っています
_fields=name,slug,id
後はpostListTypeの状態に応じて表示するオプションの変更やラベルの動的な変更を行っています
※ このタクソノミーをだし分けるロジックですが、React.momeやコールバックフックなどでもう少しパフォーマンスチューニングが可能なような気がします
引き続きアップデートしていきます
PostNumberコンポーネント
ここではNumberControl
をHTMLタグでラップするためのコンポーネントです
NumberControl
は標準ではinput
タグをレンダリングするだけなので疑似要素を追加したりなどスタイリングがしにくい為コンポーネント化します
属性値として保存された数値は文字列となってしまうためparseInt()
で整数値にしています
./components/PostNumber
import { __experimentalNumberControl as NumberControl} from "@wordpress/components"; const PostNumber = (props) => { const { showPostNumber, setAttributes } = props; return ( <div className="postsNumber"> <NumberControl label="表示数" isShiftStepEnabled="true" shiftStep="2" min="2" max="8" value={showPostNumber} onChange={(value) => setAttributes({ showPostNumber: parseInt(value) })} /> </div> ); }; export default PostNumber;
<ServerSideRender>
ServerSideRenderは編集画面でPHP の render_callback 関数に基づいたレンダリングを行うことが出来るため、パラメータの変更などに対して動的にプレビューを変更することが可能です
<ServerSideRender
block= ブロックの識別子 '名前空間/ブロック名'
attributes={{
PHP のレンダリングで使用している属性を指定(指定しないと表示されない)
}}
className='クラス名'
/>
attributesのパラメータは属性名={属性値}としますが分割代入で変数にしているため、オブジェクトの短縮記法を使用することが出来るため以下のように記述出来ます
edit.js (抜粋)
<ServerSideRender block={props.name} attributes={{ postListType, isTimeStamp, isPostLabel, showPostNumber, postDesign, termId, postOrder, postOrderBy, }} className="oja-server-siderender" />;
これで投稿を表示するロジックは完成したので実際にフロントエンドでレンダリングする処理を書いていきます
PHPでレンダリング
PHPでレンダリングするダイナミックブロックは render_callbackパラメータを指定しindex.jsのsave関数で null を返すことでPHPによる描画が可能です
ということはWordPressの関数が自由に使用できるということで、Wp_Query を使用してパラメータを指定していきます
post-list.php
<?php function post_list_render($attr, $content) { //管理画面でa タグを削除する function hrefChecker() { if ( !is_admin() ) { $href = ' href="'. get_the_permalink().'"'; } else { $href = ' style="pointer-events: none;"'; } return $href; } //表示数に合わせてレイアウトを変更する function content_style_width($show_post_num) { $content_width = 'style="flex-basis:'; if($show_post_num % 4 === 0 || $show_post_num === 7) { $content_width .= 'calc((100% - 40px) / 4); margin-right: 10px;"'; } elseif($show_post_num % 3 === 0 || $show_post_num === 5) { $content_width .= 'calc((100% - 30px) / 3); margin-right: 10px;"'; } else { $content_width .= 'calc((100% - 40px) / 2); margin-right: 20px;"'; } return $content_width; } //ラベルをレンダリングする関数 function content_label_render($is_post_label, $post_list_type, $ID) { $post_label = '<span class="post_label">'; $term = get_term($ID); if(!$is_post_label) { return; } if($post_list_type === "post") { $post_label .= "おすすめ記事</span>"; } elseif($post_list_type === "category") { $post_label .= esc_html($term->name)."</span>"; } else { $post_label .= '#'.esc_html($term->name).'</span>'; } return $post_label; } $args = array( 'post_type' => 'blogs', 'post_status' => 'publish', 'order' => $attr['postOrder'], //昇順 or 降順の指定 'orderby' => $attr['postOrderBy'], //何順で並べるかの指定 'posts_per_page' => $attr['showPostNumber'], ); $post_class = ' '.$attr['postDesign']; //投稿タイプ以外の場合 if($attr['postListType'] !== "post") { $tax_args[] = array( 'taxonomy' => $attr['postListType'] === "category" ? 'oja_cat' : 'oja_tags', 'field' => 'id', 'terms' => $attr['termId'], 'include_children' => false,//子タクソノミーを含めるかどうか ); $args += Array('tax_query' => array($tax_args)); } $archive = '<div class="wp-block-oja-post-list'.$post_class.'"><ul class="post-list-container">'; $the_query = new WP_Query( $args ); if ( $the_query->have_posts() ) : while ( $the_query->have_posts() ) : $the_query->the_post(); $archive .= '<li '.content_style_width($attr['showPostNumber']).'>'; $archive .= '<a class="post-list-link"'.hrefChecker().'>'; if (has_post_thumbnail() && $attr['postDesign'] !== "text") $archive .= '<figure>'.get_the_post_thumbnail().'</figure>'; $archive .= '<div class="post_content">'.content_label_render($attr['isPostLabel'], $attr['postListType'], $attr['termId']); $archive .= '<h3>' . get_the_title() . '</h3>'; if($attr['isTimeStamp']) $archive .= '<time datetime="' . get_the_date( 'Y-m-d' ) . '">' . get_the_date( 'Y年m月d日' ) . '</time>'; $archive .= '</div></a></li>'; endwhile; $archive .='</ul></div>'; else: $archive = '<p>記事は取得できませんでした</p>'; endif; wp_reset_postdata(); //出力 return $archive; }
Wp_Queryのパラメータについては適宜検索しています。
すぐに忘れてしまうので、、笑
いっつも忘れるWP_Queryの使用方法とパラメータ一覧。がっつり整理してみた
いくつか関数を定義して、要所で使用するため解説していきます
ラベルの有無に応じて出力を変更する関数
function content_label_render($is_post_label, $post_list_type, $ID) { $post_label = '<span class="post_label">'; $term = get_term($ID); if(!$is_post_label) { return; } if($post_list_type === "post") { $post_label .= "おすすめ記事</span>"; } elseif($post_list_type === "category") { $post_label .= esc_html($term->name)."</span>"; } else { $post_label .= '#'.esc_html($term->name).'</span>'; } return $post_label; }
この関数は、ラベルの有無を確認するbool値を判定したのち、$post_list_type
の値に応じて表示名を出し分けます。 もしタクソノミーであるならば、get_term()
関数でタームオブジェクトを取得してそのターム名を出力するようにしております
投稿の表示数に応じてレイアウトを変更する関数
function content_style_width($show_post_num) { $content_width = 'style="flex-basis:'; if($show_post_num % 4 === 0 || $show_post_num === 7) { $content_width .= 'calc((100% - 40px) / 4); margin-right: 10px;"'; } elseif($show_post_num % 3 === 0 || $show_post_num === 5) { $content_width .= 'calc((100% - 30px) / 3); margin-right: 10px;"'; } else { $content_width .= 'calc((100% - 40px) / 2); margin-right: 20px;"'; } return $content_width; }
これはインラインスタイルの文字列を作成し返す関数です
基本的にCSSの flexboxが指定されている子要素に対してこの関数を実行しカード型のデザインを整える目的で使用します
管理画面上では aタグのhref属性を無効にする
function hrefChecker() { if ( !is_admin() ) { $href = ' href="'. get_the_permalink().'"'; } else { $href = ' style="pointer-events: none;"'; } return $href; }
この関数は正直、うまくいきませんでした、、、、
is_admin()の条件判定が、上手くいっておりません
どういうわけか管理画面でもフロントエンドでもis_admin()の判定がfalseになってしまいます
ドキュメントを読んでもいまいちピンと来ないため、一旦保留にします
SCSSでスタイリング
最後にstyle.scssでデザインを整えます
src/style.scss
@charset "utf-8"; .wp-block-oja-post-list { padding: 12px 10px 2px; width: 100%; min-height: 285px; ul.post-list-container { border: none; list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; justify-content: flex-start; li { padding: 0; margin: 20px 0 0; position: relative; background: #fff; -webkit-box-shadow: 0 1px 3px rgb(0 0 0 / 18%); box-shadow: 0 1px 3px rgb(0 0 0 / 18%); pointer-events: none; transition: all 0.3s; &:hover { transform: translateY(-2px); color: #888888; box-shadow: 0 0 10px rgb(125 125 125 / 50%); } @media screen and (max-width:543px) { flex-basis: 100% !important; } a.post-list-link { border-radius: 2px; display: flex; flex-direction: column; text-decoration: none; color: #847d76; pointer-events: auto; } figure { width: 100%; position: relative; overflow: hidden; &:before { content: ""; display: block; padding-top: 28.5%; } img { position: absolute; top: 0%; left: 0%; width: 100%; height: 100%; object-fit: cover; } } .post_content { display: block; padding: 10px 13px 30px; h3 { font-family: 'Rounded Mplus 1c',sans-serif; font-size: 13px; line-height: 1.3rem; font-weight: 400; color: #847d76; margin: 0; padding: 0; &::before,::after { content: none; } @media screen and (max-width:768px) { font-size: 12px; line-height: 1.2rem; } @media screen and (max-width:543px) { font-size: 13px; } } span.post_label { font-family: Quicksand,'Rounded Mplus 1c',sans-serif; position: absolute; left: 0%; top: 0%; padding: 0 3px; background-color: #b8e886; color: #fff; font-size: 10px; } time { position: absolute; bottom: 3%; left: 5%; transform: scale(1.05); font-size: 10px; margin-top: 5%; font-family: Quicksand,'Rounded Mplus 1c',sans-serif; &::before { margin-right: 0.3em; font-family: "Font Awesome 5 Free"; font-weight: 900; content: "\f017"; } } } } //li } //ul //シンプル&テキストスタイル &.simple,&.text { ul.post-list-container { flex-direction: column; flex-wrap: nowrap; li { margin-bottom: 2%; margin-top: 10px; @media screen and (max-width:543px) { margin-bottom: 0; } a.post-list-link { flex-direction: row; } figure { width: 30%; @media screen and (max-width:768px) { width: 40%; } @media screen and (max-width:543px) { width: 90%; } } .post_content { padding: 22px 20px 15px; @media screen and (max-width:543px) { padding: 8px 10px 5px; } } h3 { font-size: 16px; @media screen and (max-width:768px) { font-size: 14px; } @media screen and (max-width:543px) { font-size: 10px; } } time { position: absolute; right: 3%; bottom: 3%; left: initial; @media screen and (max-width:543px) { transform:scale(.8); right: 1%; bottom: 1%; } } } } } &.text { .post_content { padding: 25px 10px !important; @media screen and (max-width:543px) { padding-bottom: 15px !important; } } } } //wp-block
インスペクターのデザインです
これらはお好みですので、適宜変更を
src/editor.scss
@charset "utf-8"; .wp-block-oja-post-list { min-height: initial; padding: 0; figure { margin: 0; } } .components-radio-control { .components-base-control__field { display: flex; flex-wrap: wrap; .components-base-control__label { max-width: initial; flex-basis: 100%; } } &__option { width: 30%; display: flex; flex-direction: column; input { display: none; } input[type="radio"]:checked + label { background-color: #0085ba; color: #fff; text-shadow: 0 -1px 1px #005d82, 1px 0 1px #005d82, 0 1px 1px #005d82, -1px 0 1px #005d82; } label { flex-grow: 1; display: block; cursor: pointer; width: 100%; margin: 0; padding: 7px 4px; background: #f7f7f7; color: #555e64; text-align: center; line-height: 1.3; font-size: 12px; transition: .2s; border: solid 1px #ccc; box-shadow: inset 0 -1px 0 #ccc; &:hover { background: #fafafa; border-color: #999; box-shadow: inset 0 -1px 0 #999; color: #23282d; } } } } .postsNumber { width: 80%; position:relative; &:after { content: '記事表示します'; display: block; position: absolute; right: 40%; top: 55%; } }
コメントをお待ちしております