Open9

editorjs

nicopinnicopin

Custom plugin dev

Directory structure

.
├── dist
│   ├── textField.d.ts
│   ├── textField.es.js
│   └── textField.umd.js
├── example
│   └── index.html
├── package-lock.json
├── package.json
├── src
│   ├── styles.css
│   └── textField.ts
├── tsconfig.json
└── vite.config.ts
nicopinnicopin

vite.config.ts

import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js";

export default defineConfig({
    build: {
        lib: {
            entry: 'src/textField.ts',
            name: 'TextField',
            fileName: (format) => `textField.${format}.js`,
            formats: ['es', 'umd'],
        },
        rollupOptions: {
            external: ['@editorjs/editorjs'],
            output: {
                globals: {
                    '@editorjs/editorjs': 'EditorJS',
                },
            },
        },
    },
    plugins: [dts(), cssInjectedByJsPlugin()],
    server: {
        open: true,
    },
});

libraryにcssバンドルしたい場合にはcssInjectedByJsPluginが必要

nicopinnicopin

tsconfig.json

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "node",
    "strict": true,
    "jsx": "preserve",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "declarationDir": "./dist",
    "outDir": "./dist"
  },
  "include": ["src/**/*"]
}
nicopinnicopin

検証用

example/index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Editor.js Plugin</title>
    <script src="https://cdn.jsdelivr.net/npm/@editorjs/editorjs@latest"></script>
</head>
<body>
<div id="editorjs"></div>
<script type="module">
    import  TextField  from '../dist/textField.es.js';

    const editor = new EditorJS({
        tools: {
            textField: {
                class: TextField,
            }
        }
    });
</script>
</body>
</html>

vite build --watchでHMRもどきで対応

nicopinnicopin

onPasete, pasteConfig

Editor.js の pasteConfig に基づいてペーストイベントが処理される流れについては以下のような仕組みになっています。実際にペーストが発生した際に、パターンにマッチしたツールのコンストラクタが呼び出され、ブロックの生成と描画が行われ、その後 onPaste メソッドでペーストされたデータに基づいた処理を実行します。

処理の流れの詳細

  1. pasteConfig の評価:

    • pasteConfig は、Editor.js のエディタ全体に対してペーストされたデータがパターンにマッチするかどうかを判定するための設定です。
    • ツールがロードされた際に pasteConfig が評価され、ペーストされた内容(テキスト、URLなど)がこのパターンにマッチするかを監視します。
  2. ペーストイベントの発生:

    • ユーザーがエディタ内で何かをペーストしたとき、Editor.js はペーストされた内容をキャッチし、登録されているツールの pasteConfig に基づいて、そのペーストがどのツールに対応するかを判定します。
  3. パターンにマッチしたツールのコンストラクタ呼び出し:

    • ペーストされた内容が pasteConfig で定義されたパターンに一致した場合、そのツールのインスタンス(コンストラクタ)が生成されます。この時点で、ツールのデフォルトデータや設定が初期化されます。
    • コンストラクタが呼び出される際に、ペーストされた内容に基づく処理が行われるわけではなく、まだツールが初期化されている段階です。
  4. ツールの描画 (render):

    • コンストラクタでツールが初期化された後、render メソッドが呼び出され、ツールのブロックが実際にエディタに挿入されます。この時点で、空のブロックがエディタに描画されるか、デフォルトの状態が表示されます。
  5. onPaste イベントの発火:

    • render 後、onPaste イベントが呼び出され、ペーストされたデータが event として渡されます。ここで、ペーストされたデータを用いてブロックの内容を更新したり、プロパティを変更することができます。
nicopinnicopin

Plus in wrong place

https://github.com/codex-team/editor.js/releases/tag/v2.30.6
v2.30.6だとfirstInputを探しに行くが例えばブロック内部に設定ようのdisplay:noneが当てられたInput要素が存在するとそれを参照してしまい、getBoundingClientRect()の戻り値が全てゼロになってしまい常にトップに表示されてしまう。

src/components/modules/toolbar/index.ts
/**
   * Move Toolbar to the passed (or current) Block
   *
   * @param block - block to move Toolbar near it
   */
  public moveAndOpen(block: Block = this.Editor.BlockManager.currentBlock): void {
    console.log('moveAndOpen', block);
    /**
     * Some UI elements creates inside requestIdleCallback, so the can be not ready yet
     */
    if (this.toolboxInstance === null)  {
      _.log('Can\'t open Toolbar since Editor initialization is not finished yet', 'warn');

      return;
    }

    /**
     * Close Toolbox when we move toolbar
     */
    if (this.toolboxInstance.opened) {
      this.toolboxInstance.close();
    }

    if (this.Editor.BlockSettings.opened) {
      this.Editor.BlockSettings.close();
    }

    /**
     * If no one Block selected as a Current
     */
    if (!block) {
      return;
    }

    this.hoveredBlock = block;

    const targetBlockHolder = block.holder;
    console.log('targetBlockHolder', targetBlockHolder);
    const { isMobile } = this.Editor.UI;


    /**
     * 1. Mobile:
     *  - Toolbar at the bottom of the block
     *
     * 2. Desktop:
     *   There are two cases of a toolbar position:
     *      2.1 Toolbar is moved to the top of the block (+ padding top of the block)
     *       - when the first input is far from the top of the block, for example in Image tool
     *       - when block has no inputs
     *      2.2 Toolbar is moved to the baseline of the first input
     *       - when the first input is close to the top of the block
     */
    let toolbarY;
    const MAX_OFFSET = 20;

    /**
     * Compute first input position
     */
    const firstInput = block.firstInput;
    console.log('firstInput', firstInput);
    const targetBlockHolderRect = targetBlockHolder.getBoundingClientRect();
    console.log('targetBlockHolderRect', targetBlockHolderRect);
    const firstInputRect = firstInput !== undefined ? firstInput.getBoundingClientRect() : null;
    console.log('firstInputRect', firstInputRect);

    /**
     * Compute the offset of the first input from the top of the block
     */
    const firstInputOffset = firstInputRect !== null ? firstInputRect.top - targetBlockHolderRect.top : null;
    console.log('firstInputOffset', firstInputOffset);

    /**
     * Check if the first input is far from the top of the block
     */
    const isFirstInputFarFromTop = firstInputOffset !== null ? firstInputOffset > MAX_OFFSET : undefined;
    console.log('isFirstInputFarFromTop', isFirstInputFarFromTop);

    /**
     * Case 1.
     * On mobile — Toolbar at the bottom of Block
     */
    if (isMobile) {
      toolbarY = targetBlockHolder.offsetTop + targetBlockHolder.offsetHeight;
      console.log('isMobile', toolbarY);

    /**
     * Case 2.1
     * On Desktop — without inputs or with the first input far from the top of the block
     *            Toolbar should be moved to the top of the block
     */
    } else if (firstInput === undefined || isFirstInputFarFromTop) {
      const pluginContentOffset = parseInt(window.getComputedStyle(block.pluginsContent).paddingTop);
      console.log('pluginContentOffset', pluginContentOffset);

      const paddingTopBasedY = targetBlockHolder.offsetTop + pluginContentOffset;
      console.log('paddingTopBasedY', paddingTopBasedY);

      toolbarY = paddingTopBasedY;

    /**
     * Case 2.2
     * On Desktop — Toolbar should be moved to the baseline of the first input
     */
    } else {
      const baseline = calculateBaseline(firstInput);
      console.log('baseline', baseline);
      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
      const toolbarActionsHeight =  parseInt(window.getComputedStyle(this.nodes.plusButton!).height, 10);
      console.log('toolbarActionsHeight', toolbarActionsHeight);
      /**
       * Visual padding inside the SVG icon
       */
      const toolbarActionsPaddingBottom = 8;

      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
      const baselineBasedY = targetBlockHolder.offsetTop + baseline - toolbarActionsHeight + toolbarActionsPaddingBottom + firstInputOffset!;
      console.log('baselineBasedY', baselineBasedY);

      toolbarY = baselineBasedY;
    }

    /**
     * Move Toolbar to the Top coordinate of Block
     */
    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
    this.nodes.wrapper!.style.top = `${Math.floor(toolbarY)}px`;

    /**
     * Do not show Block Tunes Toggler near single and empty block
     */
    if (this.Editor.BlockManager.blocks.length === 1 && block.isEmpty) {
      this.blockTunesToggler.hide();
    } else {
      this.blockTunesToggler.show();
    }

    this.open();
  }