👋

Reactで、lexicalを使う

2024/08/11に公開

リッチエディターを作ってみる

リッチエディターというテキストを太字にしたり、斜体にするライブラリがあるので、試してみた。公式の通りにやっても良い感じで作れなかったので、AIを力を借りて、見た目だけは、いい感じに作ってみました。

official

こちらがリッチエディターが使えるソースコード。TypeScriptで書くと型のエラーでハマるので設定で苦労した。styled-componentで、レイアウトは整えております。

import { useState, useEffect } from 'react';
import styled, { keyframes } from 'styled-components';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { HeadingNode, $createHeadingNode } from "@lexical/rich-text";
import { $setBlocksType } from "@lexical/selection";
import { $getSelection, $isRangeSelection } from 'lexical';

// スタイル定義
const EditorContainer = styled.div`
  width: 600px;
  margin: 0 auto;
  font-family: 'Comic Sans MS', cursive;
  background-color: #ffd700;
  padding: 20px;
  border-radius: 15px;
  box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
`;

const EditorInner = styled.div`
  background: #fff;
  border: 3px dashed #ff69b4;
  border-radius: 10px;
  margin-bottom: 10px;
  overflow: hidden;
`;

const blinkingEffect = keyframes`
  0% {
    box-shadow: 0 0 10px #ff69b4;
  }
  50% {
    box-shadow: 0 0 20px #ff69b4;
  }
  100% {
    box-shadow: 0 0 10px #ff69b4;
  }
`;

const StyledContentEditable = styled(ContentEditable)`
  height: 300px;
  padding: 10px;
  overflow-y: auto;
  font-size: 16px;
  color: #333;
  &:focus {
    outline: none;
    animation: ${blinkingEffect} 1s infinite;
  }
`;

const StyledPlaceholder = styled.div`
  color: #999;
  overflow: hidden;
  position: absolute;
  top: 15px;
  left: 10px;
  user-select: none;
  pointer-events: none;
  font-style: italic;
`;

const ToolbarContainer = styled.div`
  display: flex;
  padding: 10px;
  background: linear-gradient(45deg, #1e90ff, #00bfff);
  border-radius: 10px 10px 0 0;
`;

const ToolbarButton = styled.button`
  margin-right: 5px;
  padding: 8px 12px;
  background: #ffd700;
  border: 2px solid #ff69b4;
  border-radius: 50%;
  cursor: pointer;
  font-weight: bold;
  color: #ff1493;
  transition: all 0.3s ease;

  &:hover {
    background: #ff69b4;
    color: #ffd700;
    transform: scale(1.1) rotate(5deg);
  }
`;

const SaveButton = styled.button`
  width: 100%;
  padding: 10px;
  background: #ff1493;
  color: white;
  border: none;
  border-radius: 0 0 10px 10px;
  cursor: pointer;
  font-size: 18px;
  font-weight: bold;
  text-transform: uppercase;
  letter-spacing: 2px;
  transition: all 0.3s ease;

  &:hover {
    background: #ff69b4;
    transform: scale(1.05);
  }
`;

// テーマの定義(変更なし)
const exampleTheme = {
  // ... (前回と同じ定義)
};

// ツールバーコンポーネント
const Toolbar = () => {
  const [editor] = useLexicalComposerContext();

  type TextFormatType = 'bold' | 'italic' | 'underline' | 'strikethrough';
  
  const formatText = (style: TextFormatType) => {
    editor.update(() => {
      const selection = $getSelection();
      if ($isRangeSelection(selection)) {
        selection.formatText(style);
      }
    });
  };

  const formatHeading = (level: 1 | 2 | 3 | 4 | 5) => {
    editor.update(() => {
      const selection = $getSelection();
      if ($isRangeSelection(selection)) {
        $setBlocksType(selection, () => $createHeadingNode(`h${level}`));
      }
    });
  };

  return (
    <ToolbarContainer>
      <ToolbarButton onClick={() => formatText('bold')}>B</ToolbarButton>
      <ToolbarButton onClick={() => formatText('italic')}>I</ToolbarButton>
      <ToolbarButton onClick={() => formatText('underline')}>U</ToolbarButton>
      <ToolbarButton onClick={() => formatHeading(1)}>H1</ToolbarButton>
      <ToolbarButton onClick={() => formatHeading(2)}>H2</ToolbarButton>
      <ToolbarButton onClick={() => formatHeading(3)}>H3</ToolbarButton>
    </ToolbarContainer>
  );
};

// 保存ボタンコンポーネント
const SaveButtonComponent = ({ onSave }: { onSave: (content: string) => void }) => {
  const [editor] = useLexicalComposerContext();

  const handleSave = () => {
    const editorState = editor.getEditorState();
    const json = editorState.toJSON();
    onSave(JSON.stringify(json));
  };

  return <SaveButton onClick={handleSave}>Save Content</SaveButton>;
};

// エディターコンポーネント
function Editor() {
  const [savedContent, setSavedContent] = useState<string[]>([]);

  const initialConfig = {
    namespace: 'MyEditor',
    theme: exampleTheme,
    onError: (error: Error) => console.error(error),
    nodes: [HeadingNode],
  };

  const saveContent = (content: string) => {
    const updatedContent = [...savedContent, content];
    setSavedContent(updatedContent);
    localStorage.setItem('savedContent', JSON.stringify(updatedContent));
  };

  const deleteContent = (index: number) => {
    const updatedContent = savedContent.filter((_, i) => i !== index);
    setSavedContent(updatedContent);
    localStorage.setItem('savedContent', JSON.stringify(updatedContent));
  };

  useEffect(() => {
    const storedContent = localStorage.getItem('savedContent');
    if (storedContent) {
      setSavedContent(JSON.parse(storedContent));
    }
  }, []);

  return (
    <EditorContainer>
      <LexicalComposer initialConfig={initialConfig}>
        <Toolbar />
        <EditorInner>
          <RichTextPlugin
            contentEditable={<StyledContentEditable className="content-editable" />}
            placeholder={<StyledPlaceholder>Enter some funky text...</StyledPlaceholder>}
            ErrorBoundary={LexicalErrorBoundary}
          />
          <HistoryPlugin />
          <AutoFocusPlugin />
        </EditorInner>
        <SaveButtonComponent onSave={saveContent} />
      </LexicalComposer>
      <div className="saved-content">
        <h3>Saved Content:</h3>
        {savedContent.map((content, index) => (
          <div key={index} className="saved-item">
            <div>{JSON.parse(content).root.children[0].children[0].text}</div>
            <button onClick={() => deleteContent(index)}>Delete</button>
          </div>
        ))}
      </div>
    </EditorContainer>
  );
}

export default Editor;

App.tsxでimportすれば使えます。

import Editor from "./lexical/Editor";

function App() {
  return (
    <div className="App">
      <Editor />
    </div>
  );
}

export default App;

見た目はこんな感じですね。改善が必要ですが、うまいこと使えば、microSMSのようなサービスで、ブログを自作をするときに、入力フォームで使えそう。

感想

ゆっくり勉強すればもっといいものが作れそうです。最近仕事で、Reactをやることになったので、色々とキャッチアップしております。

Discussion