Closed92

Lexicalさんはじめまして!

ピン留めされたアイテム
hajimismhajimism

公式ドキュメントを読むだけのスクラップ

hajimismhajimism
hajimismhajimism

Reactに限らず使用できるがReactのためのインターフェイスが用意されている。
サンプルコードに疑問を投げかけてみるか。

import {$getRoot, $getSelection} from 'lexical';
import {useEffect} from 'react';

import {LexicalComposer} from '@lexical/react/LexicalComposer';
import {PlainTextPlugin} from '@lexical/react/LexicalPlainTextPlugin';
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
import {OnChangePlugin} from '@lexical/react/LexicalOnChangePlugin';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';

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

// When the editor changes, you can get notified via the
// LexicalOnChangePlugin!
function onChange(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) {
  console.error(error);
}

function Editor() {
  const initialConfig = {
    namespace: 'MyEditor',
    theme,
    onError,
  };

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

$つきの関数が印象的

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

plugin多いな〜。明示的でplugableなのはありがたいところ。

hajimismhajimism

たしかにComponentの形を取ることで使いやすくなる側面もありそう。Conditional renderingとかできるし。

// 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.
  return (
    <LexicalComposer initialConfig={initialConfig}>
      <PlainTextPlugin
        contentEditable={<ContentEditable />}
        placeholder={<div>Enter some text...</div>}
        ErrorBoundary={LexicalErrorBoundary}
      />
      <OnChangePlugin onChange={onChange} />
      <HistoryPlugin />
      <MyCustomAutoFocusPlugin />
    </LexicalComposer>
  );
hajimismhajimism

てか onChange までpluginなんか。初期状態はめちゃめちゃ薄いんだな。textareaのinterfaceを継承しているわけではない?

hajimismhajimism

自然言語の説明に戻る。

An Editor State is the underlying data model that represents what you want to show on the DOM.

Editor Stateは仮想DOM的なあれ?

hajimismhajimism

Editor States are also fully serializable to JSON and can easily be serialized back into the editor using editor.parseEditorState().

Editor StateはJSONと行き来できる。

hajimismhajimism

Lexical has its own DOM reconciler that takes a set of Editor States (always the "current" and the "pending") and applies a "diff" on them. It then uses this diff to update only the parts of the DOM that need changing. You can think of this as a kind-of virtual DOM, except Lexical is able to skip doing much of the diffing work, as it knows what was mutated in a given update.

まじでa kind-of virtual DOMやったわ

hajimismhajimism

Commands are the communication system used to wire everything together in Lexical. Custom commands can be created using createCommand() and dispatched to an editor using editor.dispatchCommand(command, payload). Lexical dispatches commands internally when key  presses are triggered and when other important signals occur. Commands can also be handled using editor.registerCommand(handler, priority), and incoming commands are propagated through all handlers by priority until a handler stops the propagation (in a similar way to event propagation in the browser).

操作をCommandとして登録して、editor.dispatchCommand(command, payload)を行うことで操作するらしい。これにも既視感ありますなあ。

hajimismhajimism

あ、ここまではReactでLexicalを使用するときの説明だったらしい。だからReactに寄せているのか。

hajimismhajimism

こういうときまず触ってみるのとドキュメント読むのと迷うけど、長期的に見たらドキュメントじっくり読んでからの方が良い気がしているのでまず読む
https://lexical.dev/docs/intro

hajimismhajimism

The core package of Lexical is only 22kb in file size (min+gzip) and you only ever pay the cost for what you need. So Lexical can grow with your surface and the requirements. Furthermore, in frameworks that support lazy-loading, you can defer Lexical plugins until the user actually interacts with the editor itself – which can greatly help improve performance.

パフォーマンスにはかなり気を使っているみたい。WYSIWIGって重いがちやもんな。

hajimismhajimism

At Meta, Lexical powers web text editing experiences for hundreds of millions of users everyday across Facebook, Workplace, Messenger, WhatsApp and Instagram.

ほーん、Meta製のアプリではLexical的なものが使われていると。

hajimismhajimism

https://lexical.dev/docs/getting-started/theming

Lexical tries to make theming straight-forward, by providing a way of passing a customizable theming object that maps CSS class names to the editor on creation. Here's an example of a plain-text theme:

className割当を行うことでスタイルへのアクセスを提供しているみたい

hajimismhajimism

こんなかんじ

const exampleTheme = {
  ltr: 'ltr',
  rtl: 'rtl',
  placeholder: 'editor-placeholder',
  paragraph: 'editor-paragraph',
  quote: 'editor-quote',
  heading: {
    h1: 'editor-heading-h1',
    h2: 'editor-heading-h2',
    h3: 'editor-heading-h3',
    h4: 'editor-heading-h4',
    h5: 'editor-heading-h5',
    h6: 'editor-heading-h6',
  },
  list: {
    nested: {
      listitem: 'editor-nested-listitem',
    },
    ol: 'editor-list-ol',
    ul: 'editor-list-ul',
    listitem: 'editor-listItem',
    listitemChecked: 'editor-listItemChecked',
    listitemUnchecked: 'editor-listItemUnchecked',
  },
  hashtag: 'editor-hashtag',
  image: 'editor-image',
  link: 'editor-link',
  text: {
    bold: 'editor-textBold',
    code: 'editor-textCode',
    italic: 'editor-textItalic',
    strikethrough: 'editor-textStrikethrough',
    subscript: 'editor-textSubscript',
    superscript: 'editor-textSuperscript',
    underline: 'editor-textUnderline',
    underlineStrikethrough: 'editor-textUnderlineStrikethrough',
  },
  code: 'editor-code',
  codeHighlight: {
    atrule: 'editor-tokenAttr',
    attr: 'editor-tokenAttr',
    boolean: 'editor-tokenProperty',
    builtin: 'editor-tokenSelector',
    cdata: 'editor-tokenComment',
    char: 'editor-tokenSelector',
    class: 'editor-tokenFunction',
    'class-name': 'editor-tokenFunction',
    comment: 'editor-tokenComment',
    constant: 'editor-tokenProperty',
    deleted: 'editor-tokenProperty',
    doctype: 'editor-tokenComment',
    entity: 'editor-tokenOperator',
    function: 'editor-tokenFunction',
    important: 'editor-tokenVariable',
    inserted: 'editor-tokenSelector',
    keyword: 'editor-tokenAttr',
    namespace: 'editor-tokenVariable',
    number: 'editor-tokenProperty',
    operator: 'editor-tokenOperator',
    prolog: 'editor-tokenComment',
    property: 'editor-tokenProperty',
    punctuation: 'editor-tokenPunctuation',
    regex: 'editor-tokenVariable',
    selector: 'editor-tokenSelector',
    string: 'editor-tokenSelector',
    symbol: 'editor-tokenProperty',
    tag: 'editor-tokenProperty',
    url: 'editor-tokenOperator',
    variable: 'editor-tokenVariable',
  },
};
hajimismhajimism

Concept

いちばん大事なところ

https://lexical.dev/docs/concepts/editor-state

hajimismhajimism

After an update, the editor state is then locked and deemed immutable from there on. This editor state can therefore be thought of as a "snapshot".

snapshotてまんまReactと同じやん

hajimismhajimism

Editor states contain two core things:

  • The editor node tree (starting from the root node).
  • The editor selection (which can be null).

node treeとselectionがcoreらしい。
というかそれ以外の要素はpluginで提供しているよってことかな?

hajimismhajimism
  <Button label="Save" onPress={() => {
    if (editorStateRef.current) {
      saveContent(JSON.stringify(editorStateRef.current))
    }
  }} />

できればmdでも保存したいな。

hajimismhajimism

...this means that Lexical leverages a technique called double-buffering during updates.

double-bufferingだけでもいい検索ワードになりそう

hajimismhajimism

updateListenerをこれで設定できるらしい

editor.registerUpdateListener(({editorState}) => {
  // The latest EditorState can be found as `editorState`.
  // To read the contents of the EditorState, use the following API:

  editorState.read(() => {
    // Just like editor.update(), .read() expects a closure where you can use
    // the $ prefixed helper functions.
  });
});
hajimismhajimism

To avoid selection issues, Lexical forbids insertion of text nodes directly into a RootNode.

RootNodeは他のノードと明確に区別されている。

hajimismhajimism

You should never have '\n' in your text nodes, instead you should use the LineBreakNode which represents '\n', and more importantly, can work consistently between browsers and operating systems.

改行ノード

hajimismhajimism

ElementNodeの一部にTextNodeが入ってくるイメージかな。

特殊要素を作るときはDecoratorNodeを使う。

hajimismhajimism

It's important that these properties are JSON serializable too, so you should never be assigning a property to a node that is a function, Symbol, Map, Set, or any other object that has a different prototype than the built-ins.

NodeのpropertyにはJSON serializableを保つための制限がある。

hajimismhajimism

By convention, we prefix properties with __ (double underscore) so that it makes it clear that these properties are private and their access should be avoided directly. We opted for __ instead of _ because of the fact that some build tooling mangles and minifies single _ prefixed properties to improve code size. However, this breaks down if you're exposing a node to be extended outside of your build.

propertiesには__のprefix

hajimismhajimism

カスタムノードにはgetType(), clone(), set*(), get*()が必要。

class MyCustomNode extends SomeOtherNode {
  __foo: string;

  static getType(): string {
    return 'custom-node';
  }

  static clone(node: MyCustomNode): MyCustomNode {
    return new MyCustomNode(node.__foo, node.__key);
  }

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

  setFoo(foo: string) {
    // getWritable() creates a clone of the node
    // if needed, to ensure we don't try and mutate
    // a stale version of this node.
    const self = this.getWritable();
    self.__foo = foo;
  }

  getFoo(): string {
    // getLatest() ensures we are getting the most
    // up-to-date value from the EditorState.
    const self = this.getLatest();
    return self.__foo;
  }
}
hajimismhajimism

エチケットらしい

export function $createCustomParagraphNode(): ParagraphNode {
  return new CustomParagraph();
}

export function $isCustomParagraphNode(node: ?LexicalNode): boolean {
  return node instanceof CustomParagraph;
}
hajimismhajimism
hajimismhajimism
editor.registerUpdateListener(({editorState}) => {
  // Read the editorState and maybe get some value.
  editorState.read(() => {
    // ...
  });

  // Then schedule another update.
  editor.update(() => {
    // ...
  });
});

このパターンは余計なDOM更新をしているからやめろと

hajimismhajimism

色んな種類のListenerがあるな

  • registerUpdateListener: LexicalがDOMの変更をしたときの通知
  • registerTextContentListener: DOMの変更をしてText Contentに差分があったときの通知
  • registerMutationListener: 特定のNodeが created / destroyed / updated されたときの通知
  • registerEditableListener: editor.setEditable(boolean)が走ったときの通知
  • registerDecoratorListener: decorator objectに変更があったときの通知
  • registerRootListener: root要素に変更があったときの通知
hajimismhajimism
hajimismhajimism

For example: User types a character and you want to color the word blue if the word is now equal to "congrats". We programmatically add an @Mention to the editor, the @Mention is immediately next to another @Mention (@Mention@Mention). Since we believe this makes mentions hard to read, we want to destroy/replace both mentions and render them as plain TextNode's instead.

ん?話の流れが掴めない。例もめちゃくちゃじゃない?

const removeTransform = editor.registerNodeTransform(TextNode, (textNode) => {
  if (textNode.getTextContent() === 'blue') {
    textNode.setTextContent('green');
  }
});
hajimismhajimism

updateListenerの中でupdateしても似たような結果にはなるけど、DOMの更新が2回行われてしまうのでやるなと。ハイコストだしHistoryに干渉する可能性があると。

editor.registerUpdateListener(() => {
  editor.update(() => {
    // Don't do this
  });
});
hajimismhajimism

Transforms are designed to run when nodes have been modified (aka marking nodes dirty). For the most part, transforms only need to run once after the update but the sequential nature of transforms makes it possible to have order bias. Hence, transforms are run over and over until this particular type of Node is no longer marked as dirty by any of the transforms.

Transformはどいつも反応しなくなるまで何周もするらしいな。

いい例が載ってる。

// Plugin 1
editor.registerNodeTransform(TextNode, textNode => {
  // This transform runs twice but does nothing the first time because it doesn't meet the preconditions
  if (textNode.getTextContent() === 'modified') {
    textNode.setTextContent('re-modified');
  }
})
// Plugin 2
editor.registerNodeTransform(TextNode, textNode => {
  // This transform runs only once
  if (textNode.getTextContent() === 'original') {
    textNode.setTextContent('modified');
  }
})
// App
editor.addListener('update', ({editorState}) => {
  const text = editorState.read($textContent);
  // text === 're-modified'
});
hajimismhajimism
hajimismhajimism

RangeSelection

This is the most common type of selection, and is a normalization of the browser's DOM Selection and Range APIs.

カーソル引っ張ってできるあれ。

RangeSelection consists of three main properties:

  • anchor representing a RangeSelection point
  • focus representing a RangeSelection point
  • format numeric bitwise flag, representing any active text formats

なぜanchorとfocusの2つあるんだろう。同じ内容ではない?

The main properties of a RangeSelection point are:

  • key representing the NodeKey of the selected Lexical node
  • offset representing the position from within its selected Lexical node. For the text type this is the character, and for the element type this is the child index from within the ElementNode
  • type representing either element or text.

ElementNodeのときとTextNodeのときでだいぶ違うな

hajimismhajimism

NodeSelection

NodeSelection represents a selection of multiple arbitrary nodes.

複数のNodeにまたがる選択。getNodes()で内容を取れるらしい。

hajimismhajimism

GridSelection

Tableっぽいものを選択するときの規格らしい。

For example, a table where you select row = 1 col = 1 to row 2 col = 2 could be stored as follows:

  • gridKey = 2 table key
  • anchor = 4 table cell (key may vary)
  • focus = 10 table cell (key may vary)

どうしてfocusが10になるのかイマイチわからない

hajimismhajimism

null

選択範囲が無い or Lexicalで取り扱えないものを選択している状態

hajimismhajimism

Selectionは$getSelection()でとってこれるらしい。これはオブジェクトがとれるのかな?

hajimismhajimism

むりくり選択範囲を作る例

import {$setSelection, $createRangeSelection, $createNodeSelection} from 'lexical';

editor.update(() => {
  // Set a range selection
  const rangeSelection = $createRangeSelection();
  $setSelection(rangeSelection);

  // You can also indirectly create a range selection, by calling some of the selection
  // methods on Lexical nodes.
  const someNode = $getNodeByKey(someKey);

  // On element nodes, this will create a RangeSelection with type "element",
  // referencing an offset relating to the child within the element.
  // On text nodes, this will create a RangeSelection with type "text",
  // referencing the text character offset.
  someNode.select();
  someNode.selectPrevious();
  someNode.selectNext();

  // On element nodes, you can use these.
  someNode.selectStart();
  someNode.selectEnd();

  // Set a node selection
  const nodeSelection = $createNodeSelection();
  // Add a node key to the selection.
  nodeSelection.add(someKey);
  $setSelection(nodeSelection);

  // You can also clear selection by setting it to `null`.
  $setSelection(null);
});
hajimismhajimism
hajimismhajimism

そもそもシリアライズってなんやねん、と思う
調べたら出てきた
https://qiita.com/allein-s/items/c125af381600c5b7fa28

結局Wikiの言葉に腹落ちしたらしい?

直列化(ちょくれつか)は、オブジェクト指向プログラミングにおいて使われる用語で、ある環境において存在しているオブジェクトを、バイト列やXMLフォーマットに変換することをいう。

hajimismhajimism

インターネット言論の最大公約数ことChatGPTさんに聞いても同じような答えだった

シリアライズ (serialize) とは、IT用語の1つで、あるデータ構造やオブジェクトを、連続したバイト列などの形式に変換することを指します。

例えば、プログラム内で扱っているオブジェクトをファイルやネットワーク越しに別のプログラムやシステムに渡す場合、オブジェクトそのままでは扱えないことがあります。そのため、オブジェクトをシリアライズして、バイト列の形式に変換することで、別のプログラムやシステムでも扱えるようにします。

また、逆に、シリアライズされたバイト列を元のオブジェクトに戻すことをデシリアライズ (deserialize) と呼びます。データの永続化や通信などで広く使用されている概念です。

hajimismhajimism

戻る。

we also offer generic utilities for converting Lexical -> HTML and HTML -> Lexical in our @lexical/html package.

HTML <=> Lexicalは提供されているらしい。Nodeごととかもできそう。

hajimismhajimism

It's important to note that you should avoid making breaking changes to existing fields in your JSON object, especially if backwards compatibility is an important part of your editor. That's why we recommend using a version field to separate the different changes in your node as you add or change functionality of custom nodes.

カスタムノードにはバージョニングしとけと。たしかに。

hajimismhajimism
hajimismhajimism

For instance, you might want to to show a popover when a user mouses over a specific node or open a modal when they click on a node.

こういうときの、ネイティブのDOMイベントとの紐付けについて。

hajimismhajimism

まずはrootListenerにアタッチしておくことが考えられる。たしかに。

function myListener(event) {
    // You may want to filter on the event target here
    // to only include clicks on certain types of DOM Nodes.
    alert('Nice!');
}

const removeRootListener = editor.registerRootListener((rootElement, prevRootElement) => {
    // add the listener to the current root element
    rootElement.addEventListener('click', myListener);
    // remove the listener from the old root element - make sure the ref to myListener
    // is stable so the removal works and you avoid a memory leak.
    prevRootElement.removeEventListener('click', myListener);
});

// teardown the listener - return this from your useEffect callback if you're using React.
removeRootListener();
hajimismhajimism

特定のNodeに直接アタッチすることもできる。こっちのほうが普通な感じする。

const removeMutationListener = editor.registerMutationListener(nodeType, (mutations) => {
    const registeredElements: WeakSet<HTMLElement> = new WeakSet();
    editor.getEditorState().read(() => {
        for (const [key, mutation] of mutations) {
            const element: null | HTMLElement = editor.getElementByKey(key);
            if (
            // Updated might be a move, so that might mean a new DOM element
            // is created. In this case, we need to add and event listener too.
            (mutation === 'created' || mutation === 'updated') &&
            element !== null &&
            !registeredElements.has(element)
            ) {
                registeredElements.add(element);
                element.addEventListener('click', (event: Event) => {
                    alert('Nice!');
                });
            }
        }
    });
});

// teardown the listener - return this from your useEffect callback if you're using React.
removeMutationListener();
hajimismhajimism

↑Reactだったらこれでできるらしい。あざす。

<LexicalComposer>
    <NodeEventPlugin
        nodeType={LinkNode}
        eventType={'click'}
        eventListener={(e: Event) => {
            alert('Nice!');
        }}
    />
</LexicalComposer>
hajimismhajimism

Packages

めちゃあるな

このスクラップは2023/05/01にクローズされました