diff options
Diffstat (limited to 'assets/admin/lib/component-tool.js')
| -rw-r--r-- | assets/admin/lib/component-tool.js | 166 |
1 files changed, 166 insertions, 0 deletions
diff --git a/assets/admin/lib/component-tool.js b/assets/admin/lib/component-tool.js new file mode 100644 index 0000000..8dbc330 --- /dev/null +++ b/assets/admin/lib/component-tool.js @@ -0,0 +1,166 @@ +/** @import { BlockTool, BlockToolConstructorOptions, BlockToolData, API, ToolboxConfig } from '@editorjs/editorjs' */ + +/** + * EditorJS block tool for embedding web components with props. + * + * Saved data format: + * { + * "name": "site-greeting", + * "props": { "name": "visitor", "theme": "dark" } + * } + * + * Renders in HTML as: <site-greeting name="visitor" theme="dark"></site-greeting> + * + * @implements {BlockTool} + */ +export default class ComponentTool { + /** @returns {ToolboxConfig} */ + static get toolbox() { + return { + title: 'Component', + icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M7 8l-4 4 4 4M17 8l4 4-4 4M14 4l-4 16"/></svg>', + } + } + + static get isReadOnlySupported() { + return true + } + + /** + * @param {BlockToolConstructorOptions} options + * @param {BlockToolData} options.data + * @param {API} options.api + * @param {boolean} options.readOnly + */ + constructor({ data, api, readOnly }) { + this.api = api + this.readOnly = readOnly + this.data = { + name: data.name || '', + props: data.props || {}, + } + this.wrapper = null + } + + /** @returns {HTMLElement} */ + render() { + this.wrapper = document.createElement('div') + this.wrapper.classList.add('component-tool') + if (this.readOnly) { + this._renderPreview() + } else { + this._renderEditor() + } + return this.wrapper + } + + _renderPreview() { + const tag = document.createElement('code') + tag.style.cssText = 'display:block;padding:6px 8px;background:#f5f5f5;border-radius:4px;font-size:0.85em;color:#555;' + tag.textContent = this._previewText() + this.wrapper.append(tag) + } + + _previewText() { + const attrs = Object.entries(this.data.props) + .map(([k, v]) => ` ${k}="${v}"`) + .join('') + return `<${this.data.name || '?'}${attrs}>` + } + + _renderEditor() { + this.wrapper.innerHTML = '' + + const nameLabel = document.createElement('label') + nameLabel.textContent = 'Component tag name' + nameLabel.style.cssText = 'display:block;font-size:12px;color:#888;margin-bottom:2px;' + + const nameInput = document.createElement('input') + nameInput.type = 'text' + nameInput.placeholder = 'e.g. site-greeting' + nameInput.value = this.data.name + nameInput.style.cssText = 'width:100%;padding:6px 8px;border:1px solid #ccc;border-radius:4px;font-family:monospace;margin-bottom:8px;' + nameInput.addEventListener('input', () => { this.data.name = nameInput.value }) + + const propsLabel = document.createElement('label') + propsLabel.textContent = 'Props' + propsLabel.style.cssText = 'display:block;font-size:12px;color:#888;margin-bottom:2px;' + + const propsContainer = document.createElement('div') + propsContainer.classList.add('props-container') + + const addBtn = document.createElement('button') + addBtn.type = 'button' + addBtn.textContent = '+ Add prop' + addBtn.style.cssText = 'padding:4px 10px;font-size:12px;border:1px solid #ccc;border-radius:4px;background:#f5f5f5;cursor:pointer;margin-top:4px;' + addBtn.addEventListener('click', () => { + this.data.props[''] = '' + this._renderProps(propsContainer) + }) + + this.wrapper.append(nameLabel, nameInput, propsLabel, propsContainer, addBtn) + this._renderProps(propsContainer) + } + + _renderProps(container) { + container.innerHTML = '' + Object.entries(this.data.props).forEach(([key, value]) => { + const row = document.createElement('div') + row.style.cssText = 'display:flex;gap:4px;margin-bottom:4px;' + + const kInput = document.createElement('input') + kInput.type = 'text' + kInput.placeholder = 'key' + kInput.value = key + kInput.style.cssText = 'flex:1;padding:4px 6px;border:1px solid #ccc;border-radius:4px;font-family:monospace;' + + const vInput = document.createElement('input') + vInput.type = 'text' + vInput.placeholder = 'value' + vInput.value = value + vInput.style.cssText = 'flex:2;padding:4px 6px;border:1px solid #ccc;border-radius:4px;' + + const removeBtn = document.createElement('button') + removeBtn.type = 'button' + removeBtn.textContent = '×' + removeBtn.style.cssText = 'padding:4px 8px;border:1px solid #ccc;border-radius:4px;background:#f5f5f5;cursor:pointer;' + + const updateProps = () => { + const newProps = {} + container.querySelectorAll('div').forEach((r) => { + const [k, v] = r.querySelectorAll('input') + if (k && k.value) newProps[k.value] = v ? v.value : '' + }) + this.data.props = newProps + } + + kInput.addEventListener('input', updateProps) + vInput.addEventListener('input', updateProps) + removeBtn.addEventListener('click', () => { row.remove(); updateProps() }) + + row.append(kInput, vInput, removeBtn) + container.append(row) + }) + } + + /** + * @param {HTMLElement} _blockContent + * @returns {BlockToolData} + */ + save(_blockContent) { + const props = {} + for (const [k, v] of Object.entries(this.data.props)) { + if (k.trim()) props[k.trim()] = v + } + return { name: this.data.name.trim(), props } + } + + /** @param {BlockToolData} savedData */ + validate(savedData) { + return savedData.name.trim() !== '' + } + + destroyed() { + this.wrapper = null + } +} |
