Style update and add plugin support
This commit is contained in:
236
src/App.tsx
236
src/App.tsx
@@ -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
126
src/EditorJSWrapper.tsx
Normal 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
436
src/checklist-plugin.tsx
Normal 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
71
src/checklist.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user