LexicalでWYSIWIGに画像を扱うぞ

https://lexical.dev/

facebookが開発しているWYSIWYG(What you see is what you get)エディタライブラリを使って、画像を取り扱うプラグインを導入する。

記事数も少しずつ増えてきており、スクショを載せたいタイミングも増えてきたので取り掛かる。


Lexical Playgroundを参考にする。

https://playground.lexical.dev/

ここでカスタマイズ含めどのようなことができるかのイメージを掴むことができ、playground自体のソースコードも参照することができるためちょうどよいpluginがあれば自分で導入することもできる。


Lexical Playgroundで試した際に良さげだった要素をまとめて導入する。

(- DraggableBlockPlugin: リッチテキスト中の要素をドラッグアンドドロップで移動できる。画像自体とはあまり関係ない)

- ImagesPlugin: 画像を追加するためのプラグイン。(pluginとはなんぞ...?)

- ImageNode: 画像に必須。あらかじめeditorに登録しておく必要がある。

- ImageComponent: ImageNodeの内部で画像を描画するために使用されている。

(- ImageResizer: 画像にフォーカスしているときにresizeできる。)


これらによって、まずはコピペでの画像アップロードを実現する。具体的にはアップロードしたタイミングでアップロードエンドポイント経由で画像を登録し、登録されたデータを下に記事に埋め込むsrcリンクを作成する。

公式のlexical-playground周りはcaptionのnestしたエディタ周りを含めかなり複雑になっているので、lexicalの仕組みを頭に入れながら自前で実装していく。手詰まったときに参考にする程度で。


Lexicalの概念サマリ

Lexical Plugin

- 要素のドラッグアンドドロップ移動

- Toolbar経由で要素のスタイル変更

など、アクション起因でEditor/要素に何かしら変更を加える場合はPluginを設定する。

Lexical Node

Lexicalで描画される要素。editorStateを保存した場合にもserializeされる。

今回は画像を扱うためのLexical Nodeを自分で実装する必要がある。


画像を表示するためのLexical Nodeの最小実装

import {
$applyNodeReplacement,
DecoratorNode,
LexicalNode,
NodeKey,
} from "lexical";
import Image from "next/image";
import { ReactNode } from "react";

export class ImageNode extends DecoratorNode<ReactNode> {
__src: string;

constructor(src: string, key?: NodeKey) {
super(key);
this.__src = src;
}

static getType(): string {
return "image";
}

static clone(node: ImageNode): ImageNode {
return new ImageNode(node.__src, node.__key);
}

createDOM(): HTMLElement {
return document.createElement("div");
}

updateDOM(): false {
return false;
}

decorate(): ReactNode {
return <ImageNodeComponent />;
}
}

const ImageNodeComponent = () => {
return <Image src="https://papipepa.net/icons/favicon.png" alt="favicon" width="300" height="300"/>;
};

export interface ImagePayload {
src: string;
}

export function $createImageNode({ src }: ImagePayload): ImageNode {
return $applyNodeReplacement(new ImageNode(src));
}

export function $isImageNode(
node: LexicalNode | null | undefined,
): node is ImageNode {
return node instanceof ImageNode;
}

editorContext内で$createImageNode関数を呼び出し、作成されたノードをeditorに登録することで画像が表示されることを確認できる。

const imageNode = $createImageNode({ src: "https://papipepa.net/icons/favicon.png" });
$insertNodes([imageNode]);


画像コンポーネントを保存する

前セクションのコードの状態で、画像をエディタ内に描画するところまでは実現できた。

しかし、この状態でエディタの内容をJSONにシリアライズして永続化した場合には、作成した画像コンポーネントのデータが含まれていない。


https://lexical.dev/docs/concepts/serialization#lexicalnodeexportjson

これはカスタマイズして作成したNodeに関してどのようにシリアライズ・デシリアライズするかを決定するメソッドの実装を行っていたないためである。


serializeに必要な要素をinterfaceとして定義し、

export type SerializedImageNode = Spread<
{
src: string;
},
SerializedLexicalNode
>;

ImageNode class内で各メソッドの実装を行う。

exportJSON(): SerializedImageNode {
return {
...super.exportJSON(),
src: this.__src,
};
}

static importJSON(serializedNode: SerializedImageNode): ImageNode {
return $createImageNode({
src: serializedNode.src,
}).updateFromJSON(serializedNode);
}

updateFromJSON(serializedNode: LexicalUpdateJSON<SerializedLexicalNode>): this {
return super.updateFromJSON(serializedNode);
}

これによって作成したImageNodeのシリアライズ・デシリアライズを行うことができるようになる。

Related Articles