Style update and add plugin support

This commit is contained in:
2025-04-23 19:53:15 -07:00
parent 0222d25a07
commit 544a1614a0
7 changed files with 889 additions and 158 deletions

View File

@@ -1,106 +1,10 @@
import { useEffect, useRef, useState } from 'react'
import './App.css'
import EditorJS, { OutputData } from '@editorjs/editorjs'
import EditorJSWrapper from './EditorJSWrapper';
import { OutputData, BlockTool } from '@editorjs/editorjs';
class EditorJSWrapper {
private editor: EditorJS | null = null;
private holder: HTMLDivElement | null = null;
private onChange!: (data: OutputData) => void;
private static instance: EditorJSWrapper | null = null;
private currentData: OutputData = {
time: Date.now(),
blocks: [],
version: '2.28.2'
};
constructor(onChange: (data: OutputData) => void) {
if (EditorJSWrapper.instance) {
return EditorJSWrapper.instance;
}
this.onChange = onChange;
EditorJSWrapper.instance = this;
}
initialize(holder: HTMLDivElement) {
if (this.editor || !holder) return;
// Ensure any existing instance is destroyed first
if (EditorJSWrapper.instance?.editor) {
void EditorJSWrapper.instance.destroy();
}
this.holder = holder;
try {
this.editor = new EditorJS({
holder: holder,
placeholder: 'Start writing...',
data: this.currentData,
onChange: async () => {
await this.syncState();
}
});
} catch (error) {
console.error('Error initializing editor:', error);
}
}
private async syncState() {
if (!this.editor) return;
try {
this.currentData = await this.editor.save();
this.onChange(this.currentData);
} catch (err) {
console.error('Error syncing state:', err);
}
}
async addBlock(text: string) {
if (!this.editor) return;
try {
await this.editor.blocks.insert('paragraph', { text });
await this.syncState();
} catch (err) {
console.error('Error adding block:', err);
}
}
async clear() {
if (!this.editor) return;
try {
await this.editor.clear();
this.currentData = {
time: Date.now(),
blocks: [],
version: '2.28.2'
};
this.onChange(this.currentData);
} catch (err) {
console.error('Error clearing editor:', err);
}
}
async getData(): Promise<OutputData> {
return this.currentData;
}
async destroy() {
try {
if (this.editor && typeof this.editor.destroy === 'function') {
await this.editor.destroy();
this.editor = null;
this.holder = null;
this.currentData = {
time: Date.now(),
blocks: [],
version: '2.28.2'
};
EditorJSWrapper.instance = null;
}
} catch (error: unknown) {
console.error('Error destroying editor:', error);
}
}
type ToolWithRender = BlockTool & {
renderNewElementTile: () => React.ReactElement;
}
function App() {
@@ -124,68 +28,104 @@ function App() {
const handleAdd = async () => {
if (!inputText.trim()) return;
await editor.addBlock(inputText);
setInputText("");
setTimeout(() => {
inputRef.current?.focus();
}, 0);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
void handleAdd();
const getPluginTiles = () => {
const tools = editor.getTools().map(({ name, tool }) => {
const ToolClass = tool as unknown as { renderNewElementTile: (onAdd?: () => void) => React.ReactElement };
return <div key={name}>
{ToolClass.renderNewElementTile(() => editor.addBlock(name))}
</div>;
});
return tools;
};
return (
<div style={{ backgroundColor: '#1E1E1E' }} className="flex flex-col w-full h-screen items-center">
<div className="flex flex-row w-full h-full gap-8 items-center px-8">
<div className="flex-1 flex justify-center">
<div style={{ backgroundColor: '#FFFFFF' }} className="min-h-[500px] min-w-[500px] rounded shadow-xl text-gray-900 p-6">
<div style={{ margin: '20px' }} ref={editorRef} />
<div className="min-h-screen bg-white">
<div className="container mx-auto px-4 py-8">
{/* Quick Add Section */}
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4">Quick Add</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="flex gap-4">
<form
onSubmit={(e) => {
e.preventDefault();
handleAdd();
}}
className="flex gap-4"
>
<input
type="text"
placeholder="Enter text..."
className="flex-1 p-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
/>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
Add Text
</button>
</form>
<button
onClick={() => {
editor.clear();
}}
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
>
Clear All
</button>
</div>
</div>
</div>
<div className="flex-1 flex flex-col justify-center items-center gap-4">
<OutputComponent data={editorData} />
<form onSubmit={handleSubmit} className="flex gap-2 m-2">
<input
ref={inputRef}
type="text"
className="px-6 py-3 bg-white text-gray-900 rounded border border-gray-300"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="Enter text..."
/>
<button
type="submit"
className="px-6 py-3 bg-gray-500 text-white rounded hover:bg-gray-600"
disabled={!inputText.trim()}
>
Add
</button>
<button
type="button"
onClick={() => void editor.clear()}
className="px-6 py-3 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Clear All
</button>
</form>
{/* Main Content Area */}
<div className="flex flex-col lg:flex-row gap-8">
{/* Editor Section */}
<div className="flex-1 h-[600px] overflow-auto">
<div className="bg-white rounded-lg shadow-sm border h-full flex flex-col">
<div className="p-4 border-b">
<h2 className="text-xl font-semibold">Content Editor</h2>
</div>
<div className="flex-1 p-4 overflow-auto">
<div ref={editorRef} className="prose max-w-none"></div>
</div>
</div>
</div>
{/* Output Section */}
<div className="flex-1 h-[600px] overflow-auto">
<div className="bg-white rounded-lg shadow-sm border h-full flex flex-col">
<div className="p-4 border-b">
<h2 className="text-xl font-semibold">JSON Output</h2>
</div>
<div className="flex-1 p-4 overflow-auto">
<pre className="whitespace-pre-wrap break-words">
{JSON.stringify(editorData, null, 2)}
</pre>
</div>
</div>
</div>
</div>
{/* Available Blocks Section */}
<div className="mt-8">
<h2 className="text-xl font-semibold mb-4">Available Blocks</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{getPluginTiles()}
</div>
</div>
</div>
</div>
)
);
}
export default App
function OutputComponent({ data }: { data: OutputData | null }) {
return (
<div style={{ backgroundColor: '#FFFFFF' }} className="min-h-[500px] min-w-[500px] rounded shadow-xl p-6 text-gray-900">
<pre className="whitespace-pre-wrap">
{data ? JSON.stringify(data, null, 2) : 'Output content will go here'}
</pre>
</div>
)
}
export default App

126
src/EditorJSWrapper.tsx Normal file
View File

@@ -0,0 +1,126 @@
import EditorJS from "@editorjs/editorjs";
import { OutputData } from "@editorjs/editorjs";
import ReactChecklistTool from "./checklist-plugin";
type Tools = {
[key: string]: {
class: typeof ReactChecklistTool;
};
};
class EditorJSWrapper {
private editor: EditorJS | null = null;
private onChange!: (data: OutputData) => void;
private static instance: EditorJSWrapper | null = null;
private tools: Tools = {
checklist: {
class: ReactChecklistTool
}
};
private currentData: OutputData = {
time: Date.now(),
blocks: [],
version: '2.28.2'
};
constructor(onChange: (data: OutputData) => void) {
if (EditorJSWrapper.instance) {
return EditorJSWrapper.instance;
}
this.onChange = onChange;
EditorJSWrapper.instance = this;
}
initialize(holder: HTMLDivElement) {
if (this.editor || !holder) return;
// Ensure any existing instance is destroyed first
if (EditorJSWrapper.instance?.editor) {
void EditorJSWrapper.instance.destroy();
}
try {
this.editor = new EditorJS({
holder: holder,
placeholder: 'Start writing...',
data: this.currentData,
tools: this.tools,
onChange: async () => {
await this.syncState();
}
});
} catch (error) {
console.error('Error initializing editor:', error);
}
}
private async syncState() {
if (!this.editor) return;
try {
this.currentData = await this.editor.save();
this.onChange(this.currentData);
} catch (err) {
console.error('Error syncing state:', err);
}
}
async addBlock(type: string) {
if (!this.editor) return;
try {
// If the type exists in tools, add that block type, otherwise treat as paragraph
const blockType = this.tools[type] ? type : 'paragraph';
const data = blockType === 'paragraph' ? { text: type } : undefined;
await this.editor.blocks.insert(blockType, data);
await this.syncState();
} catch (err) {
console.error('Error adding block:', err);
}
}
async clear() {
if (!this.editor) return;
try {
await this.editor.clear();
this.currentData = {
time: Date.now(),
blocks: [],
version: '2.28.2'
};
this.onChange(this.currentData);
} catch (err) {
console.error('Error clearing editor:', err);
}
}
async getData(): Promise<OutputData> {
return this.currentData;
}
async destroy() {
try {
if (this.editor && typeof this.editor.destroy === 'function') {
await this.editor.destroy();
this.editor = null;
this.currentData = {
time: Date.now(),
blocks: [],
version: '2.28.2'
};
EditorJSWrapper.instance = null;
}
} catch (error: unknown) {
console.error('Error destroying editor:', error);
}
}
getTools() {
return Object.entries(this.tools).map(([name, tool]) => ({
name,
tool: tool.class
}));
}
}
export default EditorJSWrapper;

436
src/checklist-plugin.tsx Normal file
View File

@@ -0,0 +1,436 @@
import { API, BlockTool, BlockToolData } from '@editorjs/editorjs';
import './checklist.css';
interface ChecklistItem {
text: string;
checked: boolean;
}
interface ChecklistData {
items: ChecklistItem[];
}
interface ChecklistConfig {
data: BlockToolData<ChecklistData>;
api: API;
readOnly: boolean;
block: {
id: string;
};
}
class ChecklistTool implements BlockTool {
private data: ChecklistData;
private wrapper: HTMLElement;
private api: API;
private readOnly: boolean;
private blockId: string;
private itemElements: HTMLElement[] = [];
private pendingUpdate = false;
static get toolbox() {
return {
title: 'Checklist',
icon: '<svg width="15" height="15" viewBox="0 0 15 15" xmlns="http://www.w3.org/2000/svg"><path d="M12 2h-1v-.5a.5.5 0 0 0-.5-.5h-7a.5.5 0 0 0-.5.5v.5h-1a.5.5 0 0 0-.5.5v10a.5.5 0 0 0 .5.5h10a.5.5 0 0 0 .5-.5v-10a.5.5 0 0 0-.5-.5zm-8.5 8.5l-1.5-1.5 1-1 .5.5 2-2 1 1-3 3z"/></svg>'
};
}
constructor({ data, api, readOnly, block }: ChecklistConfig) {
const initialData = data && data.items ? data : { items: [] };
this.data = {
items: Array.isArray(initialData.items) ? initialData.items.map(item => ({
text: typeof item.text === 'string' ? item.text : '',
checked: Boolean(item.checked)
})) : []
};
this.api = api;
this.readOnly = readOnly;
this.blockId = block.id;
this.wrapper = document.createElement('div');
this.wrapper.classList.add('checklist-tool');
}
render(): HTMLElement {
// Store the currently focused element before render
const activeElement = document.activeElement;
const focusedIndex = this.itemElements.findIndex(el =>
el.querySelector('.checklist-tool__item-text') === activeElement
);
this.wrapper.innerHTML = '';
this.itemElements = [];
if (this.data.items.length === 0) {
this.data.items.push({
text: '',
checked: false
});
}
this.data.items.forEach((item, index) => {
const itemElement = this.createItemElement(item, index);
this.itemElements.push(itemElement);
this.wrapper.appendChild(itemElement);
// Restore focus if this was the focused element
if (index === focusedIndex) {
const input = itemElement.querySelector('.checklist-tool__item-text');
if (input instanceof HTMLElement) {
requestAnimationFrame(() => {
input.focus();
try {
const range = document.createRange();
const sel = window.getSelection();
if (sel) {
range.selectNodeContents(input);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
}
} catch (err) {
// Fallback to just focus if range setting fails
input.focus();
}
});
}
}
});
if (!this.readOnly) {
const addButton = document.createElement('button');
addButton.classList.add('checklist-tool__add-button');
addButton.textContent = '+ Add item';
addButton.type = 'button';
// Use mousedown instead of click to prevent blur interference
addButton.addEventListener('mousedown', (e: Event) => {
e.preventDefault();
this.addItem();
}, false);
this.wrapper.appendChild(addButton);
}
// Add a single blur handler to the wrapper
this.wrapper.addEventListener('blur', (e: FocusEvent) => {
// Only notify if we're not clicking within the wrapper
const relatedTarget = e.relatedTarget as Node | null;
if (!this.wrapper.contains(relatedTarget)) {
void this.notifyChange();
}
}, true);
return this.wrapper;
}
private createItemElement(item: { text: string; checked: boolean }, index: number): HTMLElement {
const itemWrapper = document.createElement('div');
itemWrapper.classList.add('checklist-tool__item');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = item.checked;
checkbox.disabled = this.readOnly;
checkbox.classList.add('checklist-tool__item-checkbox');
checkbox.addEventListener('change', (e: Event) => {
this.data.items[index].checked = (e.target as HTMLInputElement).checked;
void this.notifyChange();
}, false);
const input = document.createElement('div');
input.classList.add('checklist-tool__item-text');
input.contentEditable = this.readOnly ? 'false' : 'true';
input.innerHTML = item.text || '';
// Handle paste to strip formatting
input.addEventListener('paste', (e: ClipboardEvent) => {
e.preventDefault();
const text = e.clipboardData?.getData('text/plain') || '';
document.execCommand('insertText', false, text);
}, false);
// Handle Enter key
input.addEventListener('keydown', (e: KeyboardEvent) => {
const isEmpty = input.innerHTML.trim() === '' ||
input.innerHTML.trim() === '<br>' ||
input.textContent?.trim() === '';
if (e.key === 'Enter') {
// Always prevent default for Enter
e.preventDefault();
e.stopPropagation();
// If Shift+Enter, allow line break
if (e.shiftKey) {
document.execCommand('insertLineBreak');
return;
}
// Get current cursor position
const selection = window.getSelection();
const range = selection?.getRangeAt(0);
const currentText = input.textContent || '';
if (range) {
// Split text at cursor position
const beforeCursor = currentText.substring(0, range.startOffset);
const afterCursor = currentText.substring(range.endOffset);
// Update current item with text before cursor
this.data.items[index].text = beforeCursor;
input.textContent = beforeCursor;
// Create new item with text after cursor
const newItem = {
text: afterCursor,
checked: false
};
// Insert new item after current one
this.data.items.splice(index + 1, 0, newItem);
const newElement = this.createItemElement(newItem, index + 1);
this.itemElements.splice(index + 1, 0, newElement);
// Insert into DOM
const nextSibling = itemWrapper.nextSibling;
if (nextSibling) {
itemWrapper.parentNode?.insertBefore(newElement, nextSibling);
} else {
itemWrapper.parentNode?.appendChild(newElement);
}
// Focus new item
const newInput = newElement.querySelector('.checklist-tool__item-text');
if (newInput instanceof HTMLElement) {
newInput.focus();
}
void this.notifyChange();
}
return;
}
// Existing backspace handler
if (e.key === 'Backspace' && isEmpty && index > 0) {
e.preventDefault();
e.stopPropagation();
// Focus previous item before deletion
this.focusPreviousItem(index);
// Remove from data and DOM
this.data.items.splice(index, 1);
this.itemElements[index].remove();
this.itemElements.splice(index, 1);
// Update state
void this.notifyChange();
}
}, false);
// Handle input without debounce for local state
input.addEventListener('input', () => {
// Clean the input value before saving
const cleanText = input.innerHTML.replace(/<br\s*\/?>/gi, '').trim();
this.data.items[index].text = cleanText;
}, false);
if (!this.readOnly) {
const deleteBtn = document.createElement('button');
deleteBtn.classList.add('checklist-tool__item-delete');
deleteBtn.innerHTML = '×';
deleteBtn.type = 'button';
deleteBtn.addEventListener('mousedown', (e: Event) => {
e.preventDefault();
this.deleteItem(index);
}, false);
itemWrapper.appendChild(deleteBtn);
}
itemWrapper.appendChild(checkbox);
itemWrapper.appendChild(input);
return itemWrapper;
}
private async notifyChange(): Promise<void> {
if (this.blockId && !this.pendingUpdate) {
try {
this.pendingUpdate = true;
await this.api.blocks.update(this.blockId, {
items: this.data.items.map(item => ({
text: item.text || '',
checked: Boolean(item.checked)
}))
});
} catch (error) {
console.error('Error updating block:', error);
} finally {
this.pendingUpdate = false;
}
}
}
private focusElement(element: HTMLElement): void {
element.focus();
}
private focusPreviousItem(currentIndex: number): void {
if (currentIndex <= 0) return;
const prevElement = this.itemElements[currentIndex - 1];
if (!prevElement) return;
const prevInput = prevElement.querySelector('.checklist-tool__item-text');
if (prevInput instanceof HTMLElement) {
this.focusElement(prevInput);
}
}
private addItem(): void {
const newItem = {
text: '',
checked: false
};
// Add to data
this.data.items.push(newItem);
// Create and add element
const newElement = this.createItemElement(newItem, this.data.items.length - 1);
this.itemElements.push(newElement);
// Get the add button reference
const addButton = this.wrapper.querySelector('.checklist-tool__add-button');
if (!addButton) return;
// Insert before the add button
this.wrapper.insertBefore(newElement, addButton);
// Try to focus the new input
const input = newElement.querySelector('.checklist-tool__item-text') as HTMLElement;
if (input) {
// Ensure contentEditable is set
input.contentEditable = 'true';
// Add a BR tag to ensure the div has content
input.innerHTML = '<br>';
input.focus();
}
// Notify change
void this.notifyChange();
}
private deleteItem(index: number): void {
// Remove the element from DOM
if (this.itemElements[index]) {
this.itemElements[index].remove();
this.itemElements.splice(index, 1);
}
// Update data
this.data.items.splice(index, 1);
// Ensure we always have at least one item
if (this.data.items.length === 0) {
this.data.items.push({
text: '',
checked: false
});
const newItem = this.createItemElement(this.data.items[0], 0);
this.itemElements = [newItem];
this.wrapper.insertBefore(newItem, this.wrapper.lastChild);
}
void this.notifyChange();
}
save(): BlockToolData<ChecklistData> {
return {
items: this.data.items.map(item => ({
text: item.text || '',
checked: Boolean(item.checked)
}))
};
}
validate(savedData: BlockToolData<ChecklistData>): boolean {
try {
const { items } = savedData;
if (!Array.isArray(items)) return false;
return items.every(item =>
item &&
typeof item === 'object' &&
'text' in item &&
'checked' in item
);
} catch (e) {
return false;
}
}
static get sanitize() {
return {
text: {
br: true,
span: true
},
checked: true
};
}
}
class ReactChecklistTool extends ChecklistTool {
private settings: { title: string };
constructor(config: ChecklistConfig) {
super(config);
this.settings = {
title: 'Checklist',
};
}
public renderSettings(): HTMLElement {
const wrapper = document.createElement('div');
wrapper.innerHTML = `
<div>
<h1>${this.settings.title}</h1>
<input type="text" value="${this.settings.title}" />
</div>
`;
const input = wrapper.querySelector('input');
if (input) {
input.addEventListener('change', (e) => {
this.settings.title = (e.target as HTMLInputElement).value;
});
}
return wrapper;
}
public static renderNewElementTile(onAdd?: () => void): React.ReactElement {
return (
<button
className="w-full border rounded-md p-4 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-left"
role="button"
aria-label="Add new checklist block"
onClick={onAdd}
>
<div className="flex items-center gap-2 mb-2">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 11L12 14L20 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M20 12V18C20 19.1046 19.1046 20 18 20H6C4.89543 20 4 19.1046 4 18V6C4 4.89543 4.89543 4 6 4H15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<h3 className="text-base font-medium">Checklist</h3>
<p className="text-sm text-gray-500 mt-1">Add a new checklist</p>
</button>
);
}
}
export default ReactChecklistTool;

71
src/checklist.css Normal file
View File

@@ -0,0 +1,71 @@
.checklist-tool {
padding: 10px;
}
.checklist-tool__item {
display: flex;
align-items: flex-start;
margin-bottom: 10px;
position: relative;
}
.checklist-tool__item-checkbox {
margin-right: 10px;
margin-top: 3px;
cursor: pointer;
}
.checklist-tool__item-text {
flex-grow: 1;
min-height: 20px;
padding: 3px 0;
outline: none;
line-height: 1.4;
}
.checklist-tool__item-text:empty:before {
content: 'List item';
color: #707684;
}
.checklist-tool__item-delete {
width: 20px;
height: 20px;
border: none;
background: none;
padding: 0;
margin: 0;
cursor: pointer;
color: #707684;
font-size: 16px;
line-height: 1;
position: absolute;
right: -24px;
top: 2px;
opacity: 0;
transition: opacity 0.2s ease;
}
.checklist-tool__item:hover .checklist-tool__item-delete {
opacity: 1;
}
.checklist-tool__add-button {
padding: 6px 10px;
margin-top: 5px;
border: 1px solid #ccc;
border-radius: 3px;
background: #fff;
cursor: pointer;
color: #707684;
font-size: 14px;
}
.checklist-tool__add-button:hover {
background: #f8f8f8;
}
.checklist-tool__item-checkbox:checked + .checklist-tool__item-text {
text-decoration: line-through;
color: #707684;
}