Some checks failed
Deploy Application / deploy (push) Has been cancelled
497 lines
18 KiB
JavaScript
497 lines
18 KiB
JavaScript
/**
|
|
* Lightweight DOM Patcher for LiveComponent Fragments
|
|
*
|
|
* Efficiently patches specific DOM fragments without full re-render.
|
|
* Optimized for LiveComponent use case with minimal overhead.
|
|
*
|
|
* Features:
|
|
* - Smart element matching by tag and data-lc-fragment attribute
|
|
* - Attribute diffing and patching
|
|
* - Text content updates
|
|
* - Child node reconciliation
|
|
* - Preserves focus and scroll position where possible
|
|
*
|
|
* Philosophy:
|
|
* - Keep it simple and focused
|
|
* - No external dependencies
|
|
* - Framework-compliant modern JavaScript
|
|
*/
|
|
export class DomPatcher {
|
|
/**
|
|
* Patch a specific fragment within a container
|
|
*
|
|
* @param {HTMLElement} container - Container element to search in
|
|
* @param {string} fragmentName - Fragment name (data-lc-fragment value)
|
|
* @param {string} newHtml - New HTML for the fragment
|
|
* @returns {boolean} - True if fragment was patched, false if not found
|
|
*/
|
|
patchFragment(container, fragmentName, newHtml) {
|
|
// Find existing fragment element
|
|
const existingElement = container.querySelector(`[data-lc-fragment="${fragmentName}"]`);
|
|
|
|
if (!existingElement) {
|
|
console.warn(`[DomPatcher] Fragment not found: ${fragmentName}`);
|
|
return false;
|
|
}
|
|
|
|
// Parse new HTML into element
|
|
const temp = document.createElement('div');
|
|
temp.innerHTML = newHtml;
|
|
const newElement = temp.firstElementChild;
|
|
|
|
if (!newElement) {
|
|
console.warn(`[DomPatcher] Invalid HTML for fragment: ${fragmentName}`);
|
|
return false;
|
|
}
|
|
|
|
// Verify fragment name matches
|
|
if (newElement.getAttribute('data-lc-fragment') !== fragmentName) {
|
|
console.warn(`[DomPatcher] Fragment name mismatch: expected ${fragmentName}, got ${newElement.getAttribute('data-lc-fragment')}`);
|
|
return false;
|
|
}
|
|
|
|
// Patch the element in place
|
|
this.patchElement(existingElement, newElement);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Patch multiple fragments at once
|
|
*
|
|
* @param {HTMLElement} container - Container element
|
|
* @param {Object.<string, string>} fragments - Map of fragment names to HTML
|
|
* @returns {Object.<string, boolean>} - Map of fragment names to success status
|
|
*/
|
|
patchFragments(container, fragments) {
|
|
const results = {};
|
|
|
|
for (const [fragmentName, html] of Object.entries(fragments)) {
|
|
results[fragmentName] = this.patchFragment(container, fragmentName, html);
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Patch an element with new content
|
|
*
|
|
* Core patching logic that efficiently updates only what changed.
|
|
*
|
|
* @param {HTMLElement} oldElement - Existing element to patch
|
|
* @param {HTMLElement} newElement - New element with updated content
|
|
*/
|
|
patchElement(oldElement, newElement) {
|
|
// 1. Patch attributes
|
|
this.patchAttributes(oldElement, newElement);
|
|
|
|
// 2. Patch child nodes
|
|
this.patchChildren(oldElement, newElement);
|
|
}
|
|
|
|
/**
|
|
* Patch element attributes
|
|
*
|
|
* Only updates attributes that actually changed.
|
|
*
|
|
* @param {HTMLElement} oldElement - Existing element
|
|
* @param {HTMLElement} newElement - New element
|
|
*/
|
|
patchAttributes(oldElement, newElement) {
|
|
// Get all attributes from both elements
|
|
const oldAttrs = new Map();
|
|
const newAttrs = new Map();
|
|
|
|
for (const attr of oldElement.attributes) {
|
|
oldAttrs.set(attr.name, attr.value);
|
|
}
|
|
|
|
for (const attr of newElement.attributes) {
|
|
newAttrs.set(attr.name, attr.value);
|
|
}
|
|
|
|
// Remove attributes that no longer exist
|
|
for (const [name, value] of oldAttrs) {
|
|
if (!newAttrs.has(name)) {
|
|
oldElement.removeAttribute(name);
|
|
}
|
|
}
|
|
|
|
// Add or update attributes
|
|
for (const [name, value] of newAttrs) {
|
|
if (oldAttrs.get(name) !== value) {
|
|
oldElement.setAttribute(name, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Patch child nodes
|
|
*
|
|
* Reconciles child nodes between old and new elements.
|
|
* Uses simple key-based matching for efficiency.
|
|
*
|
|
* @param {HTMLElement} oldElement - Existing element
|
|
* @param {HTMLElement} newElement - New element
|
|
*/
|
|
patchChildren(oldElement, newElement) {
|
|
const oldChildren = Array.from(oldElement.childNodes);
|
|
const newChildren = Array.from(newElement.childNodes);
|
|
|
|
const maxLength = Math.max(oldChildren.length, newChildren.length);
|
|
|
|
for (let i = 0; i < maxLength; i++) {
|
|
const oldChild = oldChildren[i];
|
|
const newChild = newChildren[i];
|
|
|
|
if (!oldChild && newChild) {
|
|
// New child added - append it
|
|
oldElement.appendChild(newChild.cloneNode(true));
|
|
} else if (oldChild && !newChild) {
|
|
// Child removed - remove it
|
|
oldElement.removeChild(oldChild);
|
|
} else if (oldChild && newChild) {
|
|
// Both exist - patch or replace
|
|
if (this.shouldPatch(oldChild, newChild)) {
|
|
if (oldChild.nodeType === Node.ELEMENT_NODE) {
|
|
this.patchElement(oldChild, newChild);
|
|
} else if (oldChild.nodeType === Node.TEXT_NODE) {
|
|
if (oldChild.nodeValue !== newChild.nodeValue) {
|
|
oldChild.nodeValue = newChild.nodeValue;
|
|
}
|
|
}
|
|
} else {
|
|
// Different node types or tags - replace
|
|
oldElement.replaceChild(newChild.cloneNode(true), oldChild);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determine if two nodes should be patched or replaced
|
|
*
|
|
* Nodes should be patched if they are compatible (same type and tag).
|
|
*
|
|
* @param {Node} oldNode - Existing node
|
|
* @param {Node} newNode - New node
|
|
* @returns {boolean} - True if nodes should be patched
|
|
*/
|
|
shouldPatch(oldNode, newNode) {
|
|
// Different node types - replace
|
|
if (oldNode.nodeType !== newNode.nodeType) {
|
|
return false;
|
|
}
|
|
|
|
// Text nodes can always be patched
|
|
if (oldNode.nodeType === Node.TEXT_NODE) {
|
|
return true;
|
|
}
|
|
|
|
// Element nodes - check if same tag
|
|
if (oldNode.nodeType === Node.ELEMENT_NODE) {
|
|
if (oldNode.tagName !== newNode.tagName) {
|
|
return false;
|
|
}
|
|
|
|
// Check for special keys that indicate identity
|
|
const oldKey = oldNode.getAttribute('data-lc-key') || oldNode.getAttribute('id');
|
|
const newKey = newNode.getAttribute('data-lc-key') || newNode.getAttribute('id');
|
|
|
|
// If both have keys, they must match
|
|
if (oldKey && newKey) {
|
|
return oldKey === newKey;
|
|
}
|
|
|
|
// Otherwise, assume they match (same tag is enough)
|
|
return true;
|
|
}
|
|
|
|
// Other node types - replace
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Preserve focus state before patching
|
|
*
|
|
* Returns a function to restore focus after patching.
|
|
*
|
|
* @param {HTMLElement} container - Container being patched
|
|
* @returns {Function} - Restore function
|
|
*/
|
|
preserveFocus(container) {
|
|
const activeElement = document.activeElement;
|
|
|
|
// Check if focused element is within container
|
|
if (!activeElement || !container.contains(activeElement)) {
|
|
return () => {}; // No-op restore
|
|
}
|
|
|
|
// Get selector for focused element
|
|
const selector = this.getElementSelector(activeElement);
|
|
const selectionStart = activeElement.selectionStart;
|
|
const selectionEnd = activeElement.selectionEnd;
|
|
|
|
// Return restore function
|
|
return () => {
|
|
try {
|
|
if (selector) {
|
|
const element = container.querySelector(selector);
|
|
if (element && element.focus) {
|
|
element.focus();
|
|
|
|
// Restore selection for input/textarea
|
|
if (element.setSelectionRange &&
|
|
typeof selectionStart === 'number' &&
|
|
typeof selectionEnd === 'number') {
|
|
element.setSelectionRange(selectionStart, selectionEnd);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Focus restoration failed - not critical
|
|
console.debug('[DomPatcher] Could not restore focus:', e);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get a selector for an element
|
|
*
|
|
* Tries to create a unique selector using ID, name, or data attributes.
|
|
*
|
|
* @param {HTMLElement} element - Element to get selector for
|
|
* @returns {string|null} - CSS selector or null
|
|
*/
|
|
getElementSelector(element) {
|
|
if (element.id) {
|
|
return `#${element.id}`;
|
|
}
|
|
|
|
if (element.name) {
|
|
return `[name="${element.name}"]`;
|
|
}
|
|
|
|
const lcKey = element.getAttribute('data-lc-key');
|
|
if (lcKey) {
|
|
return `[data-lc-key="${lcKey}"]`;
|
|
}
|
|
|
|
// Fallback - not guaranteed to be unique
|
|
return element.tagName.toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Swap element content using different strategies
|
|
*
|
|
* Supports multiple swap strategies similar to htmx:
|
|
* - innerHTML: Replace inner content (default)
|
|
* - outerHTML: Replace element itself
|
|
* - beforebegin: Insert before element
|
|
* - afterbegin: Insert at start of element
|
|
* - afterend: Insert after element
|
|
* - beforeend: Insert at end of element
|
|
* - none: No DOM update (only events/state)
|
|
*
|
|
* @param {HTMLElement} target - Target element to swap
|
|
* @param {string} html - HTML content to swap
|
|
* @param {string} strategy - Swap strategy (default: 'innerHTML')
|
|
* @param {string} transition - Optional transition type (fade, slide, none)
|
|
* @param {Object} scrollOptions - Optional scroll options { enabled, target, behavior }
|
|
* @returns {boolean} - True if swap was successful
|
|
*/
|
|
swapElement(target, html, strategy = 'innerHTML', transition = null, scrollOptions = null) {
|
|
if (!target || !target.parentNode) {
|
|
console.warn('[DomPatcher] Invalid target element for swap');
|
|
return false;
|
|
}
|
|
|
|
// Preserve focus state before swap
|
|
const restoreFocus = this.preserveFocus(target);
|
|
|
|
// Apply transition if specified
|
|
if (transition && transition !== 'none') {
|
|
this.applyTransition(target, transition, strategy);
|
|
}
|
|
|
|
// Parse HTML into document fragment
|
|
const temp = document.createElement('div');
|
|
temp.innerHTML = html;
|
|
const newContent = temp.firstElementChild || temp;
|
|
|
|
try {
|
|
switch (strategy) {
|
|
case 'innerHTML':
|
|
// Replace inner content (default behavior)
|
|
target.innerHTML = html;
|
|
break;
|
|
|
|
case 'outerHTML':
|
|
// Replace element itself
|
|
if (newContent.nodeType === Node.ELEMENT_NODE) {
|
|
target.parentNode.replaceChild(newContent.cloneNode(true), target);
|
|
} else {
|
|
// If HTML doesn't have a single root element, wrap it
|
|
const wrapper = document.createElement('div');
|
|
wrapper.innerHTML = html;
|
|
while (wrapper.firstChild) {
|
|
target.parentNode.insertBefore(wrapper.firstChild, target);
|
|
}
|
|
target.parentNode.removeChild(target);
|
|
}
|
|
break;
|
|
|
|
case 'beforebegin':
|
|
// Insert before target element
|
|
if (newContent.nodeType === Node.ELEMENT_NODE) {
|
|
target.parentNode.insertBefore(newContent.cloneNode(true), target);
|
|
} else {
|
|
// Multiple nodes - insert all
|
|
const wrapper = document.createElement('div');
|
|
wrapper.innerHTML = html;
|
|
while (wrapper.firstChild) {
|
|
target.parentNode.insertBefore(wrapper.firstChild, target);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'afterbegin':
|
|
// Insert at start of target element
|
|
if (newContent.nodeType === Node.ELEMENT_NODE) {
|
|
target.insertBefore(newContent.cloneNode(true), target.firstChild);
|
|
} else {
|
|
// Multiple nodes - insert all at start
|
|
const wrapper = document.createElement('div');
|
|
wrapper.innerHTML = html;
|
|
while (wrapper.firstChild) {
|
|
target.insertBefore(wrapper.firstChild, target.firstChild);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'afterend':
|
|
// Insert after target element
|
|
if (newContent.nodeType === Node.ELEMENT_NODE) {
|
|
target.parentNode.insertBefore(newContent.cloneNode(true), target.nextSibling);
|
|
} else {
|
|
// Multiple nodes - insert all after
|
|
const wrapper = document.createElement('div');
|
|
wrapper.innerHTML = html;
|
|
const nextSibling = target.nextSibling;
|
|
while (wrapper.firstChild) {
|
|
target.parentNode.insertBefore(wrapper.firstChild, nextSibling);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'beforeend':
|
|
// Insert at end of target element
|
|
if (newContent.nodeType === Node.ELEMENT_NODE) {
|
|
target.appendChild(newContent.cloneNode(true));
|
|
} else {
|
|
// Multiple nodes - append all
|
|
const wrapper = document.createElement('div');
|
|
wrapper.innerHTML = html;
|
|
while (wrapper.firstChild) {
|
|
target.appendChild(wrapper.firstChild);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'none':
|
|
// No DOM update - only events/state will be handled
|
|
// This is handled by the caller, so we just return success
|
|
return true;
|
|
|
|
default:
|
|
console.warn(`[DomPatcher] Unknown swap strategy: ${strategy}, falling back to innerHTML`);
|
|
target.innerHTML = html;
|
|
break;
|
|
}
|
|
|
|
// Restore focus after swap (only for strategies that don't remove the target)
|
|
if (strategy !== 'outerHTML' && strategy !== 'none') {
|
|
restoreFocus();
|
|
}
|
|
|
|
// Handle scroll behavior if specified
|
|
if (scrollOptions && scrollOptions.enabled) {
|
|
this.scrollToTarget(scrollOptions.target || target, scrollOptions.behavior || 'smooth');
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error('[DomPatcher] Error during swap:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply CSS transition to element
|
|
*
|
|
* @param {HTMLElement} element - Element to apply transition to
|
|
* @param {string} transition - Transition type (fade, slide)
|
|
* @param {string} strategy - Swap strategy
|
|
*/
|
|
applyTransition(element, transition, strategy) {
|
|
// Add transition class
|
|
const transitionClass = `lc-transition-${transition}`;
|
|
|
|
// For fade/slide transitions, we need to apply the transition class
|
|
// and then trigger the transition by adding an active class
|
|
element.classList.add(transitionClass);
|
|
|
|
// Force reflow to ensure class is applied
|
|
void element.offsetWidth;
|
|
|
|
// Add active class to trigger transition
|
|
requestAnimationFrame(() => {
|
|
element.classList.add('lc-transition-active');
|
|
});
|
|
|
|
// Remove transition classes after animation completes
|
|
const handleTransitionEnd = (e) => {
|
|
// Only handle transition end for this element
|
|
if (e.target === element) {
|
|
element.classList.remove(transitionClass);
|
|
element.classList.remove('lc-transition-active');
|
|
element.removeEventListener('transitionend', handleTransitionEnd);
|
|
}
|
|
};
|
|
|
|
element.addEventListener('transitionend', handleTransitionEnd, { once: true });
|
|
}
|
|
|
|
/**
|
|
* Scroll to target element
|
|
*
|
|
* @param {HTMLElement|string} target - Target element or selector
|
|
* @param {string} behavior - Scroll behavior (smooth, instant)
|
|
*/
|
|
scrollToTarget(target, behavior = 'smooth') {
|
|
let targetElement = target;
|
|
|
|
// If target is a string, try to find element
|
|
if (typeof target === 'string') {
|
|
targetElement = document.querySelector(target);
|
|
}
|
|
|
|
if (!targetElement || !(targetElement instanceof HTMLElement)) {
|
|
console.warn('[DomPatcher] Scroll target not found:', target);
|
|
return;
|
|
}
|
|
|
|
// Scroll to element
|
|
targetElement.scrollIntoView({
|
|
behavior: behavior === 'smooth' ? 'smooth' : 'auto',
|
|
block: 'start',
|
|
inline: 'nearest'
|
|
});
|
|
}
|
|
}
|
|
|
|
// Create singleton instance
|
|
export const domPatcher = new DomPatcher();
|
|
|
|
export default domPatcher;
|