ReactでProseMirrorを使ったWYSIWYGエディタの実装
始めに
WYSIWYGエディタライブラリの一つにProseMirrorがありますが、これはReactやVue.jsを使わないpureなJavaScriptであるため、ProseMirror単体でWYSIWYGエディタを作ることができます。しかし、エディタをカスタマイズするに当たってフォントサイズの変更などのメニューを用意したい場合にUIの実装はReactなどを使った方が作りやすくはあります。そこで今回はReactでProseMirrorを使う方法を先に説明し、その後実際にいくつか機能を盛り込んだWYSIWYGエディタを紹介したいと思います。
実際に作ったもの
今回は以下の装飾機能を持つWYSIWYGエディタを作りました。
- 太字、斜体、下線の装飾
- 色変更
- 文字サイズ変更
- テキストの位置指定(左寄せ、中央寄せ、右寄せ)
- テキストリンク設定
ProseMirrorで動かした内容をReactと連動させる
まず最初にProseMirrorで入力した内容をReactと連動できるようにします。Reactで操作しやすいようにEditor
コンポーネントでラップさせます。
Editor
コンポーネントのinterfaceはinitialHtml
とonChangeHtml
だけで、初期表示用のHTMLテキストを使って初期表示をし、後は入力変更があるたびにonChangeHtml
でHTMLテキストを親コンポーネントに送ります。
ProseMirrorの起動はあらかじめrefで参照しておいたDOMに対してマウントします。ProseMirrorのstateにはinitialHtml
をDocに変換したものを使って、初期のテキストを構成します。ProseMirrorで変更があったときはdispatchTransaction
コールバックで変更処理を受け取れるので、その時に現在のinnerHTMLをonChangeHtml
に送ります。
これでReactとの連携自体は完了しましたが、今後EditorViewのステータスを見てUIを変更する際にこのままだとReactのrerenderが走らないためUIの変更が描画されません。そこでforceUpdate
の役割を果たす仮のreducerを用意し、それをProseMirror変更時に実行することでUI変更の追従ができるようにします。
import { useEffect, useRef, useReducer, FC } from "react";
import { Schema, Node, DOMParser } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { undo, redo, history } from "prosemirror-history";
import { keymap } from "prosemirror-keymap";
import { baseKeymap, toggleMark } from "prosemirror-commands";
import { schema } from "../schema";
const createDoc = <T extends Schema>(html: string, pmSchema: T) => {
const element = document.createElement("div");
element.innerHTML = html;
return DOMParser.fromSchema(pmSchema).parse(element);
};
const createPmState = <T extends Schema>(
pmSchema: T,
options: { doc?: Node } = {}
) => {
return EditorState.create({
doc: options.doc,
schema: pmSchema,
plugins: [
history(),
keymap({
"Mod-z": undo,
"Mod-y": redo,
"Mod-Shift-z": redo
}),
keymap({
Enter: baseKeymap["Enter"],
Backspace: baseKeymap["Backspace"]
})
]
});
};
export type Props = {
initialHtml: string;
onChangeHtml: (html: string) => void;
};
export const Editor: FC<Props> = (props) => {
const [_, forceUpdate] = useReducer((x) => x + 1, 0);
const elContentRef = useRef<HTMLDivElement | null>(null);
const editorViewRef = useRef<EditorView>();
useEffect(() => {
const doc = createDoc(props.initialHtml, schema);
const state = createPmState(schema, { doc });
const editorView = new EditorView(elContentRef.current, {
state,
dispatchTransaction(transaction) {
const newState = editorView.state.apply(transaction);
editorView.updateState(newState);
props.onChangeHtml(editorView.dom.innerHTML);
forceUpdate();
}
});
editorViewRef.current = editorView;
forceUpdate();
return () => {
editorView.destroy();
};
}, []);
return (
<div>
<div ref={elContentRef} />
</div>
);
};
ProseMirrorのschemaの定義は現段階だと以下のようになっています。
import { Schema } from "prosemirror-model";
export const schema = new Schema({
nodes: {
doc: {
content: "block+"
},
paragraph: {
content: "inline*",
group: "block",
parseDOM: [{ tag: "p" }],
toDOM() {
return ["p", 0];
}
},
text: {
group: "inline"
}
},
marks: {}
});
後はこのEditor.tsx
をアプリケーションで呼び出します。入力内容を確認できるようにHTMLテキストやプレビューも出せるようにします。
import { FC, useState } from "react";
import { Editor } from "./components/Editor";
const INITIAL_HTML = `<p>Reactと連携</p>`;
const App: FC = () => {
const [html, setHtml] = useState(INITIAL_HTML);
return (
<div>
<Editor
initialHtml={html}
onChangeHtml={(newHtml) => {
setHtml(newHtml);
}}
/>
<div>
<h3>HTML</h3>
<div>{html}</div>
</div>
<div className="preview">
<h3>プレビュー</h3>
<div
dangerouslySetInnerHTML={{
__html: html
}}
/>
</div>
</div>
);
};
export default App;
これを実行すると以下のような動作になります。
各装飾の実装
ProseMirrorをReactと連携できたので、ここからは各装飾の実装に入っていきます。ProseMirrorへ操作を実行する場合はCommand
を発行してそれを実行するフローになっています。そのCommand
をReactで作ったUIコンポーネント内で呼び出せば良いので、実装の流れは基本以下のようになります。
- 装飾用のProseMirror Schemaの定義
- 装飾を行うCommandを定義
- React UI上でCommandを実行
太字、斜体、下線の装飾
schemaの定義
まず最初に太字、斜体、下線のschemaを定義します。コードは以下のようになります。
import { Schema } from "prosemirror-model";
export const schema = new Schema({
// nodesは省略
marks: {
/// An emphasis mark. Rendered as an `<em>` element. Has parse rules
/// that also match `<i>` and `font-style: italic`.
em: {
parseDOM: [{ tag: "i" }, { tag: "em" }, { style: "font-style=italic" }],
toDOM() {
return ["em", 0];
}
},
/// A strong mark. Rendered as `<strong>`, parse rules also match
/// `<b>` and `font-weight: bold`.
strong: {
parseDOM: [
{ tag: "strong" },
// This works around a Google Docs misbehavior where
// pasted content will be inexplicably wrapped in `<b>`
// tags with a font-weight normal.
{
tag: "b",
getAttrs: (node: string | HTMLElement) =>
typeof node !== "string" &&
node.style.fontWeight !== "normal" &&
null
},
{
style: "font-weight",
getAttrs: (value: string | HTMLElement) =>
typeof value === "string" &&
/^(bold(er)?|[5-9]\d{2,})$/.test(value) &&
null
}
],
toDOM() {
return ["strong", 0];
}
},
underline: {
parseDOM: [{ tag: "u" }],
toDOM() {
return ["u", 0];
}
},
}
});
ちなみに太字、斜体については以下のコードを参考にしました。
装飾で行うCommandの定義
範囲選択したテキストにmarkをつけたり外したりするCommandは既にtoggleMark
という名前でProseMirrorに提供されており、今回はこちらを使います。
React UI上でCommandを実行
最後にこのCommandをReact UI上で実行します。そこでまずは装飾用のメニューコンポーネントを作ります。今選択しているテキストが装飾がついているかもUIで見えていた方が良いため、isActiveMark
というutilメソッドを用意して、チェックしています。
import { FC } from "react";
import { EditorView } from "prosemirror-view";
import { toggleMark } from "prosemirror-commands";
import { schema } from "../../schema";
import { isActiveMark } from "./isActiveMark";
export type EditorMenuProps = {
editorView: EditorView;
};
export const EditorMenu: FC<EditorMenuProps> = (props) => {
return (
<div>
<button
style={{
fontWeight: isActiveMark(props.editorView.state, schema.marks.strong)
? "bold"
: undefined
}}
onClick={() => {
toggleMark(schema.marks.strong)(
props.editorView.state,
props.editorView.dispatch,
props.editorView
);
props.editorView.focus();
}}
>
B
</button>
<button
style={{
fontWeight: isActiveMark(props.editorView.state, schema.marks.em)
? "bold"
: undefined
}}
onClick={() => {
toggleMark(schema.marks.em)(
props.editorView.state,
props.editorView.dispatch,
props.editorView
);
props.editorView.focus();
}}
>
I
</button>
<button
style={{
fontWeight: isActiveMark(
props.editorView.state,
schema.marks.underline
)
? "bold"
: undefined
}}
onClick={() => {
toggleMark(schema.marks.underline)(
props.editorView.state,
props.editorView.dispatch,
props.editorView
);
props.editorView.focus();
}}
>
U
</button>
</div>
);
};
isActiveMark
の実装はtoggleMark
でaddするかremoveするかの判定をしているコードを抜粋したものになっています。
import { EditorState } from "prosemirror-state";
import { TextSelection } from "prosemirror-state";
import { MarkType } from "prosemirror-model";
/**
* 選択中のエディタが既にmarkの設定をしているかチェック
* @param state - エディタのステート
* @param markType - アクティブか調べるMark
*/
export const isActiveMark = (state: EditorState, markType: MarkType) => {
// 参考: https://github.com/ProseMirror/prosemirror-commands/blob/1.3.1/src/commands.ts#L510-L534
if (state.selection instanceof TextSelection && state.selection.$cursor) {
return markType.isInSet(
state.storedMarks || state.selection.$cursor.marks()
)
? true
: false;
}
const { ranges } = state.selection;
for (let i = 0; i < ranges.length; i++) {
if (
state.doc.rangeHasMark(ranges[i].$from.pos, ranges[i].$to.pos, markType)
) {
return true;
}
}
return false;
};
このメニューコンポーネントをEditor.tsx
で呼び出したら完成です。
// 省略
export const Editor: FC<Props> = (props) => {
// 省略
return (
<div>
+ {editorViewRef.current && (
+ <EditorMenu editorView={editorViewRef.current} />
+ )}
<div ref={elContentRef} style={{ marginTop: "4px" }} />
</div>
);
};
実行すると以下のような動きになると思います。
余談: ショートカットキーの設定
ProseMirrorのCommandの実行はReact UIからに限ったものではなく、ショートカットキーからでも実行できます。むしろそっちに合わせたインターフェースな気がしています。
これはprosemirror-keymap
モジュールに目的のキーに対して実行したいCommandをセットするだけで動きます。
import { keymap } from "prosemirror-keymap";
import { baseKeymap, toggleMark } from "prosemirror-commands";
const createPmState = <T extends Schema>(
pmSchema: T,
options: { doc?: Node } = {}
) => {
return EditorState.create({
doc: options.doc,
schema: pmSchema,
plugins: [
// 他pluginは省略
keymap({
"Mod-b": toggleMark(pmSchema.marks.strong),
"Mod-i": toggleMark(pmSchema.marks.em),
"Mod-u": toggleMark(pmSchema.marks.underline)
}),
]
});
};
色変更
schemaの定義
続いて色のschemaを定義します。色はタグで表現することができないため、spanタグにstyle属性を付与するschemaにします。
import { Schema } from "prosemirror-model";
export const schema = new Schema({
// nodesは省略
marks: {
// 他のmarksは省略
color: {
attrs: {
color: {}
},
parseDOM: [
{
tag: "span",
getAttrs: (dom: string | HTMLElement) => {
if (typeof dom === "string") {
return false;
}
const { color } = dom.style;
if (!color) {
return false;
}
return {
color
};
}
}
],
toDOM(mark) {
const { color } = mark.attrs;
return ["span", { style: `color: ${color}` }, 0];
}
},
}
});
装飾で行うCommandの定義
色の装飾は指定した色をセットする形になるため、太字などの装飾と違ってtoggleMark
をすることができません。そこでこのコードを参考にaddMark
とremoveMark
に分けて、それぞれつけたり外したりできるCommandを用意します。
import { MarkType, Attrs } from "prosemirror-model";
import { Command, TextSelection } from "prosemirror-state";
import { markApplies } from "./markApplies";
/**
* Markをセットするコマンド
* toggleMarkからadd機能だけ抽出
* https://github.com/ProseMirror/prosemirror-commands/blob/1.3.1/src/commands.ts#L505-L538
* @param markType - Markタイプ
* @param attrs - Markに付与する属性
*/
export const addMark = (
markType: MarkType,
attrs: Attrs | null = null
): Command => {
return (state, dispatch) => {
let { empty, $cursor, ranges } = state.selection as TextSelection;
if ((empty && !$cursor) || !markApplies(state.doc, ranges, markType))
return false;
if (dispatch) {
if ($cursor) {
dispatch(state.tr.addStoredMark(markType.create(attrs)));
} else {
let tr = state.tr;
for (let i = 0; i < ranges.length; i++) {
const { $from, $to } = ranges[i];
let from = $from.pos,
to = $to.pos,
start = $from.nodeAfter,
end = $to.nodeBefore;
let spaceStart =
start && start.isText ? /^\s*/.exec(start.text!)![0].length : 0;
let spaceEnd =
end && end.isText ? /\s*$/.exec(end.text!)![0].length : 0;
if (from + spaceStart < to) {
from += spaceStart;
to -= spaceEnd;
}
tr = tr.addMark(from, to, markType.create(attrs));
}
dispatch(tr.scrollIntoView());
}
}
return true;
};
};
import { MarkType } from "prosemirror-model";
import { Command, TextSelection } from "prosemirror-state";
import { markApplies } from "./markApplies";
/**
* Markを取り除くコマンド
* toggleMarkからremove機能だけ抽出
* https://github.com/ProseMirror/prosemirror-commands/blob/1.3.1/src/commands.ts#L505-L538
* @param markType - Markタイプ
* @param attrs - Markに付与する属性
*/
export const removeMark = (markType: MarkType): Command => {
return (state, dispatch) => {
const { empty, $cursor, ranges } = state.selection as TextSelection;
if ((empty && !$cursor) || !markApplies(state.doc, ranges, markType))
return false;
if (dispatch) {
if ($cursor) {
dispatch(state.tr.removeStoredMark(markType));
} else {
let tr = state.tr;
for (let i = 0; i < ranges.length; i++) {
const { $from, $to } = ranges[i];
tr = tr.removeMark($from.pos, $to.pos, markType);
}
dispatch(tr.scrollIntoView());
}
}
return true;
};
};
React UI上でCommandを実行
このCommandをReact UI上で実行します。EditorMenu.tsx
にそのまま書くとコードが膨らんでしまうので、更にMenuItemColor.tsx
でコンポーネント化し、onSetColor
やonResetColor
を呼びます。メニュー上で何色を選択しているかが分かる様に、getSelectionMark
utilメソッドでmarkのattributesを取り出しています。
import { FC, useState, useMemo } from "react";
import { useDetectClickOutside } from "react-detect-click-outside";
import { EditorState } from "prosemirror-state";
import { getSelectionMark } from "../getSelectionMark";
import { schema } from "../../../schema";
export type MenuItemColorProps = {
editorState: EditorState;
onSetColor: (color: string) => void;
onResetColor: () => void;
};
const COLOR_LIST = ["red", "orange", "green", "cyan", "blue", "purple"];
export const MenuItemColor: FC<MenuItemColorProps> = (props) => {
const [isOpen, setIsOpen] = useState(false);
const ref = useDetectClickOutside({
onTriggered: () => {
setIsOpen(false);
}
});
const selectedColor = useMemo(() => {
const mark = getSelectionMark(props.editorState, schema.marks.color);
return mark ? mark.attrs.color : "black";
}, [props.editorState]);
return (
<div ref={ref} style={{ display: "inline-block", position: "relative" }}>
<button
onClick={() => {
setIsOpen(true);
}}
>
<span
style={{
display: "inline-block",
width: "10px",
height: "10px",
backgroundColor: selectedColor
}}
/>
</button>
{isOpen && (
<div
style={{
position: "absolute",
zIndex: 10,
padding: "10px",
border: "solid 1px #ccc",
backgroundColor: "white"
}}
>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gridGap: "4px"
}}
>
{COLOR_LIST.map((COLOR) => (
<button
key={COLOR}
onClick={() => {
props.onSetColor(COLOR);
setIsOpen(false);
}}
>
{COLOR}
</button>
))}
</div>
<hr />
<div style={{ textAlign: "right" }}>
<button
onClick={() => {
props.onResetColor();
setIsOpen(false);
}}
>
reset
</button>
</div>
</div>
)}
</div>
);
};
getSelectionMark.ts
の実装は以下になります。
import { MarkType } from "prosemirror-model";
import { EditorState, TextSelection } from "prosemirror-state";
/**
* 選択中のエディタの先頭カーソル部分のmarkデータを取得
* @param state - エディタのステート
* @param markType - 取得するMark
*/
export const getSelectionMark = (state: EditorState, markType: MarkType) => {
// 参考: https://github.com/ProseMirror/prosemirror-commands/blob/1.3.1/src/commands.ts#L510-L534
if (state.selection instanceof TextSelection && state.selection.$cursor) {
return markType.isInSet(
state.storedMarks || state.selection.$cursor.marks()
);
}
const { $from } = state.selection;
return $from.nodeAfter ? markType.isInSet($from.nodeAfter.marks) : undefined;
};
後はこれをEditorMenu.tsx
の方に追記して完成です。
export const EditorMenu: FC<EditorMenuProps> = (props) => {
return (
<div>
{/* 他のメニューボタンは省略 */}
+ <MenuItemColor
+ editorState={props.editorView.state}
+ onSetColor={(color) => {
+ addMark(schema.marks.color, { color })(
+ props.editorView.state,
+ props.editorView.dispatch,
+ props.editorView
+ );
+ props.editorView.focus();
+ }}
+ onResetColor={() => {
+ removeMark(schema.marks.color)(
+ props.editorView.state,
+ props.editorView.dispatch,
+ props.editorView
+ );
+ props.editorView.focus();
+ }}
+ />
</div>
);
};
実行すると以下のような動きになります。
文字サイズ変更
schemaの定義
次は文字サイズのschemaを定義します。色と同じようにタグでは表現できないので、spanタグにstyleを当てる感じにします。
import { Schema } from "prosemirror-model";
export const schema = new Schema({
// nodesは省略
marks: {
// 他のmarksは省略
size: {
attrs: {
fontSize: {}
},
parseDOM: [
{
tag: "span",
getAttrs: (dom: string | HTMLElement) => {
if (typeof dom === "string") {
return false;
}
const { fontSize } = dom.style;
if (!fontSize) {
return false;
}
return {
fontSize
};
}
}
],
toDOM(mark) {
const { fontSize } = mark.attrs;
return ["span", { style: `font-size: ${fontSize}` }, 0];
}
}
}
});
装飾で行うCommandの定義
色変更と同じやり方になるのでaddMark
とremoveMark
を使いまわします。
React UI上でCommandを実行
色変更と同じようにコンポーネントを分けて、フォントサイズ変更のUIコンポーネントをまずは用意します。
import { FC, useMemo } from "react";
import { EditorState } from "prosemirror-state";
import { getSelectionMark } from "../getSelectionMark";
import { schema } from "../../../schema";
export type MenuItemFontSizeProps = {
editorState: EditorState;
onSetFontSize: (fontSize: string) => void;
onResetFontSize: () => void;
};
const FONT_SIZE_LIST = ["12px", "16px", "24px"];
export const MenuItemFontSize: FC<MenuItemFontSizeProps> = (props) => {
const selectedFontSize = useMemo(() => {
const mark = getSelectionMark(props.editorState, schema.marks.size);
return mark ? mark.attrs.fontSize : "16px";
}, [props.editorState]);
return (
<select
value={selectedFontSize}
onChange={(event) => {
const fontSize = event.currentTarget.value;
if (fontSize === "16px") {
props.onResetFontSize();
} else {
props.onSetFontSize(fontSize);
}
}}
>
{FONT_SIZE_LIST.map((fontSize) => (
<option key={fontSize} value={fontSize}>
{fontSize}
</option>
))}
</select>
);
};
これをEditorMenu.tsx
に追加して完成です。
export const EditorMenu: FC<EditorMenuProps> = (props) => {
return (
<div>
{/* 他のメニューボタンは省略 */}
+ <MenuItemFontSize
+ editorState={props.editorView.state}
+ onSetFontSize={(fontSize) => {
+ addMark(schema.marks.size, { fontSize })(
+ props.editorView.state,
+ props.editorView.dispatch,
+ props.editorView
+ );
+ props.editorView.focus();
+ }}
+ onResetFontSize={() => {
+ removeMark(schema.marks.size)(
+ props.editorView.state,
+ props.editorView.dispatch,
+ props.editorView
+ );
+ props.editorView.focus();
+ }}
+ />
</div>
);
};
実行すると以下の様な動きになります。
テキストの位置指定(左寄せ、中央寄せ、右寄せ)
schemaの定義
次はテキストの位置指定のschemaを定義します。テキストの位置はparagraph Nodeで行った方が良いので、既存のnodeの定義にattrsを追加します。
import { Schema } from "prosemirror-model";
export const schema = new Schema({
nodes: {
// 他のnodesは省略
paragraph: {
content: "inline*",
group: "block",
+ attrs: {
+ align: { default: "left" }
+ },
parseDOM: [
{
tag: "p",
+ getAttrs(dom) {
+ if (typeof dom === "string") {
+ return false;
+ }
+ return {
+ align: dom.style.textAlign || "left"
+ };
+ }
}
],
toDOM(node) {
+ const { align } = node.attrs;
+ if (!align || align === "left") {
+ return ["p", 0];
+ }
+ return ["p", { style: `text-align: ${align}` }, 0];
}
},
},
// marksは省略
}
装飾で行うCommandの定義
ProseMirror Nodeにattrsを変更するCommandは既にsetBlockType
という名前で提供されているため、これを使うことにします。
React UI上でCommandを実行
後はこのCommandをReact UI上で実行します。ここでもテキスト位置設定用のコンポーネントに分けて実装します。カーソル位置のテキスト位置設定状況を知るためにgetSelectionNode
というutilメソッドを作っています。
import { FC, useMemo } from "react";
import { EditorState } from "prosemirror-state";
import { getSelectionNode } from "../getSelectionNode";
import { schema } from "../../../schema";
export type MenuItemTextAlignProps = {
editorState: EditorState;
onSetTextAlign: (textAlign: string) => void;
};
const ALIGN_LIST = ["left", "center", "right"];
export const MenuItemTextAlign: FC<MenuItemTextAlignProps> = (props) => {
const align = useMemo(() => {
const node = getSelectionNode(props.editorState, schema.nodes.paragraph);
return node ? node.attrs.align : "left";
}, [props.editorState]);
return (
<select
value={align}
onChange={(event) => {
props.onSetTextAlign(event.currentTarget.value);
}}
>
{ALIGN_LIST.map((ALIGN) => (
<option key={ALIGN} value={ALIGN}>
{ALIGN}
</option>
))}
</select>
);
};
getSelectionNode.ts
の実装は以下の様になります。
import { NodeType } from "prosemirror-model";
import { EditorState, TextSelection } from "prosemirror-state";
/**
* エディタの先頭カーソル部分のNodeデータを取得
* @param state - エディタのステート
* @param nodeType - 取得するNode
*/
export const getSelectionNode = (state: EditorState, nodeType: NodeType) => {
if (state.selection instanceof TextSelection && state.selection.$cursor) {
const node = state.selection.$cursor.parent;
if (node.type === nodeType) {
return node;
}
return undefined;
}
const node = state.selection.$from.parent;
return node.type === nodeType ? node : undefined;
};
後はEditorMenu.tsx
にこれを追加して完成です。
export const EditorMenu: FC<EditorMenuProps> = (props) => {
return (
<div>
{/* 他のメニューボタンは省略 */}
+ <MenuItemTextAlign
+ editorState={props.editorView.state}
+ onSetTextAlign={(align) => {
+ setBlockType(schema.nodes.paragraph, { align })(
+ props.editorView.state,
+ props.editorView.dispatch,
+ props.editorView
+ );
+ props.editorView.focus();
+ }}
+ />
</div>
);
};
これを実行すると以下の様な動きになります。
テキストリンク設定
schemaの定義
最後にテキストリンクのschemaを定義します。
import { Schema } from "prosemirror-model";
export const schema = new Schema({
// nodesは省略
marks: {
/// A link. Has `href` and `title` attributes. `title`
/// defaults to the empty string. Rendered and parsed as an `<a>`
/// element.
link: {
attrs: {
href: {},
title: { default: null }
},
// これがあると末尾で追加することができなくなる
inclusive: false,
parseDOM: [
{
tag: "a[href]",
getAttrs(dom: string | HTMLElement) {
if (typeof dom === "string") {
return false;
}
return {
href: dom.getAttribute("href"),
title: dom.getAttribute("title")
};
}
}
],
toDOM(mark) {
let { href, title } = mark.attrs;
return ["a", { href, title, target: "_blank" }, 0];
}
}
// 他のmarksは省略
}
});
ちなみにlink markはこちらを参考にしました。
装飾で行うCommandの定義
リンクを設定するCommandは既にaddMark
があるのでそれを使いまわします。
React UI上でCommandを実行
他と同じようにReact UI上でCommandを実行します。コンポーネントは分けていますが、簡易実装でwindow.prompt
を使っているので大分スッキリしたコードになっています。
import { FC } from "react";
import { EditorState } from "prosemirror-state";
export type MenuItemLinkProps = {
editorState: EditorState;
onSetLink: (url: string) => void;
};
export const MenuItemLink: FC<MenuItemLinkProps> = (props) => {
return (
<button
onClick={() => {
const url = window.prompt("URL");
if (url) {
props.onSetLink(url);
}
}}
>
link
</button>
);
};
これをEditorMenu.tsx
に追加したら完成です。
export const EditorMenu: FC<EditorMenuProps> = (props) => {
return (
<div>
{/* 他のメニューボタンは省略 */}
+ <MenuItemLink
+ editorState={props.editorView.state}
+ onSetLink={(url) => {
+ addMark(schema.marks.link, { href: url })(
+ props.editorView.state,
+ props.editorView.dispatch,
+ props.editorView
+ );
+ props.editorView.focus();
+ }}
+ />
</div>
);
};
実行すると以下の様な動きになります。
これでリンクの装飾はできましたが、なんのURLが設定されているかを確認する術が現状ではありません。次のセクションでURLの確認と変更ができるプラグインを作っていきます。
設定したリンクを確認・変更ができるプラグインの作成
リンクテキストにカーソルを当てた際に下にツールチップで設定されたURLの確認ができて、更に変更ができると良さそうなので、それを実装します。ツールチップの表示は以下でサンプルが公開されているので、それをベースに実装していきます。
ツールチップ表示する機能をReactで実装する
プラグイン自体はProseMirror側のものですが、実際にrenderingする処理はReactで行って問題ないです。なのでその辺の橋渡しができる様にプラグイン内で調整します。PluginView
をインターフェースとしてクラスを作成し、constructorでReactの描画ができるようにcreateRoot
を実行します。ここで得られたreactRootをupdate
のタイミングでreactRoot.render
を実行してReactの描画を実行し、詳細の実装はLinkTooltip
コンポーネントに委ねています。
import { createRoot } from "react-dom/client";
import { PluginView, EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { LinkTooltip } from "./LinkTooltip";
// 参考: https://prosemirror.net/examples/tooltip/
export class SelectionLinkTooltipView implements PluginView {
private dom: HTMLElement;
// 型が分からないので一旦any
private reactRoot: any;
constructor(view: EditorView) {
this.dom = document.createElement("div");
this.reactRoot = createRoot(this.dom);
if (view.dom.parentNode) {
view.dom.parentNode.appendChild(this.dom);
}
}
update(view: EditorView, prevState: EditorState) {
const state = view.state;
// Don't do anything if the document/selection didn't change
if (
prevState &&
prevState.doc.eq(state.doc) &&
prevState.selection.eq(state.selection)
) {
return;
}
if (this.dom.offsetParent == null) {
return;
}
const box = this.dom.offsetParent.getBoundingClientRect();
this.reactRoot.render(<LinkTooltip editorView={view} box={box} />);
}
destroy() {
this.dom.remove();
// 以下の警告が出てしまうのでワンサイクル置いてからunmountする
// Attempted to synchronously unmount a root while React was already rendering.
// React cannot finish unmounting the root until the current render has completed, which may lead to a race condition.
// @see https://stackoverflow.com/questions/73459382/react-18-async-way-to-unmount-root
setTimeout(() => {
this.reactRoot.unmount();
}, 0);
}
}
このプラグインを更にラップすることでProseMirrorで使えるようになります。
const selectionLinkTooltipPlugin = () => {
return new Plugin({
view(editorView) {
return new SelectionLinkTooltipView(editorView);
}
});
};
const createPmState = <T extends Schema>(
pmSchema: T,
options: { doc?: Node } = {}
) => {
return EditorState.create({
doc: options.doc,
schema: pmSchema,
plugins: [
// 他のpluginは省略
selectionLinkTooltipPlugin()
]
});
};
ツールチップ内の実装(LinkTooltip.tsx)
実際にツールチップを描画するLinkTooltip.tsx
のコードは以下の様になります。
まず表示する場所ですが、editorView.coordsAtPos
で指定したテキスト位置の座標がどこかを取得できます。これとpropで受け取っている親DOMのサイズを照らし合わせて、適切な場所にabsolute
で配置します。後は現在のURLをeditorView.state
が変更されるたびに更新して、カーソル位置で設定されているURLを見れるようにします。もしそこにURLを設定しているmarkがない場合はツールチップ自体を非表示にします。
このURL自体を入力テキストにして、カーソルが移動しない間は自由にURLを変更して更新できるようにします。更新する際はこの後説明するupdateMark
Commandを実行してURLを更新します。
import { FC, useState, useEffect } from "react";
import { clamp } from "lodash-es";
import { EditorView } from "prosemirror-view";
import { updateMark } from "../../commands";
import { getSelectionMark } from "../../components/EditorMenu/getSelectionMark";
import { schema } from "../../schema";
type Props = {
editorView: EditorView;
box: DOMRect;
};
const WIDTH = 255;
const OFFSET = 5;
export const LinkTooltip: FC<Props> = (props) => {
const [url, setUrl] = useState("");
const state = props.editorView.state;
const { from, to } = state.selection;
const start = props.editorView.coordsAtPos(from);
const end = props.editorView.coordsAtPos(to);
const center = Math.max((start.left + end.left) / 2, start.left + 3);
const left = clamp(
center - WIDTH / 2,
props.box.left,
props.box.right - WIDTH
);
useEffect(() => {
const mark = getSelectionMark(props.editorView.state, schema.marks.link);
if (mark) {
setUrl(mark.attrs.href);
}
}, [props.editorView.state]);
const mark = getSelectionMark(state, schema.marks.link);
const isVisible = mark != null;
return (
<div
style={{
display: isVisible ? undefined : "none",
position: "absolute",
top: `${start.bottom - props.box.top + OFFSET}px`,
left: `${left - props.box.left}px`,
width: WIDTH,
border: "solid 1px #ccc",
padding: "5px",
backgroundColor: "white",
boxSizing: "border-box"
}}
>
URL:
<input
type="text"
value={url}
onInput={(event) => {
setUrl(event.currentTarget.value);
}}
/>
<button
onClick={() => {
updateMark(schema.marks.link, { href: url })(
props.editorView.state,
props.editorView.dispatch,
props.editorView
);
}}
>
Apply
</button>
</div>
);
};
URLを更新するCommandの用意
既存のmark装飾に対して新しいattrs(URL)を付与するCommandは用意されてないので、それを作ります。今までは選択した範囲に直接markを指定したり取り除いたりしていますが、URLを更新する場合は既に設定されているlink markの範囲を知る必要があるので、まず先にその範囲を取得してからその範囲に対してaddMark
します。今回の実装は簡易的にして、カーソルの前後のテキストブロックのみを対象にしています。
import { MarkType, Attrs } from "prosemirror-model";
import { Command, TextSelection } from "prosemirror-state";
import { markApplies } from "./markApplies";
/**
* Markを更新するコマンド
* @param markType - Markタイプ
* @param attrs - Markに付与する属性
*/
export const updateMark = (
markType: MarkType,
attrs: Attrs | null = null
): Command => {
return (state, dispatch) => {
let { empty, $cursor, ranges } = state.selection as TextSelection;
if ((empty && !$cursor) || !markApplies(state.doc, ranges, markType))
return false;
if (dispatch) {
const $pos = $cursor || state.selection.$from;
let tr = state.tr;
const { nodeBefore, nodeAfter } = $pos;
// inline Nodeとかが含まれているとこれだけでは不十分そう
if (nodeBefore && markType.isInSet(nodeBefore.marks)) {
const textLength = nodeBefore.isText ? nodeBefore.text!.length : 0;
tr = tr.addMark(
$pos.pos - textLength,
$pos.pos,
markType.create(attrs)
);
}
if (nodeAfter && markType.isInSet(nodeAfter.marks)) {
const textLength = nodeAfter.isText ? nodeAfter.text!.length : 0;
tr = tr.addMark(
$pos.pos,
$pos.pos + textLength,
markType.create(attrs)
);
}
dispatch(tr.scrollIntoView());
}
return true;
};
};
これを実行すると以下のようになります。
終わりに
以上がReactでProseMirrorを使う方法でした。ProseMirror単体でも色々なことができますが、メニューのUIなどを作るときはReactを使った方が見た目の変更とかがやりやすくなると思うので、込み入った実装をしていくようになってきた際にこの記事が参考になれれば幸いです。
Discussion