@silexlabs/grapesjs-data-source
Version:
Grapesjs Data Source
719 lines (635 loc) • 24.4 kB
text/typescript
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)
}