Files
michaelschiemer/resources/js/modules/livecomponent/DomPatcher.js
2025-11-24 21:28:25 +01:00

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;