Open9
editorjs

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

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
が必要

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/**/*"]
}

検証用
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もどきで対応

Change tool name

onPasete, pasteConfig
Editor.js の pasteConfig
に基づいてペーストイベントが処理される流れについては以下のような仕組みになっています。実際にペーストが発生した際に、パターンにマッチしたツールのコンストラクタが呼び出され、ブロックの生成と描画が行われ、その後 onPaste
メソッドでペーストされたデータに基づいた処理を実行します。
処理の流れの詳細
-
pasteConfig
の評価:-
pasteConfig
は、Editor.js のエディタ全体に対してペーストされたデータがパターンにマッチするかどうかを判定するための設定です。 - ツールがロードされた際に
pasteConfig
が評価され、ペーストされた内容(テキスト、URLなど)がこのパターンにマッチするかを監視します。
-
-
ペーストイベントの発生:
- ユーザーがエディタ内で何かをペーストしたとき、Editor.js はペーストされた内容をキャッチし、登録されているツールの
pasteConfig
に基づいて、そのペーストがどのツールに対応するかを判定します。
- ユーザーがエディタ内で何かをペーストしたとき、Editor.js はペーストされた内容をキャッチし、登録されているツールの
-
パターンにマッチしたツールのコンストラクタ呼び出し:
- ペーストされた内容が
pasteConfig
で定義されたパターンに一致した場合、そのツールのインスタンス(コンストラクタ)が生成されます。この時点で、ツールのデフォルトデータや設定が初期化されます。 - コンストラクタが呼び出される際に、ペーストされた内容に基づく処理が行われるわけではなく、まだツールが初期化されている段階です。
- ペーストされた内容が
-
ツールの描画 (
render
):- コンストラクタでツールが初期化された後、
render
メソッドが呼び出され、ツールのブロックが実際にエディタに挿入されます。この時点で、空のブロックがエディタに描画されるか、デフォルトの状態が表示されます。
- コンストラクタでツールが初期化された後、
-
onPaste
イベントの発火:-
render
後、onPaste
イベントが呼び出され、ペーストされたデータがevent
として渡されます。ここで、ペーストされたデータを用いてブロックの内容を更新したり、プロパティを変更することができます。
-

Block Conversion

Plus in wrong place
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();
}