UNPKG

@silexlabs/grapesjs-data-source

Version:
719 lines (635 loc) 24.4 kB
import { Editor, Component } from 'grapesjs' import { getState, StoredState } from '../model/state' import { Properties, StoredToken, BinaryOperator, UnariOperator, PREVIEW_RENDER_START, PREVIEW_RENDER_END, PREVIEW_RENDER_ERROR, DataSourceEditorViewOptions } from '../types' import { fromStored } from '../model/token' import { evaluateExpressionTokens, EvaluationContext } from '../model/expressionEvaluator' import { getAllDataSources } from '../model/dataSourceRegistry' import { getFilters, getPreviewData } from '../model/dataSourceManager' import { getPreviewActive } from '../commands' // DOM diffing function to compare and update nodes only when needed function updateNodeContent(oldNode: Node, newNode: Node): void { // If node types differ, we need to replace if (oldNode.nodeType !== newNode.nodeType) { oldNode.parentNode?.replaceChild(newNode.cloneNode(true), oldNode) return } // Text nodes: compare and update text content if (oldNode.nodeType === Node.TEXT_NODE) { if (oldNode.textContent !== newNode.textContent) { oldNode.textContent = newNode.textContent } return } // Element nodes: compare tag names if (oldNode.nodeType === Node.ELEMENT_NODE && newNode.nodeType === Node.ELEMENT_NODE) { const oldEl = oldNode as Element const newEl = newNode as Element // If tag names differ, replace the entire node if (oldEl.tagName !== newEl.tagName) { oldNode.parentNode?.replaceChild(newNode.cloneNode(true), oldNode) return } // Update attributes // Remove old attributes that don't exist in new element const oldAttrs = oldEl.attributes for (let i = oldAttrs.length - 1; i >= 0; i--) { const attrName = oldAttrs[i].name if (!newEl.hasAttribute(attrName)) { oldEl.removeAttribute(attrName) } } // Set/update attributes from new element const newAttrs = newEl.attributes for (let i = 0; i < newAttrs.length; i++) { const attr = newAttrs[i] if (oldEl.getAttribute(attr.name) !== attr.value) { oldEl.setAttribute(attr.name, attr.value) } } // Recursively update children const oldChildren = Array.from(oldEl.childNodes) const newChildren = Array.from(newEl.childNodes) // Build a map of old children by ID for efficient lookup // Note: Multiple children can have the same ID (loop clones), so store arrays const oldChildrenById = new Map<string, Element[]>() const oldChildrenWithoutId: Node[] = [] oldChildren.forEach(child => { if (child.nodeType === Node.ELEMENT_NODE) { const id = (child as Element).id if (id) { if (!oldChildrenById.has(id)) { oldChildrenById.set(id, []) } oldChildrenById.get(id)!.push(child as Element) } else { oldChildrenWithoutId.push(child) } } else { oldChildrenWithoutId.push(child) } }) // Track which old children have been matched const matchedOldChildren = new Set<Node>() // Process new children in order let lastInsertedNode: Node | null = null newChildren.forEach(newChild => { if (newChild.nodeType === Node.ELEMENT_NODE) { const newId = (newChild as Element).id // Try to find matching old child by ID if (newId && oldChildrenById.has(newId)) { const candidates = oldChildrenById.get(newId)! const oldChild = candidates.shift() // Take first unmatched element if (!oldChild) { // All elements with this ID already matched, treat as no match const clonedNode = newChild.cloneNode(true) if (lastInsertedNode) { oldEl.insertBefore(clonedNode, lastInsertedNode.nextSibling) } else { oldEl.insertBefore(clonedNode, oldEl.firstChild) } lastInsertedNode = clonedNode return } matchedOldChildren.add(oldChild) // Update the existing element in place updateNodeContent(oldChild, newChild) // Ensure it's in the correct position if (lastInsertedNode) { if (lastInsertedNode.nextSibling !== oldChild) { oldEl.insertBefore(oldChild, lastInsertedNode.nextSibling) } } else { if (oldEl.firstChild !== oldChild) { oldEl.insertBefore(oldChild, oldEl.firstChild) } } lastInsertedNode = oldChild } else { // No matching old child found, insert new element const clonedNode = newChild.cloneNode(true) if (lastInsertedNode) { oldEl.insertBefore(clonedNode, lastInsertedNode.nextSibling) } else { oldEl.insertBefore(clonedNode, oldEl.firstChild) } lastInsertedNode = clonedNode } } else { // Text node or other - try to match with old children without IDs const matchIndex = oldChildrenWithoutId.findIndex(oldChild => !matchedOldChildren.has(oldChild) && oldChild.nodeType === newChild.nodeType ) if (matchIndex >= 0) { const oldChild = oldChildrenWithoutId[matchIndex] matchedOldChildren.add(oldChild) updateNodeContent(oldChild, newChild) if (lastInsertedNode) { if (lastInsertedNode.nextSibling !== oldChild) { oldEl.insertBefore(oldChild, lastInsertedNode.nextSibling) } } else { if (oldEl.firstChild !== oldChild) { oldEl.insertBefore(oldChild, oldEl.firstChild) } } lastInsertedNode = oldChild } else { const clonedNode = newChild.cloneNode(true) if (lastInsertedNode) { oldEl.insertBefore(clonedNode, lastInsertedNode.nextSibling) } else { oldEl.insertBefore(clonedNode, oldEl.firstChild) } lastInsertedNode = clonedNode } } }) // Remove old children that weren't matched oldChildren.forEach(oldChild => { if (!matchedOldChildren.has(oldChild)) { oldEl.removeChild(oldChild) } }) } } function getPrivateState(component: Component, stateId: string): StoredState | null { return getState(component, stateId, false) } // Helper function to evaluate expressions with internal API function evaluateExpression(expression: StoredToken[], component: Component, resolvePreviewIndex = true): unknown | null { try { // Convert StoredTokens to full Tokens first, like main branch did const tokens = expression.map(token => fromStored(token, component.getId?.() || null)) const context: EvaluationContext = { dataSources: getAllDataSources(), filters: getFilters(), previewData: getPreviewData(), component, resolvePreviewIndex, } return evaluateExpressionTokens(tokens, context) } catch (e) { console.warn('Error evaluating expression:', e) return null } } // Pure function to evaluate a single condition function evaluateCondition(expression: StoredToken[], component: Component): unknown | null { return evaluateExpression(expression, component, true) } // Pure function to render innerHTML for a component at a specific loop index function renderInnerHTML(component: Component, loopIndex?: number): string | null { const innerHTML = getPrivateState(component, Properties.innerHTML) if (innerHTML === null) { return null } try { // Set preview index for loop context if (typeof loopIndex === 'number') { setPreviewIndexToLoopData(component, loopIndex) } const value = evaluateCondition(innerHTML.expression, component) return value !== null && value !== undefined ? String(value) : null } catch (e) { console.warn('Error rendering innerHTML:', e) return null } } // Pure function to set preview index on all tokens in a component // Exprorted for API function setPreviewIndexToLoopData(component: Component, index: number): void { const privateStates = component.get('privateStates') || [] privateStates.forEach((state: {id: string, expression: StoredToken[]}) => { if (state.expression && state.expression.length > 0) { setPreviewIndex(state.expression, index) } }) } export function setPreviewIndex(expression: StoredToken[], index: number, group?: number) { expression.forEach((token: StoredToken & {previewIndex?: number}) => { if (token.type === 'state' && token.storedStateId === '__data' || (token.type === 'state' && token.storedStateId === 'items')) { token.previewIndex = index token.previewGroup = group } else if (token.type === 'property' || token.type === 'filter') { token.previewIndex = index token.previewGroup = group } }) } function getPreviewIndex(component: Component): number | undefined { const privateStates = component.get('privateStates') || [] for (const state of privateStates) { if (state.expression && state.expression.length > 0) { for (const token of state.expression) { if ( (token.type === 'state' && token.storedStateId === '__data') || token.type === 'property' || token.type === 'filter' ) { return token.previewIndex } } } } return undefined } function renderLoopData( component: Component, ): unknown[] | null { try { const __data = getPrivateState(component, Properties.__data)! if (__data === null) { return null } const result = evaluateExpression(__data.expression, component, false) // Get full array return Array.isArray(result) ? JSON.parse(JSON.stringify(result)) : null } catch (e) { console.warn('Error getting loop item:', e) return null } } // Export for tests export function isComponentVisible( component: Component, ): boolean { const condition1State = getPrivateState(component, Properties.condition) const condition2State = getPrivateState(component, Properties.condition2) const conditionOperator = component.get('conditionOperator') // If no condition is set, component is visible if (!condition1State || !condition1State?.expression || condition1State?.expression.length === 0) { return true } let condition1Value: unknown try { condition1Value = evaluateExpression(condition1State.expression, component, true) } catch (e) { console.warn('Error evaluating condition1:', e) // If condition evaluation fails but no operator is set, default to visible if (conditionOperator === undefined || conditionOperator === null) { return true } // For explicit operators, treat failed evaluation as falsy condition1Value = null } // For unary operators, only condition1 is needed switch (conditionOperator) { case UnariOperator.TRUTHY: return !!condition1Value case UnariOperator.FALSY: return !condition1Value case UnariOperator.EMPTY_ARR: return Array.isArray(condition1Value) && condition1Value.length === 0 case UnariOperator.NOT_EMPTY_ARR: return Array.isArray(condition1Value) && condition1Value.length > 0 case undefined: case null: // If no operator is specified but condition1 exists, default to TRUTHY behavior return !!condition1Value default: } // For binary operators, we need condition2 if (!condition2State || !condition2State.expression || condition2State.expression.length === 0) { return false } let condition2Value: unknown try { condition2Value = evaluateExpression(condition2State.expression, component, true) } catch (e) { console.warn('Error evaluating condition2:', e) // If condition2 evaluation fails, treat as falsy condition2Value = null } // Apply binary operator switch (conditionOperator) { case BinaryOperator.EQUAL: return condition1Value == condition2Value case BinaryOperator.NOT_EQUAL: return condition1Value !== condition2Value case BinaryOperator.GREATER_THAN: return Number(condition1Value) > Number(condition2Value) case BinaryOperator.LESS_THAN: return Number(condition1Value) < Number(condition2Value) case BinaryOperator.GREATER_THAN_OR_EQUAL: return Number(condition1Value) >= Number(condition2Value) case BinaryOperator.LESS_THAN_OR_EQUAL: return Number(condition1Value) <= Number(condition2Value) default: throw new Error(`Unknown operator ${conditionOperator}`) } } function renderAttributes( component: Component, ): void { const privateStates = component.get('privateStates') || [] privateStates.forEach((state: {id: string, expression: StoredToken[], label?: string}) => { // Skip condition states and internal data states - they should not become HTML attributes if (state.id && state.id !== Properties.innerHTML && state.id !== Properties.__data && state.id !== Properties.condition && state.id !== Properties.condition2 && state.expression) { try { const value = evaluateExpression(state.expression, component, true) if (value !== null && value !== undefined) { component.view?.el.setAttribute(state.label || state.id, String(value)) } } catch (e) { console.warn(`Error evaluating attribute ${state.id}:`, e) } } }) } // // Helper to extend a component instance // function extendComponent(comp: Component, onRender: (c: Component) => void) { // // Extend view // if (comp.view) { // const origOnRender = comp.view.onRender?.bind(comp.view) // comp.view.onRender = function (opts: ClbObj) { // if (origOnRender) origOnRender(opts) // onRender(comp) // } // } // } // // /** // * Applies extended model/view logic to all existing components in the editor. // * @param editor The GrapesJS editor instance // */ // function extendAllComponents(editor: Editor, onRender: (c: Component) => void, parents: Components = editor.getComponents()) { // parents.forEach((comp) => { // extendComponent(comp, onRender) // extendAllComponents(editor, onRender, comp.components()) // }) // } // function renderContent(comp: Component, deep: number) { const innerHtml = renderInnerHTML(comp) if (innerHtml === null) { comp.components() .forEach(c => renderPreview(c, deep+1)) } else { const el = comp.view!.el // Parse new HTML into a temporary container const temp = document.createElement('div') temp.innerHTML = innerHtml! // Get children from both old and new const oldChildren = Array.from(el.childNodes) const newChildren = Array.from(temp.childNodes) // Update existing children const minLength = Math.min(oldChildren.length, newChildren.length) for (let i = 0; i < minLength; i++) { updateNodeContent(oldChildren[i], newChildren[i]) } // Remove extra old children for (let i = oldChildren.length - 1; i >= minLength; i--) { el.removeChild(oldChildren[i]) } // Add new children for (let i = minLength; i < newChildren.length; i++) { el.appendChild(newChildren[i].cloneNode(true)) } } } // Restore original GrapesJS rendering without preview data export function restoreOriginalRender(comp: Component) { const view = comp.view if (!view) { return } // Force standard GrapesJS render view.render() // Recursively restore all children comp.components().forEach(child => restoreOriginalRender(child)) } // exported for unit tests only export function renderPreview(comp: Component, deep = 0) { const view = comp.view if (!view) { return } const el = view.el const __data = renderLoopData(comp) if (__data) { if (__data.length === 0) { el.remove() } else { // Remove all existing loop clones (siblings with same ID from previous render) const componentId = el.id let nextSibling = el.nextElementSibling as HTMLElement | null while (nextSibling && nextSibling.id === componentId) { const toRemove = nextSibling nextSibling = nextSibling.nextElementSibling as HTMLElement | null toRemove.remove() } const initialPreviewIndex = getPreviewIndex(comp) || 0 // Render each loop iteration // Render first iteration in the original element // FIXME: as a workaround we need to loop reverse on the __data array, I have no idea why const fromIdx = __data.length - 1 const toIdx = 0 setPreviewIndexToLoopData(comp, fromIdx) const isVisible = isComponentVisible(comp) if(isVisible) { renderContent(comp, deep) renderAttributes(comp) } else { el.remove() } // For subsequent iterations: clone first, then render into original (without diffing), then repeat for (let idx = fromIdx - 1; idx >= toIdx; idx--) { // Check if this iteration should be visible setPreviewIndexToLoopData(comp, idx) const isVisibleAtIdx = isComponentVisible(comp) // Skip invisible iterations - don't create a clone if (!isVisibleAtIdx) { continue } // Clone the current state (with previous iteration's content) const clone = el.cloneNode(true) as HTMLElement // Remove selection marker from the element and its children clone.classList.remove('gjs-selected') clone.querySelectorAll('.gjs-selected').forEach(el => el.classList.remove('gjs-selected')) // Remove hidden elements from the clone to prevent them from appearing in the output const hiddenInClone = clone.querySelectorAll('[style*="display: none"]') hiddenInClone.forEach(hiddenEl => hiddenEl.remove()) // Reset visibility on the ORIGINAL element's children before rendering next iteration // This ensures the next iteration starts with all elements visible const hiddenInOriginal = el.querySelectorAll('[style*="display: none"]') hiddenInOriginal.forEach(hiddenEl => { if (hiddenEl instanceof HTMLElement && hiddenEl.style) { hiddenEl.style.removeProperty('display') // Remove empty style attribute if (hiddenEl.style.length === 0) { hiddenEl.removeAttribute('style') } } }) // Keep the selection mechanism - use GrapesJS component API clone.addEventListener('click', (event) => { const clickedElement = event.target as HTMLElement // Find the corresponding GrapesJS component to select let targetComponent = comp // Look for the component ID directly in the clicked element if (clickedElement.id) { // Try to find a component with this ID in the entire editor const editor = comp.em const findComponentById = (comp: Component, id: string): Component | null => { if (comp.getId() === id) return comp for (const child of comp.components()) { const found = findComponentById(child, id) if (found) return found } return null } // Search from the root of the editor const wrapper = editor?.getWrapper() const foundComp = wrapper ? findComponentById(wrapper, clickedElement.id) : null if (foundComp) { targetComponent = foundComp } else { // Fallback: Use path-based approach const path: number[] = [] let current = clickedElement // Build path from clicked element up to clone, but only count elements with gjs attributes while (current && current !== clone) { const parent = current.parentElement if (parent) { const siblings = Array.from(parent.children).filter(child => child.hasAttribute('data-gjs-type') ) const index = siblings.indexOf(current) if (index >= 0) { path.unshift(index) } current = parent } else { break } } // Navigate the component tree using the path let currentComp = comp for (let i = 0; i < path.length; i++) { const index = path[i] const children = currentComp.components() if (index < children.length) { currentComp = children.at(index) } else { break } } targetComponent = currentComp } } // Use GrapesJS API to select the component setTimeout(() => { const editor = targetComponent.em if (editor) { editor.setSelected(targetComponent) } }) event.preventDefault() event.stopImmediatePropagation() }) // Add the clone to the canvas el.insertAdjacentElement('afterend', clone) // Render the current iteration into the original element // NOTE: We skip the diffing here because we're in a loop creating initial elements // Diffing only helps on re-renders, not initial renders const innerHtml = renderInnerHTML(comp) if (innerHtml === null) { comp.components() .forEach(c => renderPreview(c, deep+1)) } else { // Just set innerHTML directly without diffing in the loop el.innerHTML = innerHtml } renderAttributes(comp) } setPreviewIndexToLoopData(comp, initialPreviewIndex) } } else { const isVisible = isComponentVisible(comp) if(isVisible) { // Make sure the element is visible (in case it was hidden before) if (el.style && el.style.display === 'none') { el.style.removeProperty('display') // Remove empty style attribute if (el.style.length === 0) { el.removeAttribute('style') } } renderContent(comp, deep) renderAttributes(comp) } else { // Don't remove the element, just hide it // This prevents breaking loop rendering where the same element // is referenced across multiple iterations if (el.parentElement && el.style) { el.style.display = 'none' } } } } export function doRender(editor: Editor) { if(!editor.getWrapper()?.view?.el) { return } try { editor.trigger(PREVIEW_RENDER_START) renderPreview(editor.getWrapper()!) requestAnimationFrame(() => { editor.trigger(PREVIEW_RENDER_END) }) } catch (err) { editor.trigger(PREVIEW_RENDER_ERROR, err) console.error('Error during preview render:', err) } } let renderTimeoutId: NodeJS.Timeout | null = null let debounceDelay = 500 function debouncedRender(editor: Editor) { if (renderTimeoutId) { clearTimeout(renderTimeoutId) } renderTimeoutId = setTimeout(() => { doRender(editor) renderTimeoutId = null }, debounceDelay) } // Helper function to clean up loop clones for a component function cleanupLoopClones(component: Component): void { const view = component.view if (!view || !view.el) { return } const el = view.el const componentId = el.id // Remove all loop clones (siblings with same ID) let nextSibling = el.nextElementSibling as HTMLElement | null while (nextSibling && nextSibling.id === componentId) { const toRemove = nextSibling nextSibling = nextSibling.nextElementSibling as HTMLElement | null toRemove.remove() } // Recursively clean up clones for all children component.components().forEach(child => cleanupLoopClones(child)) } export default (editor: Editor, opts: DataSourceEditorViewOptions) => { const events = opts.previewRefreshEvents!.split(' ') for(const eventName of events) { editor.on(eventName, () => { if (getPreviewActive()) { debouncedRender(editor) } }) } // Clean up loop clones when a component is removed editor.on('component:remove', (component: Component) => { cleanupLoopClones(component) }) setTimeout(() => { debounceDelay = opts.previewDebounceDelay! }, 1000) }