Home About Contact
lexical , React , Note Taking

簡単なエディタをつくる試み lexical を調べる(その5)

前回 から 引き続き作業していきます。

今回は、Markdown Preview を追加しようと思います。

方針として、現状は Editor での編集結果を textarea へ渡していましたが、 それをやめて、代わりに React の Preview コンポーネントへ渡し Markdown テキストを良い感じにレンダリングさせます。

今回作成する React Preview コンポーネントは、 lexical のエディタ(LexicalComposer)を editable false の状態にして使います。 幸い、lexical は Markdownテキスト のレンダリングに最初から対応しているので、 それを活用します。

Preview の追加

まず src/Editor.js を src/Preview.js へコピーします。

その上で不要なコードを削除します。 その結果できたのが次のコード。

src/Preview.js

import React from "react";

import {LexicalComposer} from '@lexical/react/LexicalComposer';
import {PlainTextPlugin} from '@lexical/react/LexicalPlainTextPlugin';
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';

import {
  $convertFromMarkdownString,
  TRANSFORMERS,
} from '@lexical/markdown';


const theme = {};

function onError(error) {
  console.error(error);
}

function Preview( { initTextValue='' } ) {
  console.log( initTextValue );
  const initialConfig = {
    namespace: 'MyPreview',
    editable: false,
    theme,
    onError,
    editorState: () => $convertFromMarkdownString(initTextValue, TRANSFORMERS),
  };

  return (
    <LexicalComposer initialConfig={initialConfig}>
      <PlainTextPlugin
        contentEditable={<ContentEditable style={{ outline: 'none' }} />}
        ErrorBoundary={LexicalErrorBoundary}
      />
    </LexicalComposer>
  );
}

export default Preview;

初期設定として editable: false することで編集不可にしています。

  const initialConfig = {
    ...
    editable: false,
    ...    
  };

編集機能がないので、OnChangePlugin などは削除しています。

次に、src/index.js を修正します。

//root.render(<Editor initTextValue={initTextValue} onTextChange={onTextChange} />);

root.render(
  <>
    <Editor initTextValue={initTextValue} onTextChange={onTextChange} />
    <Preview initTextValue={initTextValue} />
  </>
);

root.render() の部分で Editor のみ記述していましたが、 ここに、 Preview を追加しました。

Preview を使ったので、先頭でインポートしておきます。

import Preview from "./Preview";

ここまでの変更でいったん実行( npm run build して python3 -m http.server 8000 --directory dist )します。 そして、 ブラウザで確認( http://localhost:8000/ )。

preview 1

できました。

ただ、テキストエディタ部分と Markdown プレビュー部分がひとつのボーダーで囲まれているので、わかりにくい。修正しましょう。

src/index.js を修正します。 ここで Editor, Preview それぞれに div 要素で囲んでそこにボーダーのスタイルを追加します。 また Editor, Preview 両方を囲む div 要素をクラス名を mycontainer として追加しました。

root.render(
  <div className="mycontainer">
    <div style={myBorderStyle}>
      <Editor initTextValue={initTextValue} onTextChange={onTextChange} />
    </div>
    <div style={myBorderStyle}>
      <Preview initTextValue={initTextValue} />
    </div>
  </div>
);

myBorderStyle は次のように設定しました。

const myBorderStyle = {
  margin: "30px",
  border: "solid 1px #333"
};

また dist/index.html で ボーダー指定していた #myeditor は不要なので削除。 さらに mycontainer のスタイルを追加します。

dist/index.html

.mycontainer {
  margin: 10px;
  padding: 5px;
  border: solid 1px #333;
}

実行してブラウザで確認。

preview 2

Editor の内容変更を Preview に反映

この段階では テキスト編集をしても、その変更内容は Preview に反映されません。

この問題に対処します。

そのためには、編集するテキストを State として管理する必要がありそうです。

src/Panel.js を追加

Panel コンポーネントを追加して、 src/index.js の内容の一部をそちらへ移動します。

src/Panel.js

import React from "react";
import Editor from "./Editor";
import Preview from "./Preview";
import {useState} from 'react';

function Panel({initTextValue=''}){
  const [textValue, setTextValue] = useState(initTextValue);

  const onTextChange = (newTextValue)=>{
    setTextValue(newTextValue)
  };

  const myBorderStyle = {
    margin: "30px",
    border: "solid 1px #333"
  };

  return (
    <div className="mycontainer">
      <div style={myBorderStyle}>
        <Editor initTextValue={initTextValue} onTextChange={onTextChange} />
      </div>
      <div style={myBorderStyle}>
        <Preview initTextValue={initTextValue} textValue={textValue} />
      </div>
    </div>
  );
}

export default Panel;

useState を使って textValue, setTextValue を状態管理を追加します。

const [textValue, setTextValue] = useState(initTextValue);

onTextChange がコールバックされたときに、今までは textarea に新しい内容 newTextValue をセットしていました。 それを setTextValue するように変更しました。

  const onTextChange = (newTextValue)=>{
    setTextValue(newTextValue)
  };

変更前は、次のように PreviewinitTextValue={initTextValue} を渡していました。

    <Preview initTextValue={initTextValue} />

これを textValue={textValue} に変更しました。

    <Preview textValue={textValue} />

src/index.js

src/index.js の内容の一部を src/Panel.js に移動したので、 それらのコードは src/index.js から削除します。 削除した結果の src/index.js は次のようになりました。

import React from "react";
import { createRoot } from 'react-dom/client';
import Panel from "./Panel";

const initTextValue = document.getElementById("mytextarea").value;

const container = document.getElementById("root");
const root = createRoot(container);
root.render(<Panel initTextValue={initTextValue} />);

src/Preview.js

src/Panel.js で textValue を渡すように変更しました。

    <Preview textValue={textValue} />

これに対応するため src/Preview.js を修正。

//function Preview( { initTextValue='' } ) {
function Preview( { textValue='' } ) {
  const initialConfig = {
    namespace: 'MyPreview',
    editable: false,
    theme,
    onError,
    editorState: () => $convertFromMarkdownString(textValue, TRANSFORMERS),
  };
  ...
}

ここまで修正したら一度実行して作動を確認します。

preview 2

先ほどと同じ結果になりました。(まだテキスト編集した結果は Preview に反映されません。)

UpdatePlugin を追加

src/Preview.js に UpdatePlugin を追加して、 Editor でのテキスト変更を反映できるようにします。

function UpdatePlugin({toTextValue}) {
  const [editor] = useLexicalComposerContext();

  editor.update(()=>{
    $convertFromMarkdownString(toTextValue(), TRANSFORMERS);
  });
}

UpdatePlugin に渡している toTextValue 関数は、適用すると現在の(最新の)テキストを返す関数です。

シグニチャを書くとこんな感じ

toTextValue :: Unit -> String

そして、この UpdatePlugin を src/Preview.js の LexicalComposer に追加します。

  return (
    <LexicalComposer initialConfig={initialConfig}>
      <PlainTextPlugin
        contentEditable={<ContentEditable style={{ outline: 'none' }} />}
        ErrorBoundary={LexicalErrorBoundary}
      />
      <UpdatePlugin toTextValue={()=>textValue} />
    </LexicalComposer>
  );

この部分ですが・・・

<UpdatePlugin toTextValue={()=>textValue} />

もし分かりにくければ、名前つきの関数 toTextValue を別途定義しても同じことです。

  const toTextValue = ()=>{
    return textValue;
  };

  return (
    <LexicalComposer initialConfig={initialConfig}>
      <PlainTextPlugin
        contentEditable={<ContentEditable style={{ outline: 'none' }} />}
        ErrorBoundary={LexicalErrorBoundary}
      />
      <UpdatePlugin toTextValue={toTextValue} />
    </LexicalComposer>
  );

これで完成です。

実行して、ブラウザ上でテキストを編集してみましょう。 内容が変化にすると即座に、マークダウン Preview に結果が反映されます。

preview 3

レンダリングできるマークアップには制限がある

ここでは、斜体のレンダリング(二行目の Hello 部分)ができたことがわかりました。 しかし、もしリストアイテムなどを記述すると意図通り変更が反映されません。

この問題は次回解決することにします。

まとめ

ここまでで完成したコードを掲載します。

現在のファイル構成は次の通りです。

.
├── babel.config.json
├── dist
│   ├── editor.js
│   └── index.html
├── node_modules/...
├── package.json
├── package-lock.json
├── src
│   ├── Editor.js
│   ├── index.js
│   ├── Panel.js
│   └── Preview.js
└── webpack.config.js

コードはそれぞれ次の通り。

dist/index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>my text editor</title>
    <style>
body {
  margin:0;
  padding:0;
}
div {
  margin:0;
  padding:0;
}
.mycontainer {
  margin: 10px;
  padding: 5px;
  border: solid 1px #333;
}
.editor-paragraph {
  padding:0;
  margin:0;
  font-size:14pt;
}
.editor-placeholder {
  position: absolute;
  top: 32px;
  left: 32px;
  user-select: none;
  pointer-events: none;
  font-size:14pt;
  color: #cccccc;
  overflow: hidden;
}
    </style>
  </head>
  <body>
    <div id="root"></div>
    <textarea id="mytextarea" style="display:block;">Hello, **World**!</textarea>
    <script src="./editor.js"></script>
  </body>
</html>

src/index.js

import React from "react";
import { createRoot } from 'react-dom/client';
import Panel from "./Panel";

const initTextValue = document.getElementById("mytextarea").value;

const container = document.getElementById("root");
const root = createRoot(container);
root.render(<Panel initTextValue={initTextValue} />);

src/Panel.js

import React from "react";
import Editor from "./Editor";
import Preview from "./Preview";
import {useState} from 'react';

function Panel({initTextValue=''}){
  const [textValue, setTextValue] = useState(initTextValue);

  const onTextChange = (newTextValue)=>{
	setTextValue(newTextValue)
  };

  const myBorderStyle = {
    margin: "30px",
    border: "solid 1px #333"
  };

  return (
    <div className="mycontainer">
      <div style={myBorderStyle}>
        <Editor initTextValue={initTextValue} onTextChange={onTextChange} />
      </div>
      <div style={myBorderStyle}>
        <Preview textValue={textValue} />
      </div>
    </div>
  );
}

export default Panel;

src/Editor.js

これは変更がないので省略。

src/Preview.js

import React from "react";
import {LexicalComposer} from '@lexical/react/LexicalComposer';
import {PlainTextPlugin} from '@lexical/react/LexicalPlainTextPlugin';
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';

import {
  $convertFromMarkdownString,
  TRANSFORMERS,
} from '@lexical/markdown';


const theme = {};

function onError(error) {
  console.error(error);
}

function UpdatePlugin({toTextValue}) {
  const [editor] = useLexicalComposerContext();

  editor.update(()=>{
    $convertFromMarkdownString(toTextValue(), TRANSFORMERS);
  });
}

function Preview( { textValue='' } ) {

  const initialConfig = {
    namespace: 'MyPreview',
    editable: false,
    theme,
    onError,
    editorState: () => $convertFromMarkdownString(textValue, TRANSFORMERS),
  };


  return (
    <LexicalComposer initialConfig={initialConfig}>
      <PlainTextPlugin
        contentEditable={<ContentEditable style={{ outline: 'none' }} />}
        ErrorBoundary={LexicalErrorBoundary}
      />
      <UpdatePlugin toTextValue={()=>textValue} />
    </LexicalComposer>
  );

  /*
  const toTextValue = ()=>{ return textValue; };

  return (
    <LexicalComposer initialConfig={initialConfig}>
      <PlainTextPlugin
        contentEditable={<ContentEditable style={{ outline: 'none' }} />}
        ErrorBoundary={LexicalErrorBoundary}
      />
      <UpdatePlugin toTextValue={toTextValue} />
    </LexicalComposer>
  );
  */
}

export default Preview;

以上です。