summaryrefslogtreecommitdiffstats
path: root/assets/admin/lib/component-tool.js
diff options
context:
space:
mode:
Diffstat (limited to 'assets/admin/lib/component-tool.js')
-rw-r--r--assets/admin/lib/component-tool.js166
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
+ }
+}