Closed45

Lexical 触ってみるぞ

hajimismhajimism

Setup

Next.js appDirにて。Storybookの動作とかも見たいのでいろいろはいってるテンプレートを使う。
https://github.com/hajimism/windy-radix-template

npm installののちに、READMEの"an example of a basic plain text editor"とやらを適当なファイルに貼り付け。appDirで使うので"use client"を書く。

"use client";

import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
import { $getRoot, $getSelection, EditorState } from "lexical";
import { useEffect } from "react";

const theme = {
  // Theme styling goes here
  // ...
};

// When the editor changes, you can get notified via the
// LexicalOnChangePlugin!
function onChange(editorState: EditorState) {
  editorState.read(() => {
    // Read the contents of the EditorState here.
    const root = $getRoot();
    const selection = $getSelection();

    console.log(root, selection);
  });
}

// Lexical React plugins are React components, which makes them
// highly composable. Furthermore, you can lazy load plugins if
// desired, so you don't pay the cost for plugins until you
// actually use them.
function MyCustomAutoFocusPlugin() {
  const [editor] = useLexicalComposerContext();

  useEffect(() => {
    // Focus the editor when the effect fires!
    editor.focus();
  }, [editor]);

  return null;
}

// Catch any errors that occur during Lexical updates and log them
// or throw them as needed. If you don't throw them, Lexical will
// try to recover gracefully without losing user data.
function onError(error: any) {
  console.error(error);
}

const initialConfig = {
  namespace: "MyEditor",
  theme,
  onError,
};

export function Editor() {
  return (
    <LexicalComposer initialConfig={initialConfig}>
      <PlainTextPlugin
        contentEditable={<ContentEditable />}
        placeholder={<div>Enter some text...</div>}
        ErrorBoundary={LexicalErrorBoundary}
      />
      <OnChangePlugin onChange={onChange} />
      <HistoryPlugin />
      <MyCustomAutoFocusPlugin />
    </LexicalComposer>
  );
}
hajimismhajimism

こんな感じ。helper textが下にあるのが紛らわしいけど一応動く。

hajimismhajimism

Theme切り替えもいけるな...。文字色の設定をどこかでした覚えはないのだが、

hajimismhajimism

こういうコードで

  <div className="py-20 px-6 max-w-2xl mx-auto">
      <ThemeSwitcher />
      <Editor />
      <p>こんにちは</p>
    </div>
  );

こうなったから

たぶんTailwindが勝手にglobalでやっているのだと思う。

hajimismhajimism

"hello world"と入力してworldを選択した状態のRootNodeとSelection

hajimismhajimism

PlainTextPluginの代わりにRichTextPluginにしたら⌘Bで太字ができるようになった

hajimismhajimism

HistoryPluginが入っているからか、⌘Zで太字を戻すことができる

hajimismhajimism

mdを追加しようと思って以下のようにした。

      <MarkdownShortcutPlugin transformers={TRANSFORMERS} />

するとerrorが出る。

Error: MarkdownShortcuts: missing dependency for transformer. Ensure node dependency is included in editor initial config.
hajimismhajimism

このnodesinitialConfigにわたすことで解決した。import散らばっててややこしすぎる。

node.ts
import { CodeNode, CodeHighlightNode } from "@lexical/code";
import { AutoLinkNode, LinkNode } from "@lexical/link";
import { ListNode, ListItemNode } from "@lexical/list";
import { HeadingNode, QuoteNode } from "@lexical/rich-text";
import { Klass, LexicalNode } from "lexical";

export const nodes: Array<Klass<LexicalNode>> = [
  HeadingNode,
  ListNode,
  ListItemNode,
  QuoteNode,
  CodeNode,
  CodeHighlightNode,
  AutoLinkNode,
  LinkNode,
];

適当なglobal styleを当てるとmd editorな書き方でこんなふうにできた。

hajimismhajimism

ContentEditableclassNameを渡すことができた。

     <RichTextPlugin
        contentEditable={<ContentEditable className="p-4 min-h-[30rem]" />}
        placeholder={<div>Enter some text...</div>}
        ErrorBoundary={LexicalErrorBoundary}
      />

hajimismhajimism

適当なwraperをComposerの中に置いて、placeholderのstyleをいじったらいい感じの配置になった。

    <LexicalComposer initialConfig={initialConfig}>
      <div className="relative">
        <RichTextPlugin
          contentEditable={<ContentEditable className="p-6 min-h-[30rem]" />}
          placeholder={
            <div className="absolute pt-[2px] pl-1 top-6 left-6 pointer-events-none select-none">
              Enter some text...
            </div>
          }
          ErrorBoundary={LexicalErrorBoundary}
        />
        <OnChangePlugin onChange={onChange} />
        <HistoryPlugin />
        <AutoFocusPlugin />
        <MyCustomAutoFocusPlugin />
        <MarkdownShortcutPlugin transformers={TRANSFORMERS} />
      </div>
    </LexicalComposer>

hajimismhajimism

style をglobalに当ててたけど、themeに移すことができた。

theme.ts
import { EditorThemeClasses } from "lexical";

export const theme: EditorThemeClasses = {
  heading: {
    h1: "scroll-m-20 mt-12 mb-6 text-4xl font-extrabold tracking-tight lg:text-5xl [&:nth-child(1)]:mt-0",
    h2: "scroll-m-20 border-b mt-8 mb-2 pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0",
    h3: "scroll-m-20 my-4 text-2xl font-semibold tracking-tight",
    h4: "scroll-m-20 my-3 text-xl font-semibold tracking-tight",
  },
  paragraph: "leading-7 [&:not(:first-child)]:mt-6",
  quote: "mt-6 border-l-2 pl-6",
  list: {
    ul: "my-6 ml-6 list-disc [&>li]:mt-2",
    ol: "my-6 ml-6 list-decimal [&>li]:mt-2",
  },
  code: "relative rounded bg-sage-3 px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold",
};

hajimismhajimism

import文をなるべく整理したいので、1つのpluginに対して複数importあるやつはファイルを分割してもいいかもしれない。

markdown.tsx
import { TRANSFORMERS } from "@lexical/markdown";
import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin";

export const MarkdownPlugin = () => (
  <MarkdownShortcutPlugin transformers={TRANSFORMERS} />
);

hajimismhajimism

賛否分かれそう

richtext.tsx
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
import { RichTextPlugin as RichTextPluginPrimitive } from "@lexical/react/LexicalRichTextPlugin";

export const RichTextPlugin = () => (
  <RichTextPluginPrimitive
    contentEditable={<ContentEditable className="p-6 min-h-[30rem]" />}
    placeholder={
      <div className="absolute pt-[2px] pl-1 top-6 left-6 pointer-events-none select-none">
        Enter some text...
      </div>
    }
    ErrorBoundary={LexicalErrorBoundary}
  />
);

hajimismhajimism

だいぶ整理されてはいる

/Editor/index.tsx
"use client";

import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import { $getRoot, $getSelection, EditorState } from "lexical";

import { nodes } from "./node";
import { MarkdownPlugin } from "./plugins/markdown";
import { RichTextPlugin } from "./plugins/richtext";
import { theme } from "./theme";

// When the editor changes, you can get notified via the
// LexicalOnChangePlugin!
function onChange(editorState: EditorState) {
  editorState.read(() => {
    // Read the contents of the EditorState here.
    const root = $getRoot();
    const selection = $getSelection();

    console.log(root, selection);
  });
}

const initialConfig = {
  namespace: "MyEditor",
  theme,
  onError: (error: any) => console.error(error),
  nodes,
};

export function Editor() {
  return (
    <LexicalComposer initialConfig={initialConfig}>
      <div className="relative">
        <RichTextPlugin />
        <OnChangePlugin onChange={onChange} />
        <HistoryPlugin />
        <AutoFocusPlugin />
        <MarkdownPlugin />
      </div>
    </LexicalComposer>
  );
}

hajimismhajimism

List/CheckListがあったので入れた。

        <ListPlugin />
        <CheckListPlugin />

Tab/Shift Tabでネストはまだできない。
リストに文字を入力せず改行することでリストから抜けることはできる。

hajimismhajimism

リストを触ってるとスタイルが気になり始めたので微調整した。

import { EditorThemeClasses } from "lexical";

export const theme: EditorThemeClasses = {
  heading: {
    h1: "scroll-m-20 mt-12 mb-6 text-4xl font-extrabold tracking-tight lg:text-5xl [&:nth-child(1)]:mt-0",
    h2: "scroll-m-20 border-b mt-8 mb-2 pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0",
    h3: "scroll-m-20 my-4 text-2xl font-semibold tracking-tight",
    h4: "scroll-m-20 my-3 text-xl font-semibold tracking-tight",
  },
  paragraph: "leading-7 [&:not(:first-child)]:mt-2",
  quote: "mt-2 border-l-2 pl-6",
  list: {
    ul: "my-2 ml-6 list-disc [&>li]:mt-2 [&:first-child]:mt-0",
    ol: "my-2 ml-6 list-decimal [&>li]:mt-2 [&:first-child]:mt-0",
    listitem: "[&:first-child]:mt-0",
  },
  code: "relative rounded bg-sage-3 px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold",
};

hajimismhajimism

一旦わからん

  • Tabを押すとlistのnestではなく、他の要素へfocusが移動してしまう
  • CheckListが出てこない
hajimismhajimism

CodeHighlight
すてぃんさんの記事を読みながら。

公式Plugin用意されてないらしい?ありがたく記事内容を貼らせてもらう。

import { FC, useEffect } from "react";
import { registerCodeHighlighting } from "@lexical/code";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";

export const CodeHighlightPlugin: FC = () => {
  const [editor] = useLexicalComposerContext();

  useEffect(() => {
    return registerCodeHighlighting(editor);
  }, [editor]);

  return null;
};
hajimismhajimism

まてよ、そもそもcodeのスタイルがびみょいぞ

themeはちゃんとしている気がするのだが

  code: "relative rounded bg-sage-3 px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold",

hajimismhajimism

tailwind直スタイリングはいけるな

  code {
    @apply relative rounded bg-sage-3 text-green-9 px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold;
  }

hajimismhajimism

とりあえず例に習ってlexicalのコード真似してみたけどうまくスタイリングされてない。

.code {
  background-color: rgb(240, 242, 245);
  font-family: Menlo, Consolas, Monaco, monospace;
  display: block;
  padding: 8px 8px 8px 52px;
  line-height: 1.53;
  font-size: 13px;
  margin: 0;
  margin-top: 8px;
  margin-bottom: 8px;
  tab-size: 2;
  /* white-space: pre; */
  overflow-x: auto;
  position: relative;
}
.code:before {
  content: attr(data-gutter);
  position: absolute;
  background-color: #eee;
  left: 0;
  top: 0;
  border-right: 1px solid #ccc;
  padding: 8px;
  color: #777;
  white-space: pre-wrap;
  text-align: right;
  min-width: 25px;
}
.tokenComment {
  color: slategray;
}
.tokenPunctuation {
  color: #999;
}
.tokenProperty {
  color: #905;
}
.tokenSelector {
  color: #690;
}
.tokenOperator {
  color: #9a6e3a;
}
.tokenAttr {
  color: #07a;
}
.tokenVariable {
  color: #e90;
}
.tokenFunction {
  color: #dd4a68;
}

code: styles.code,
  codeHighlight: {
    atrule: styles.tokenAttr,
    attr: styles.tokenAttr,
    boolean: styles.tokenProperty,
    builtin: styles.tokenSelector,
    cdata: styles.tokenComment,
    char: styles.tokenSelector,
    class: styles.tokenFunction,
    "class-name": styles.tokenFunction,
    comment: styles.tokenComment,
    constant: styles.tokenProperty,
    deleted: styles.tokenProperty,
    doctype: styles.tokenComment,
    entity: styles.tokenOperator,
    function: styles.tokenFunction,
    important: styles.tokenVariable,
    inserted: styles.tokenSelector,
    keyword: styles.tokenAttr,
    namespace: styles.tokenVariable,
    number: styles.tokenProperty,
    operator: styles.tokenOperator,
    prolog: styles.tokenComment,
    property: styles.tokenProperty,
    punctuation: styles.tokenPunctuation,
    regex: styles.tokenVariable,
    selector: styles.tokenSelector,
    string: styles.tokenSelector,
    symbol: styles.tokenProperty,
    tag: styles.tokenProperty,
    url: styles.tokenOperator,
    variable: styles.tokenVariable,
hajimismhajimism

inline codeのstyleはこっちでした

  text: {
    code: "relative rounded bg-sage-3 text-green-9 px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold;",
  },
hajimismhajimism

AutoLinkを導入する

pugins/auto-link.tsx
import { AutoLinkPlugin as AutoLinkPluginPrimitive } from "@lexical/react/LexicalAutoLinkPlugin";

const URL_MATCHER =
  /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/;

const EMAIL_MATCHER =
  /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/;

const MATCHERS = [
  (text: string) => {
    const match = URL_MATCHER.exec(text);
    if (match === null) {
      return null;
    }
    const fullMatch = match[0];
    return {
      index: match.index,
      length: fullMatch.length,
      text: fullMatch,
      url: fullMatch.startsWith("http") ? fullMatch : `https://${fullMatch}`,
    };
  },
  (text: string) => {
    const match = EMAIL_MATCHER.exec(text);
    return (
      match && {
        index: match.index,
        length: match[0].length,
        text: match[0],
        url: `mailto:${match[0]}`,
      }
    );
  },
];

export const AutoLinkPlugin = () => (
  <AutoLinkPluginPrimitive matchers={MATCHERS} />
);

a tagになってるけどクリックしてジャンプできない

hajimismhajimism

「文字列を選択してlinkをpasteしたらlink付きテキストになる」ができてない

hajimismhajimism

別スクラップに分割してやりたいこと一覧

  • lexical-playgrounのコードリーディングしてPlugin探求
  • 理想のlink体験
  • リロードしてもテキストが残るようにStateの永続化
  • syntax highlightを全部Tailwindで書いてみる
  • Notionみたいに/を押したらコマンドパレット出現
  • 美しいTypographyのスタイリングを学ぶためにNotion Typography分析
このスクラップは2023/05/03にクローズされました