1import { Controller } from '@hotwired/stimulus'; 2 3class BackendRequest { 4 constructor(promise, actions, updateModels) { 5 this.isResolved = false; 6 this.promise = promise; 7 this.promise.then((response) => { 8 this.isResolved = true; 9 return response; 10 }); 11 this.actions = actions; 12 this.updatedModels = updateModels; 13 } 14 containsOneOfActions(targetedActions) { 15 return this.actions.filter((action) => targetedActions.includes(action)).length > 0; 16 } 17 areAnyModelsUpdated(targetedModels) { 18 return this.updatedModels.filter((model) => targetedModels.includes(model)).length > 0; 19 } 20} 21 22class RequestBuilder { 23 constructor(url, method = 'post') { 24 this.url = url; 25 this.method = method; 26 } 27 buildRequest(props, actions, updated, children, updatedPropsFromParent, files) { 28 const splitUrl = this.url.split('?'); 29 let [url] = splitUrl; 30 const [, queryString] = splitUrl; 31 const params = new URLSearchParams(queryString || ''); 32 const fetchOptions = {}; 33 fetchOptions.headers = { 34 Accept: 'application/vnd.live-component+html', 35 'X-Requested-With': 'XMLHttpRequest', 36 }; 37 const totalFiles = Object.entries(files).reduce((total, current) => total + current.length, 0); 38 const hasFingerprints = Object.keys(children).length > 0; 39 if (actions.length === 0 && 40 totalFiles === 0 && 41 this.method === 'get' && 42 this.willDataFitInUrl(JSON.stringify(props), JSON.stringify(updated), params, JSON.stringify(children), JSON.stringify(updatedPropsFromParent))) { 43 params.set('props', JSON.stringify(props)); 44 params.set('updated', JSON.stringify(updated)); 45 if (Object.keys(updatedPropsFromParent).length > 0) { 46 params.set('propsFromParent', JSON.stringify(updatedPropsFromParent)); 47 } 48 if (hasFingerprints) { 49 params.set('children', JSON.stringify(children)); 50 } 51 fetchOptions.method = 'GET'; 52 } 53 else { 54 fetchOptions.method = 'POST'; 55 const requestData = { props, updated }; 56 if (Object.keys(updatedPropsFromParent).length > 0) { 57 requestData.propsFromParent = updatedPropsFromParent; 58 } 59 if (hasFingerprints) { 60 requestData.children = children; 61 } 62 if (actions.length > 0) { 63 if (actions.length === 1) { 64 requestData.args = actions[0].args; 65 url += `/${encodeURIComponent(actions[0].name)}`; 66 } 67 else { 68 url += '/_batch'; 69 requestData.actions = actions; 70 } 71 } 72 const formData = new FormData(); 73 formData.append('data', JSON.stringify(requestData)); 74 for (const [key, value] of Object.entries(files)) { 75 const length = value.length; 76 for (let i = 0; i < length; ++i) { 77 formData.append(key, value[i]); 78 } 79 } 80 fetchOptions.body = formData; 81 } 82 const paramsString = params.toString(); 83 return { 84 url: `${url}${paramsString.length > 0 ? `?${paramsString}` : ''}`, 85 fetchOptions, 86 }; 87 } 88 willDataFitInUrl(propsJson, updatedJson, params, childrenJson, propsFromParentJson) { 89 const urlEncodedJsonData = new URLSearchParams(propsJson + updatedJson + childrenJson + propsFromParentJson).toString(); 90 return (urlEncodedJsonData + params.toString()).length < 1500; 91 } 92} 93 94class Backend { 95 constructor(url, method = 'post') { 96 this.requestBuilder = new RequestBuilder(url, method); 97 } 98 makeRequest(props, actions, updated, children, updatedPropsFromParent, files) { 99 const { url, fetchOptions } = this.requestBuilder.buildRequest(props, actions, updated, children, updatedPropsFromParent, files); 100 return new BackendRequest(fetch(url, fetchOptions), actions.map((backendAction) => backendAction.name), Object.keys(updated)); 101 } 102} 103 104class BackendResponse { 105 constructor(response) { 106 this.response = response; 107 } 108 async getBody() { 109 if (!this.body) { 110 this.body = await this.response.text(); 111 } 112 return this.body; 113 } 114} 115 116function getElementAsTagText(element) { 117 return element.innerHTML 118 ? element.outerHTML.slice(0, element.outerHTML.indexOf(element.innerHTML)) 119 : element.outerHTML; 120} 121 122let componentMapByElement = new WeakMap(); 123let componentMapByComponent = new Map(); 124const registerComponent = (component) => { 125 componentMapByElement.set(component.element, component); 126 componentMapByComponent.set(component, component.name); 127}; 128const unregisterComponent = (component) => { 129 componentMapByElement.delete(component.element); 130 componentMapByComponent.delete(component); 131}; 132const getComponent = (element) => new Promise((resolve, reject) => {
133 let count = 0; 134 const maxCount = 10; 135 const interval = setInterval(() => { 136 const component = componentMapByElement.get(element); 137 if (component) { 138 clearInterval(interval); 139 resolve(component); 140 } 141 count++; 142 if (count > maxCount) { 143 clearInterval(interval); 144 reject(new Error(`Component not found for element ${getElementAsTagText(element)}`)); 145 } 146 }, 5); 147}); 148const findComponents = (currentComponent, onlyParents, onlyMatchName) => { 149 const components = []; 150 componentMapByComponent.forEach((componentName, component) => { 151 if (onlyParents && (currentComponent === component || !component.element.contains(currentComponent.element))) { 152 return; 153 } 154 if (onlyMatchName && componentName !== onlyMatchName) { 155 return; 156 } 157 components.push(component); 158 }); 159 return components; 160}; 161const findChildren = (currentComponent) => { 162 const children = []; 163 componentMapByComponent.forEach((componentName, component) => { 164 if (currentComponent === component) { 165 return; 166 } 167 if (!currentComponent.element.contains(component.element)) { 168 return; 169 } 170 let foundChildComponent = false; 171 componentMapByComponent.forEach((childComponentName, childComponent) => { 172 if (foundChildComponent) { 173 return; 174 } 175 if (childComponent === component) { 176 return; 177 } 178 if (childComponent.element.contains(component.element)) { 179 foundChildComponent = true; 180 } 181 }); 182 children.push(component); 183 }); 184 return children; 185}; 186const findParent = (currentComponent) => { 187 let parentElement = currentComponent.element.parentElement; 188 while (parentElement) { 189 const component = componentMapByElement.get(parentElement); 190 if (component) { 191 return component; 192 } 193 parentElement = parentElement.parentElement; 194 } 195 return null; 196}; 197 198class HookManager { 199 constructor() { 200 this.hooks = new Map(); 201 } 202 register(hookName, callback) { 203 const hooks = this.hooks.get(hookName) || []; 204 hooks.push(callback); 205 this.hooks.set(hookName, hooks); 206 } 207 unregister(hookName, callback) { 208 const hooks = this.hooks.get(hookName) || []; 209 const index = hooks.indexOf(callback); 210 if (index === -1) { 211 return; 212 } 213 hooks.splice(index, 1); 214 this.hooks.set(hookName, hooks); 215 } 216 triggerHook(hookName, ...args) { 217 const hooks = this.hooks.get(hookName) || []; 218 hooks.forEach((callback) => callback(...args)); 219 } 220} 221 222class ChangingItemsTracker { 223 constructor() { 224 this.changedItems = new Map(); 225 this.removedItems = new Map(); 226 } 227 setItem(itemName, newValue, previousValue) { 228 if (this.removedItems.has(itemName)) { 229 const removedRecord = this.removedItems.get(itemName); 230 this.removedItems.delete(itemName); 231 if (removedRecord.original === newValue) { 232 return; 233 } 234 } 235 if (this.changedItems.has(itemName)) { 236 const originalRecord = this.changedItems.get(itemName); 237 if (originalRecord.original === newValue) { 238 this.changedItems.delete(itemName); 239 return; 240 } 241 this.changedItems.set(itemName, { original: originalRecord.original, new: newValue }); 242 return; 243 } 244 this.changedItems.set(itemName, { original: previousValue, new: newValue }); 245 } 246 removeItem(itemName, currentValue) { 247 let trueOriginalValue = currentValue; 248 if (this.changedItems.has(itemName)) { 249 const originalRecord = this.changedItems.get(itemName); 250 trueOriginalValue = originalRecord.original; 251 this.changedItems.delete(itemName); 252 if (trueOriginalValue === null) { 253 return; 254 } 255 } 256 if (!this.removedItems.has(itemName)) { 257 this.removedItems.set(itemName, { original: trueOriginalValue }); 258 } 259 } 260 getChangedItems() { 261 return Array.from(this.changedItems, ([name, { new: value }]) => ({ name, value })); 262 } 263 getRemovedItems() { 264 return Array.from(this.removedItems.keys()); 265 } 266 isEmpty() { 267 return this.changedItems.size === 0 && this.removedItems.size === 0; 268 } 269} 270 271class ElementChanges { 272 constructor() {
273 this.addedClasses = new Set(); 274 this.removedClasses = new Set(); 275 this.styleChanges = new ChangingItemsTracker(); 276 this.attributeChanges = new ChangingItemsTracker(); 277 } 278 addClass(className) { 279 if (!this.removedClasses.delete(className)) { 280 this.addedClasses.add(className); 281 } 282 } 283 removeClass(className) { 284 if (!this.addedClasses.delete(className)) { 285 this.removedClasses.add(className); 286 } 287 } 288 addStyle(styleName, newValue, originalValue) { 289 this.styleChanges.setItem(styleName, newValue, originalValue); 290 } 291 removeStyle(styleName, originalValue) { 292 this.styleChanges.removeItem(styleName, originalValue); 293 } 294 addAttribute(attributeName, newValue, originalValue) { 295 this.attributeChanges.setItem(attributeName, newValue, originalValue); 296 } 297 removeAttribute(attributeName, originalValue) { 298 this.attributeChanges.removeItem(attributeName, originalValue); 299 } 300 getAddedClasses() { 301 return [...this.addedClasses]; 302 } 303 getRemovedClasses() { 304 return [...this.removedClasses]; 305 } 306 getChangedStyles() { 307 return this.styleChanges.getChangedItems(); 308 } 309 getRemovedStyles() { 310 return this.styleChanges.getRemovedItems(); 311 } 312 getChangedAttributes() { 313 return this.attributeChanges.getChangedItems(); 314 } 315 getRemovedAttributes() { 316 return this.attributeChanges.getRemovedItems(); 317 } 318 applyToElement(element) { 319 element.classList.add(...this.addedClasses); 320 element.classList.remove(...this.removedClasses);
321 this.styleChanges.getChangedItems().forEach((change) => { 322 element.style.setProperty(change.name, change.value); 323 return; 324 }); 325 this.styleChanges.getRemovedItems().forEach((styleName) => { 326 element.style.removeProperty(styleName); 327 }); 328 this.attributeChanges.getChangedItems().forEach((change) => { 329 element.setAttribute(change.name, change.value); 330 }); 331 this.attributeChanges.getRemovedItems().forEach((attributeName) => { 332 element.removeAttribute(attributeName); 333 }); 334 } 335 isEmpty() { 336 return (this.addedClasses.size === 0 && 337 this.removedClasses.size === 0 && 338 this.styleChanges.isEmpty() && 339 this.attributeChanges.isEmpty()); 340 } 341} 342 343class ExternalMutationTracker { 344 constructor(element, shouldTrackChangeCallback) { 345 this.changedElements = new WeakMap(); 346 this.changedElementsCount = 0; 347 this.addedElements = []; 348 this.removedElements = []; 349 this.isStarted = false; 350 this.element = element; 351 this.shouldTrackChangeCallback = shouldTrackChangeCallback; 352 this.mutationObserver = new MutationObserver(this.onMutations.bind(this)); 353 } 354 start() { 355 if (this.isStarted) { 356 return; 357 } 358 this.mutationObserver.observe(this.element, { 359 childList: true, 360 subtree: true, 361 attributes: true, 362 attributeOldValue: true, 363 }); 364 this.isStarted = true; 365 } 366 stop() { 367 if (this.isStarted) { 368 this.mutationObserver.disconnect(); 369 this.isStarted = false; 370 } 371 } 372 getChangedElement(element) { 373 return this.changedElements.has(element) ? this.changedElements.get(element) : null; 374 } 375 getAddedElements() { 376 return this.addedElements; 377 } 378 wasElementAdded(element) { 379 return this.addedElements.includes(element); 380 } 381 handlePendingChanges() { 382 this.onMutations(this.mutationObserver.takeRecords()); 383 } 384 onMutations(mutations) { 385 const handledAttributeMutations = new WeakMap(); 386 for (const mutation of mutations) { 387 const element = mutation.target; 388 if (!this.shouldTrackChangeCallback(element)) { 389 continue; 390 } 391 if (this.isElementAddedByTranslation(element)) { 392 continue; 393 } 394 let isChangeInAddedElement = false; 395 for (const addedElement of this.addedElements) { 396 if (addedElement.contains(element)) { 397 isChangeInAddedElement = true; 398 break; 399 } 400 } 401 if (isChangeInAddedElement) { 402 continue; 403 } 404 switch (mutation.type) { 405 case 'childList': 406 this.handleChildListMutation(mutation); 407 break; 408 case 'attributes': 409 if (!handledAttributeMutations.has(element)) { 410 handledAttributeMutations.set(element, []); 411 } 412 if (!handledAttributeMutations.get(element).includes(mutation.attributeName)) { 413 this.handleAttributeMutation(mutation); 414 handledAttributeMutations.set(element, [ 415 ...handledAttributeMutations.get(element), 416 mutation.attributeName, 417 ]); 418 } 419 break; 420 } 421 } 422 } 423 handleChildListMutation(mutation) { 424 mutation.addedNodes.forEach((node) => { 425 if (!(node instanceof Element)) { 426 return; 427 } 428 if (this.removedElements.includes(node)) { 429 this.removedElements.splice(this.removedElements.indexOf(node), 1); 430 return; 431 } 432 if (this.isElementAddedByTranslation(node)) { 433 return; 434 } 435 this.addedElements.push(node); 436 });
437 mutation.removedNodes.forEach((node) => { 438 if (!(node instanceof Element)) { 439 return; 440 } 441 if (this.addedElements.includes(node)) { 442 this.addedElements.splice(this.addedElements.indexOf(node), 1); 443 return; 444 } 445 this.removedElements.push(node); 446 }); 447 } 448 handleAttributeMutation(mutation) { 449 const element = mutation.target; 450 if (!this.changedElements.has(element)) { 451 this.changedElements.set(element, new ElementChanges()); 452 this.changedElementsCount++; 453 } 454 const changedElement = this.changedElements.get(element); 455 switch (mutation.attributeName) { 456 case 'class': 457 this.handleClassAttributeMutation(mutation, changedElement); 458 break; 459 case 'style': 460 this.handleStyleAttributeMutation(mutation, changedElement); 461 break; 462 default: 463 this.handleGenericAttributeMutation(mutation, changedElement); 464 } 465 if (changedElement.isEmpty()) { 466 this.changedElements.delete(element); 467 this.changedElementsCount--; 468 } 469 } 470 handleClassAttributeMutation(mutation, elementChanges) { 471 const element = mutation.target; 472 const previousValue = mutation.oldValue || ''; 473 const previousValues = previousValue.match(/(\S+)/gu) || []; 474 const newValues = [].slice.call(element.classList); 475 const addedValues = newValues.filter((value) => !previousValues.includes(value)); 476 const removedValues = previousValues.filter((value) => !newValues.includes(value)); 477 addedValues.forEach((value) => { 478 elementChanges.addClass(value); 479 }); 480 removedValues.forEach((value) => { 481 elementChanges.removeClass(value); 482 }); 483 } 484 handleStyleAttributeMutation(mutation, elementChanges) { 485 const element = mutation.target; 486 const previousValue = mutation.oldValue || ''; 487 const previousStyles = this.extractStyles(previousValue); 488 const newValue = element.getAttribute('style') || ''; 489 const newStyles = this.extractStyles(newValue); 490 const addedOrChangedStyles = Object.keys(newStyles).filter((key) => previousStyles[key] === undefined || previousStyles[key] !== newStyles[key]); 491 const removedStyles = Object.keys(previousStyles).filter((key) => !newStyles[key]); 492 addedOrChangedStyles.forEach((style) => { 493 elementChanges.addStyle(style, newStyles[style], previousStyles[style] === undefined ? null : previousStyles[style]); 494 }); 495 removedStyles.forEach((style) => { 496 elementChanges.removeStyle(style, previousStyles[style]); 497 }); 498 } 499 handleGenericAttributeMutation(mutation, elementChanges) { 500 const attributeName = mutation.attributeName; 501 const element = mutation.target; 502 let oldValue = mutation.oldValue; 503 let newValue = element.getAttribute(attributeName); 504 if (oldValue === attributeName) { 505 oldValue = ''; 506 } 507 if (newValue === attributeName) { 508 newValue = ''; 509 } 510 if (!element.hasAttribute(attributeName)) { 511 if (oldValue === null) { 512 return; 513 } 514 elementChanges.removeAttribute(attributeName, mutation.oldValue); 515 return; 516 } 517 if (newValue === oldValue) { 518 return; 519 } 520 elementChanges.addAttribute(attributeName, element.getAttribute(attributeName), mutation.oldValue); 521 } 522 extractStyles(styles) { 523 const styleObject = {}; 524 styles.split(';').forEach((style) => { 525 const parts = style.split(':'); 526 if (parts.length === 1) { 527 return; 528 } 529 const property = parts[0].trim(); 530 styleObject[property] = parts.slice(1).join(':').trim(); 531 }); 532 return styleObject; 533 } 534 isElementAddedByTranslation(element) { 535 return element.tagName === 'FONT' && element.getAttribute('style') === 'vertical-align: inherit;'; 536 } 537} 538 539function parseDirectives(content) { 540 const directives = []; 541 if (!content) { 542 return directives; 543 }
544 let currentActionName = ''; 545 let currentArgumentValue = ''; 546 let currentArguments = []; 547 let currentModifiers = []; 548 let state = 'action'; 549 const getLastActionName = () => { 550 if (currentActionName) { 551 return currentActionName; 552 } 553 if (directives.length === 0) { 554 throw new Error('Could not find any directives'); 555 } 556 return directives[directives.length - 1].action; 557 }; 558 const pushInstruction = () => { 559 directives.push({ 560 action: currentActionName, 561 args: currentArguments, 562 modifiers: currentModifiers, 563 getString: () => { 564 return content; 565 }, 566 }); 567 currentActionName = ''; 568 currentArgumentValue = ''; 569 currentArguments = []; 570 currentModifiers = []; 571 state = 'action'; 572 }; 573 const pushArgument = () => { 574 currentArguments.push(currentArgumentValue.trim()); 575 currentArgumentValue = ''; 576 }; 577 const pushModifier = () => { 578 if (currentArguments.length > 1) { 579 throw new Error(`The modifier "${currentActionName}()" does not support multiple arguments.`); 580 } 581 currentModifiers.push({ 582 name: currentActionName, 583 value: currentArguments.length > 0 ? currentArguments[0] : null, 584 }); 585 currentActionName = ''; 586 currentArguments = []; 587 state = 'action'; 588 }; 589 for (let i = 0; i < content.length; i++) { 590 const char = content[i]; 591 switch (state) { 592 case 'action': 593 if (char === '(') { 594 state = 'arguments'; 595 break; 596 } 597 if (char === ' ') { 598 if (currentActionName) { 599 pushInstruction(); 600 } 601 break; 602 } 603 if (char === '|') { 604 pushModifier(); 605 break; 606 } 607 currentActionName += char; 608 break; 609 case 'arguments': 610 if (char === ')') { 611 pushArgument(); 612 state = 'after_arguments'; 613 break; 614 } 615 if (char === ',') { 616 pushArgument(); 617 break; 618 } 619 currentArgumentValue += char; 620 break; 621 case 'after_arguments': 622 if (char === '|') { 623 pushModifier(); 624 break; 625 } 626 if (char !== ' ') { 627 throw new Error(`Missing space after ${getLastActionName()}()`); 628 } 629 pushInstruction(); 630 break; 631 } 632 } 633 switch (state) { 634 case 'action': 635 case 'after_arguments': 636 if (currentActionName) { 637 pushInstruction(); 638 } 639 break; 640 default: 641 throw new Error(`Did you forget to add a closing ")" after "${currentActionName}"?`); 642 } 643 return directives; 644} 645 646function combineSpacedArray(parts) { 647 const finalParts = []; 648 parts.forEach((part) => { 649 finalParts.push(...trimAll(part).split(' ')); 650 }); 651 return finalParts; 652} 653function trimAll(str) { 654 return str.replace(/[\s]+/g, ' ').trim(); 655} 656function normalizeModelName(model) { 657 return (model 658 .replace(/\[]$/, '') 659 .split('[') 660 .map((s) => s.replace(']', '')) 661 .join('.')); 662} 663 664function getValueFromElement(element, valueStore) { 665 if (element instanceof HTMLInputElement) { 666 if (element.type === 'checkbox') { 667 const modelNameData = getModelDirectiveFromElement(element, false); 668 if (modelNameData !== null) { 669 const modelValue = valueStore.get(modelNameData.action); 670 if (Array.isArray(modelValue)) { 671 return getMultipleCheckboxValue(element, modelValue); 672 } 673 if (Object(modelValue) === modelValue) { 674 return getMultipleCheckboxValue(element, Object.values(modelValue)); 675 } 676 } 677 if (element.hasAttribute('value')) { 678 return element.checked ? element.getAttribute('value') : null; 679 } 680 return element.checked; 681 } 682 return inputValue(element); 683 } 684 if (element instanceof HTMLSelectElement) { 685 if (element.multiple) { 686 return Array.from(element.selectedOptions).map((el) => el.value); 687 } 688 return element.value; 689 } 690 if (element.dataset.value) { 691 return element.dataset.value; 692 } 693 if ('value' in element) { 694 return element.value; 695 } 696 if (element.hasAttribute('value')) { 697 return element.getAttribute('value'); 698 } 699 return null; 700} 701function setValueOnElement(element, value) { 702 if (element instanceof HTMLInputElement) { 703 if (element.type === 'file') { 704 return; 705 } 706 if (element.type === 'radio') { 707 element.checked = element.value == value; 708 return; 709 } 710 if (element.type === 'checkbox') { 711 if (Array.isArray(value)) {
712 element.checked = value.some((val) => val == element.value); 713 } 714 else if (element.hasAttribute('value')) { 715 element.checked = element.value == value; 716 } 717 else { 718 element.checked = value; 719 } 720 return; 721 } 722 } 723 if (element instanceof HTMLSelectElement) { 724 const arrayWrappedValue = [].concat(value).map((value) => { 725 return `${value}`; 726 }); 727 Array.from(element.options).forEach((option) => { 728 option.selected = arrayWrappedValue.includes(option.value); 729 }); 730 return; 731 } 732 value = value === undefined ? '' : value; 733 element.value = value; 734} 735function getAllModelDirectiveFromElements(element) { 736 if (!element.dataset.model) { 737 return []; 738 } 739 const directives = parseDirectives(element.dataset.model); 740 directives.forEach((directive) => { 741 if (directive.args.length > 0) { 742 throw new Error(`The data-model="${element.dataset.model}" format is invalid: it does not support passing arguments to the model.`); 743 } 744 directive.action = normalizeModelName(directive.action); 745 }); 746 return directives; 747} 748function getModelDirectiveFromElement(element, throwOnMissing = true) { 749 const dataModelDirectives = getAllModelDirectiveFromElements(element); 750 if (dataModelDirectives.length > 0) { 751 return dataModelDirectives[0]; 752 } 753 if (element.getAttribute('name')) { 754 const formElement = element.closest('form'); 755 if (formElement && 'model' in formElement.dataset) { 756 const directives = parseDirectives(formElement.dataset.model || '*'); 757 const directive = directives[0]; 758 if (directive.args.length > 0) { 759 throw new Error(`The data-model="${formElement.dataset.model}" format is invalid: it does not support passing arguments to the model.`); 760 } 761 directive.action = normalizeModelName(element.getAttribute('name')); 762 return directive; 763 } 764 } 765 if (!throwOnMissing) { 766 return null; 767 } 768 throw new Error(`Cannot determine the model name for "${getElementAsTagText(element)}": the element must either have a "data-model" (or "name" attribute living inside a <form data-model="*">).`); 769} 770function elementBelongsToThisComponent(element, component) { 771 if (component.element === element) { 772 return true; 773 } 774 if (!component.element.contains(element)) { 775 return false; 776 } 777 const closestLiveComponent = element.closest('[data-controller~="live"]'); 778 return closestLiveComponent === component.element; 779} 780function cloneHTMLElement(element) { 781 const newElement = element.cloneNode(true); 782 if (!(newElement instanceof HTMLElement)) { 783 throw new Error('Could not clone element'); 784 } 785 return newElement; 786} 787function htmlToElement(html) { 788 const template = document.createElement('template'); 789 html = html.trim(); 790 template.innerHTML = html; 791 if (template.content.childElementCount > 1) { 792 throw new Error(`Component HTML contains ${template.content.childElementCount} elements, but only 1 root element is allowed.`); 793 } 794 const child = template.content.firstElementChild; 795 if (!child) { 796 throw new Error('Child not found'); 797 } 798 if (!(child instanceof HTMLElement)) { 799 throw new Error(`Created element is not an HTMLElement: ${html.trim()}`); 800 } 801 return child; 802} 803const getMultipleCheckboxValue = (element, currentValues) => { 804 const finalValues = [...currentValues]; 805 const value = inputValue(element); 806 const index = currentValues.indexOf(value); 807 if (element.checked) { 808 if (index === -1) { 809 finalValues.push(value); 810 } 811 return finalValues; 812 } 813 if (index > -1) { 814 finalValues.splice(index, 1); 815 } 816 return finalValues; 817}; 818const inputValue = (element) => element.dataset.value ? element.dataset.value : element.value; 819 820// base IIFE to define idiomorph 821var Idiomorph = (function () { 822 823 //============================================================================= 824 // AND NOW IT BEGINS... 825 //============================================================================= 826 let EMPTY_SET = new Set(); 827 828 // default configuration values, updatable by users now 829 let defaults = { 830 morphStyle: "outerHTML", 831 callbacks : { 832 beforeNodeAdded: noOp, 833 afterNodeAdded: noOp, 834 beforeNodeMorphed: noOp, 835 afterNodeMorphed: noOp, 836 beforeNodeRemoved: noOp, 837 afterNodeRemoved: noOp, 838 beforeAttributeUpdated: noOp, 839 840 }, 841 head: { 842 style: 'merge', 843 shouldPreserve: function (elt) { 844 return elt.getAttribute("im-preserve") === "true"; 845 }, 846 shouldReAppend: function (elt) { 847 return elt.getAttribute("im-re-append") === "true"; 848 }, 849 shouldRemove: noOp, 850 afterHeadMorphed: noOp, 851 } 852 }; 853 854 //============================================================================= 855 // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren 856 //============================================================================= 857 function morph(oldNode, newContent, config = {}) { 858 859 if (oldNode instanceof Document) { 860 oldNode = oldNode.documentElement; 861 } 862 863 if (typeof newContent === 'string') { 864 newContent = parseContent(newContent); 865 } 866 867 let normalizedContent = normalizeContent(newContent); 868 869 let ctx = createMorphContext(oldNode, normalizedContent, config); 870 871 return morphNormalizedContent(oldNode, normalizedContent, ctx); 872 } 873 874 function morphNormalizedContent(oldNode, normalizedNewContent, ctx) { 875 if (ctx.head.block) { 876 let oldHead = oldNode.querySelector('head'); 877 let newHead = normalizedNewContent.querySelector('head'); 878 if (oldHead && newHead) { 879 let promises = handleHeadElement(newHead, oldHead, ctx); 880 // when head promises resolve, call morph again, ignoring the head tag 881 Promise.all(promises).then(function () { 882 morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, { 883 head: { 884 block: false, 885 ignore: true 886 } 887 })); 888 }); 889 return; 890 } 891 } 892 893 if (ctx.morphStyle === "innerHTML") { 894 895 // innerHTML, so we are only updating the children 896 morphChildren(normalizedNewContent, oldNode, ctx); 897 return oldNode.children; 898 899 } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) { 900 // otherwise find the best element match in the new content, morph that, and merge its siblings 901 // into either side of the best match 902 let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx); 903 904 // stash the siblings that will need to be inserted on either side of the best match 905 let previousSibling = bestMatch?.previousSibling; 906 let nextSibling = bestMatch?.nextSibling; 907 908 // morph it 909 let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx); 910 911 if (bestMatch) { 912 // if there was a best match, merge the siblings in too and return the 913 // whole bunch 914 return insertSiblings(previousSibling, morphedNode, nextSibling); 915 } else { 916 // otherwise nothing was added to the DOM 917 return [] 918 } 919 } else { 920 throw "Do not understand how to morph style " + ctx.morphStyle; 921 } 922 } 923 924 925 /** 926 * @param possibleActiveElement 927 * @param ctx 928 * @returns {boolean} 929 */
930 function ignoreValueOfActiveElement(possibleActiveElement, ctx) { 931 return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement; 932 } 933 934 /** 935 * @param oldNode root node to merge content into 936 * @param newContent new content to merge 937 * @param ctx the merge context 938 * @returns {Element} the element that ended up in the DOM 939 */ 940 function morphOldNodeTo(oldNode, newContent, ctx) { 941 if (ctx.ignoreActive && oldNode === document.activeElement) ; else if (newContent == null) { 942 if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode; 943 944 oldNode.remove(); 945 ctx.callbacks.afterNodeRemoved(oldNode); 946 return null; 947 } else if (!isSoftMatch(oldNode, newContent)) { 948 if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode; 949 if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode; 950 951 oldNode.parentElement.replaceChild(newContent, oldNode); 952 ctx.callbacks.afterNodeAdded(newContent); 953 ctx.callbacks.afterNodeRemoved(oldNode); 954 return newContent; 955 } else { 956 if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode; 957 958 if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") { 959 handleHeadElement(newContent, oldNode, ctx); 960 } else { 961 syncNodeFrom(newContent, oldNode, ctx); 962 if (!ignoreValueOfActiveElement(oldNode, ctx)) { 963 morphChildren(newContent, oldNode, ctx); 964 } 965 } 966 ctx.callbacks.afterNodeMorphed(oldNode, newContent); 967 return oldNode; 968 } 969 } 970 971 /** 972 * This is the core algorithm for matching up children. The idea is to use id sets to try to match up 973 * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but 974 * by using id sets, we are able to better match up with content deeper in the DOM. 975 * 976 * Basic algorithm is, for each node in the new content: 977 * 978 * - if we have reached the end of the old parent, append the new content 979 * - if the new content has an id set match with the current insertion point, morph 980 * - search for an id set match 981 * - if id set match found, morph 982 * - otherwise search for a "soft" match 983 * - if a soft match is found, morph 984 * - otherwise, prepend the new node before the current insertion point 985 * 986 * The two search algorithms terminate if competing node matches appear to outweigh what can be achieved 987 * with the current node. See findIdSetMatch() and findSoftMatch() for details. 988 * 989 * @param {Element} newParent the parent element of the new content 990 * @param {Element } oldParent the old content that we are merging the new content into 991 * @param ctx the merge context 992 */ 993 function morphChildren(newParent, oldParent, ctx) { 994 995 let nextNewChild = newParent.firstChild; 996 let insertionPoint = oldParent.firstChild; 997 let newChild; 998 999 // run through all the new content 1000 while (nextNewChild) { 1001 1002 newChild = nextNewChild; 1003 nextNewChild = newChild.nextSibling; 1004 1005 // if we are at the end of the exiting parent's children, just append 1006 if (insertionPoint == null) { 1007 if (ctx.callbacks.beforeNodeAdded(newChild) === false) return; 1008 1009 oldParent.appendChild(newChild); 1010 ctx.callbacks.afterNodeAdded(newChild); 1011 removeIdsFromConsideration(ctx, newChild); 1012 continue; 1013 } 1014 1015 // if the current node has an id set match then morph 1016 if (isIdSetMatch(newChild, insertionPoint, ctx)) { 1017 morphOldNodeTo(insertionPoint, newChild, ctx); 1018 insertionPoint = insertionPoint.nextSibling; 1019 removeIdsFromConsideration(ctx, newChild); 1020 continue; 1021 } 1022 1023 // otherwise search forward in the existing old children for an id set match 1024 let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx); 1025 1026 // if we found a potential match, remove the nodes until that point and morph 1027 if (idSetMatch) { 1028 insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx); 1029 morphOldNodeTo(idSetMatch, newChild, ctx); 1030 removeIdsFromConsideration(ctx, newChild); 1031 continue; 1032 } 1033 1034 // no id set match found, so scan forward for a soft match for the current node 1035 let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx); 1036 1037 // if we found a soft match for the current node, morph 1038 if (softMatch) { 1039 insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx); 1040 morphOldNodeTo(softMatch, newChild, ctx); 1041 removeIdsFromConsideration(ctx, newChild); 1042 continue; 1043 } 1044 1045 // abandon all hope of morphing, just insert the new child before the insertion point 1046 // and move on 1047 if (ctx.callbacks.beforeNodeAdded(newChild) === false) return; 1048 1049 oldParent.insertBefore(newChild, insertionPoint); 1050 ctx.callbacks.afterNodeAdded(newChild); 1051 removeIdsFromConsideration(ctx, newChild); 1052 } 1053 1054 // remove any remaining old nodes that didn't match up with
1054new content 1055 while (insertionPoint !== null) { 1056 1057 let tempNode = insertionPoint; 1058 insertionPoint = insertionPoint.nextSibling; 1059 removeNode(tempNode, ctx); 1060 } 1061 } 1062 1063 //============================================================================= 1064 // Attribute Syncing Code 1065 //============================================================================= 1066 1067 /** 1068 * @param attr {String} the attribute to be mutated 1069 * @param to {Element} the element that is going to be updated 1070 * @param updateType {("update"|"remove")} 1071 * @param ctx the merge context 1072 * @returns {boolean} true if the attribute should be ignored, false otherwise 1073 */ 1074 function ignoreAttribute(attr, to, updateType, ctx) { 1075 if(attr === 'value' && ctx.ignoreActiveValue && to === document.activeElement){ 1076 return true; 1077 } 1078 return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false; 1079 } 1080 1081 /** 1082 * syncs a given node with another node, copying over all attributes and 1083 * inner element state from the 'from' node to the 'to' node 1084 * 1085 * @param {Element} from the element to copy attributes & state from 1086 * @param {Element} to the element to copy attributes & state to 1087 * @param ctx the merge context 1088 */ 1089 function syncNodeFrom(from, to, ctx) { 1090 let type = from.nodeType; 1091 1092 // if is an element type, sync the attributes from the 1093 // new node into the new node 1094 if (type === 1 /* element type */) { 1095 const fromAttributes = from.attributes; 1096 const toAttributes = to.attributes; 1097 for (const fromAttribute of fromAttributes) { 1098 if (ignoreAttribute(fromAttribute.name, to, 'update', ctx)) { 1099 continue; 1100 } 1101 if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) { 1102 to.setAttribute(fromAttribute.name, fromAttribute.value); 1103 } 1104 } 1105 // iterate backwards to avoid skipping over items when a delete occurs 1106 for (let i = toAttributes.length - 1; 0 <= i; i--) { 1107 const toAttribute = toAttributes[i]; 1108 if (ignoreAttribute(toAttribute.name, to, 'remove', ctx)) { 1109 continue; 1110 } 1111 if (!from.hasAttribute(toAttribute.name)) { 1112 to.removeAttribute(toAttribute.name); 1113 } 1114 } 1115 } 1116 1117 // sync text nodes 1118 if (type === 8 /* comment */ || type === 3 /* text */) { 1119 if (to.nodeValue !== from.nodeValue) { 1120 to.nodeValue = from.nodeValue; 1121 } 1122 } 1123 1124 if (!ignoreValueOfActiveElement(to, ctx)) { 1125 // sync input values 1126 syncInputValue(from, to, ctx); 1127 } 1128 } 1129 1130 /** 1131 * @param from {Element} element to sync the value from 1132 * @param to {Element} element to sync the value to 1133 * @param attributeName {String} the attribute name 1134 * @param ctx the merge context 1135 */ 1136 function syncBooleanAttribute(from, to, attributeName, ctx) { 1137 if (from[attributeName] !== to[attributeName]) { 1138 let ignoreUpdate = ignoreAttribute(attributeName, to, 'update', ctx); 1139 if (!ignoreUpdate) { 1140 to[attributeName] = from[attributeName]; 1141 } 1142 if (from[attributeName]) { 1143 if (!ignoreUpdate) { 1144 to.setAttribute(attributeName, from[attributeName]); 1145 } 1146 } else { 1147 if (!ignoreAttribute(attributeName, to, 'remove', ctx)) { 1148 to.removeAttribute(attributeName); 1149 } 1150 } 1151 } 1152 } 1153 1154 /** 1155 * NB: many bothans died to bring us information: 1156 * 1157 * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js 1158 * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113 1159 * 1160 * @param from {Element} the element to sync the input value from 1161 * @param to {Element} the element to sync the input value to 1162 * @param ctx the merge context 1163 */ 1164 function syncInputValue(from, to, ctx) { 1165 if (from instanceof HTMLInputElement && 1166 to instanceof HTMLInputElement && 1167 from.type !== 'file') { 1168 1169 let fromValue = from.value; 1170 let toValue = to.value; 1171 1172 // sync boolean attributes 1173 syncBooleanAttribute(from, to, 'checked', ctx); 1174 syncBooleanAttribute(from, to, 'disabled', ctx); 1175 1176 if (!from.hasAttribute('value')) { 1177 if (!ignoreAttribute('value', to, 'remove', ctx)) { 1178 to.value = ''; 1179 to.removeAttribute('value'); 1180 } 1181 } else if (fromValue !== toValue) { 1182 if (!ignoreAttribute('value', to, 'update', ctx)) { 1183 to.setAttribute('value', fromValue); 1184 to.value = fromValue; 1185 } 1186 } 1187 } else if (from instanceof HTMLOptionElement) { 1188 syncBooleanAttribute(from, to, 'selected', ctx); 1189 } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) { 1190 let fromValue = from.value; 1191 let toValue = to.value; 1192 if (ignoreAttribute('value', to, 'update', ctx)) { 1193 return; 1194 } 1195 if (fromValue !== toValue) { 1196 to.value = fromValue; 1197 } 1198 if (to.firstChild && to.firstChild.nodeValue !== fromValue) { 1199 to.firstChild.nodeValue = fromValue; 1200 } 1201 } 1202 } 1203 1204 //============================================================================= 1205 // the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style 1206 //============================================================================= 1207 function handleHeadElement(newHeadTag, currentHead, ctx) { 1208 1209 let added = []; 1210 let removed = []; 1211 let preserved = []; 1212 let nodesToAppend = []; 1213 1214 let headMergeStyle = ctx.head.style; 1215 1216 // put all new head elements into a Map, by their outerHTML 1217 let srcToNewHeadNodes = new Map(); 1218 for (const newHeadChild of newHeadTag.children) { 1219 srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild); 1220 } 1221 1222 // for each elt in the current head 1223 for (const currentHeadElt of currentHead.children) { 1224 1225 // If the current head element is in the map 1226 let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML); 1227 let isReAppended = ctx.head.shouldReAppend(currentHeadElt); 1228 let isPreserved = ctx.head.shouldPreserve(currentHeadElt); 1229 if (inNewContent || isPreserved) { 1230 if (isReAppended) { 1231 // remove the current version and let the new version replace it and re-execute 1232 removed.push(currentHeadElt); 1233 } else { 1234 // this element already exists and should not be re-appended, so remove it from 1235 // the new content map, preserving it in the DOM 1236 srcToNewHeadNodes.delete(currentHeadElt.outerHTML); 1237 preserved.push(currentHeadElt); 1238 } 1239 } else { 1240 if (headMergeStyle === "append") { 1241 // we are appending and this existing element is not new content 1242 // so if and only if it is marked for re-append do we do anything 1243 if (isReAppended) { 1244 removed.push(currentHeadElt); 1245 nodesToAppend.push(currentHeadElt); 1246 } 1247 } else { 1248 // if this is a merge, we remove this content since it is not in the new head 1249 if (ctx.head.shouldRemove(currentHeadElt) !== false) { 1250 removed.push(currentHeadElt); 1251 } 1252 } 1253 } 1254 } 1255 1256 // Push the remaining new head elements in the Map into the 1257 // nodes to append to the head tag 1258 nodesToAppend.push(...srcToNewHeadNodes.values()); 1259 1260 let promises = []; 1261 for (const newNode of nodesToAppend) { 1262 let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild; 1263 if (ctx.callbacks.beforeNodeAdded(newElt) !== false) { 1264 if (newElt.href || newElt.src) { 1265 let resolve = null; 1266 let promise = new Promise(function (_resolve) { 1267 resolve = _resolve; 1268 }); 1269 newElt.addEventListener('load', function () { 1270 resolve(); 1271 }); 1272 promises.push(promise); 1273 } 1274 currentHead.appendChild(newElt); 1275 ctx.callbacks.afterNodeAdded(newElt); 1276 added.push(newElt); 1277 } 1278 } 1279 1280 // remove all removed elements, after we have appended the new elements to avoid 1281 // additional network requests for things like style sheets 1282 for (const removedElement of removed) { 1283 if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) { 1284 currentHead.removeChild(removedElement); 1285 ctx.callbacks.afterNodeRemoved(removedElement); 1286 } 1287 } 1288 1289 ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed}); 1290 return promises; 1291 } 1292 1293 function noOp() { 1294 } 1295 1296 /* 1297 Deep merges the config object and the Idiomoroph.defaults object to 1298 produce a final configuration object 1299 */ 1300 function mergeDefaults(config) { 1301 let finalConfig = {}; 1302 // copy top level stuff into final config 1303 Object.assign(finalConfig, defaults); 1304 Object.assign(finalConfig, config); 1305 1306 // copy callbacks into final config (do this to deep merge the callbacks) 1307 finalConfig.callbacks = {}; 1308 Object.assign(finalConfig.callbacks, defaults.callbacks); 1309 Object.assign(finalConfig.callbacks, config.callbacks); 1310 1311 // copy head config into final config (do this to deep merge the head) 1312 finalConfig.head = {}; 1313 Object.assign(finalConfig.head, defaults.head); 1314 Object.assign(finalConfig.head, config.head); 1315 return finalConfig; 1316 } 1317 1318 function createMorphContext(oldNode, newContent, config) { 1319 config = mergeDefaults(config); 1320 return { 1321 target: oldNode, 1322 newContent: newContent, 1323 config: config, 1324 morphStyle: config.morphStyle, 1325 ignoreActive: config.ignoreActive, 1326 ignoreActiveValue: config.ignoreActiveValue, 1327 idMap: createIdMap(oldNode, newContent), 1328 deadIds: new Set(), 1329 callbacks: config.callbacks, 1330 head: config.head 1331 } 1332 } 1333 1334 function isIdSetMatch(node1, node2, ctx) { 1335 if (node1 == null || node2 == null) { 1336 return false;
1337 } 1338 if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) { 1339 if (node1.id !== "" && node1.id === node2.id) { 1340 return true; 1341 } else { 1342 return getIdIntersectionCount(ctx, node1, node2) > 0; 1343 } 1344 } 1345 return false; 1346 } 1347 1348 function isSoftMatch(node1, node2) { 1349 if (node1 == null || node2 == null) { 1350 return false; 1351 } 1352 return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName 1353 } 1354 1355 function removeNodesBetween(startInclusive, endExclusive, ctx) { 1356 while (startInclusive !== endExclusive) { 1357 let tempNode = startInclusive; 1358 startInclusive = startInclusive.nextSibling; 1359 removeNode(tempNode, ctx); 1360 } 1361 removeIdsFromConsideration(ctx, endExclusive); 1362 return endExclusive.nextSibling; 1363 } 1364 1365 //============================================================================= 1366 // Scans forward from the insertionPoint in the old parent looking for a potential id match 1367 // for the newChild. We stop if we find a potential id match for the new child OR 1368 // if the number of potential id matches we are discarding is greater than the 1369 // potential id matches for the new child 1370 //============================================================================= 1371 function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) { 1372 1373 // max id matches we are willing to discard in our search 1374 let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent); 1375 1376 let potentialMatch = null; 1377 1378 // only search forward if there is a possibility of an id match 1379 if (newChildPotentialIdCount > 0) { 1380 let potentialMatch = insertionPoint; 1381 // if there is a possibility of an id match, scan forward 1382 // keep track of the potential id match count we are discarding (the 1383 // newChildPotentialIdCount must be greater than this to make it likely 1384 // worth it) 1385 let otherMatchCount = 0; 1386 while (potentialMatch != null) { 1387 1388 // If we have an id match, return the current potential match 1389 if (isIdSetMatch(newChild, potentialMatch, ctx)) { 1390 return potentialMatch; 1391 } 1392 1393 // computer the other potential matches of this new content 1394 otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent); 1395 if (otherMatchCount > newChildPotentialIdCount) { 1396 // if we have more potential id matches in _other_ content, we 1397 // do not have a good candidate for an id match, so return null 1398 return null; 1399 } 1400 1401 // advanced to the next old content child 1402 potentialMatch = potentialMatch.nextSibling; 1403 } 1404 } 1405 return potentialMatch; 1406 } 1407 1408 //============================================================================= 1409 // Scans forward from the insertionPoint in the old parent looking for a potential soft match 1410 // for the newChild. We stop if we find a potential soft match for the new child OR 1411 // if we find a potential id match in the old parents children OR if we find two 1412 // potential soft matches for the next two pieces of new content 1413 //============================================================================= 1414 function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) { 1415 1416 let potentialSoftMatch = insertionPoint; 1417 let nextSibling = newChild.nextSibling; 1418 let siblingSoftMatchCount = 0; 1419 1420 while (potentialSoftMatch != null) { 1421 1422 if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) { 1423 // the current potential soft match has a potential id set match with the remaining new 1424 // content so bail out of looking 1425 return null; 1426 } 1427 1428 // if we have a soft match with the current node, return it 1429 if (isSoftMatch(newChild, potentialSoftMatch)) { 1430 return potentialSoftMatch; 1431 } 1432 1433 if (isSoftMatch(nextSibling, potentialSoftMatch)) { 1434 // the next new node has a soft match with this node, so 1435 // increment the count of future soft matches 1436 siblingSoftMatchCount++; 1437 nextSibling = nextSibling.nextSibling; 1438 1439 // If there are two future soft matches, bail to allow the siblings to soft match 1440 // so that we don't consume future soft matches for the sake of the current node 1441 if (siblingSoftMatchCount >= 2) { 1442 return null; 1443 } 1444 } 1445 1446 // advanced to the next old content child 1447 potentialSoftMatch = potentialSoftMatch.nextSibling; 1448 } 1449 1450 return potentialSoftMatch; 1451 } 1452 1453 function parseContent(newContent) { 1454 let parser = new DOMParser(); 1455 1456 // remove svgs to avoid false-positive matches on head, etc. 1457 let contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, ''); 1458 1459 // if the newContent contains a html, head or body tag, we can simply parse it w/
1459o wrapping 1460 if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) { 1461 let content = parser.parseFromString(newContent, "text/html"); 1462 // if it is a full HTML document, return the document itself as the parent container 1463 if (contentWithSvgsRemoved.match(/<\/html>/)) { 1464 content.generatedByIdiomorph = true; 1465 return content; 1466 } else { 1467 // otherwise return the html element as the parent container 1468 let htmlElement = content.firstChild; 1469 if (htmlElement) { 1470 htmlElement.generatedByIdiomorph = true; 1471 return htmlElement; 1472 } else { 1473 return null; 1474 } 1475 } 1476 } else { 1477 // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help 1478 // deal with touchy tags like tr, tbody, etc. 1479 let responseDoc = parser.parseFromString("<body><template>" + newContent + "</template></body>", "text/html"); 1480 let content = responseDoc.body.querySelector('template').content; 1481 content.generatedByIdiomorph = true; 1482 return content 1483 } 1484 } 1485 1486 function normalizeContent(newContent) { 1487 if (newContent == null) { 1488 // noinspection UnnecessaryLocalVariableJS 1489 const dummyParent = document.createElement('div'); 1490 return dummyParent; 1491 } else if (newContent.generatedByIdiomorph) { 1492 // the template tag created by idiomorph parsing can serve as a dummy parent 1493 return newContent; 1494 } else if (newContent instanceof Node) { 1495 // a single node is added as a child to a dummy parent 1496 const dummyParent = document.createElement('div'); 1497 dummyParent.append(newContent); 1498 return dummyParent; 1499 } else { 1500 // all nodes in the array or HTMLElement collection are consolidated under 1501 // a single dummy parent element 1502 const dummyParent = document.createElement('div'); 1503 for (const elt of [...newContent]) { 1504 dummyParent.append(elt); 1505 } 1506 return dummyParent; 1507 } 1508 } 1509 1510 function insertSiblings(previousSibling, morphedNode, nextSibling) { 1511 let stack = []; 1512 let added = []; 1513 while (previousSibling != null) { 1514 stack.push(previousSibling); 1515 previousSibling = previousSibling.previousSibling; 1516 } 1517 while (stack.length > 0) { 1518 let node = stack.pop(); 1519 added.push(node); // push added preceding siblings on in order and insert 1520 morphedNode.parentElement.insertBefore(node, morphedNode); 1521 } 1522 added.push(morphedNode); 1523 while (nextSibling != null) { 1524 stack.push(nextSibling); 1525 added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add 1526 nextSibling = nextSibling.nextSibling; 1527 } 1528 while (stack.length > 0) { 1529 morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling); 1530 } 1531 return added; 1532 } 1533 1534 function findBestNodeMatch(newContent, oldNode, ctx) { 1535 let currentElement; 1536 currentElement = newContent.firstChild; 1537 let bestElement = currentElement; 1538 let score = 0; 1539 while (currentElement) { 1540 let newScore = scoreElement(currentElement, oldNode, ctx); 1541 if (newScore > score) { 1542 bestElement = currentElement; 1543 score = newScore; 1544 } 1545 currentElement = currentElement.nextSibling; 1546 } 1547 return bestElement; 1548 } 1549 1550 function scoreElement(node1, node2, ctx) { 1551 if (isSoftMatch(node1, node2)) { 1552 return .5 + getIdIntersectionCount(ctx, node1, node2); 1553 } 1554 return 0; 1555 } 1556 1557 function removeNode(tempNode, ctx) { 1558 removeIdsFromConsideration(ctx, tempNode); 1559 if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return; 1560 1561 tempNode.remove(); 1562 ctx.callbacks.afterNodeRemoved(tempNode); 1563 } 1564 1565 //============================================================================= 1566 // ID Set Functions 1567 //============================================================================= 1568 1569 function isIdInConsideration(ctx, id) { 1570 return !ctx.deadIds.has(id); 1571 } 1572 1573 function idIsWithinNode(ctx, id, targetNode) { 1574 let idSet = ctx.idMap.get(targetNode) || EMPTY_SET; 1575 return idSet.has(id); 1576 } 1577 1578 function removeIdsFromConsideration(ctx, node) { 1579 let idSet = ctx.idMap.get(node) || EMPTY_SET; 1580 for (const id of idSet) { 1581 ctx.deadIds.add(id); 1582 } 1583 } 1584 1585 function getIdIntersectionCount(ctx, node1, node2) { 1586 let sourceSet = ctx.idMap.get(node1) || EMPTY_SET; 1587 let matchCount = 0; 1588 for (const id of sourceSet) {
1589 // a potential match is an id in the source and potentialIdsSet, but 1590 // that has not already been merged into the DOM 1591 if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) { 1592 ++matchCount; 1593 } 1594 } 1595 return matchCount; 1596 } 1597 1598 /** 1599 * A bottom up algorithm that finds all elements with ids inside of the node 1600 * argument and populates id sets for those nodes and all their parents, generating 1601 * a set of ids contained within all nodes for the entire hierarchy in the DOM 1602 * 1603 * @param node {Element} 1604 * @param {Map<Node, Set<String>>} idMap 1605 */ 1606 function populateIdMapForNode(node, idMap) { 1607 let nodeParent = node.parentElement; 1608 // find all elements with an id property 1609 let idElements = node.querySelectorAll('[id]'); 1610 for (const elt of idElements) { 1611 let current = elt; 1612 // walk up the parent hierarchy of that element, adding the id 1613 // of element to the parent's id set 1614 while (current !== nodeParent && current != null) { 1615 let idSet = idMap.get(current); 1616 // if the id set doesn't exist, create it and insert it in the map 1617 if (idSet == null) { 1618 idSet = new Set(); 1619 idMap.set(current, idSet); 1620 } 1621 idSet.add(elt.id); 1622 current = current.parentElement; 1623 } 1624 } 1625 } 1626 1627 /** 1628 * This function computes a map of nodes to all ids contained within that node (inclusive of the 1629 * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows 1630 * for a looser definition of "matching" than tradition id matching, and allows child nodes 1631 * to contribute to a parent nodes matching. 1632 * 1633 * @param {Element} oldContent the old content that will be morphed 1634 * @param {Element} newContent the new content to morph to 1635 * @returns {Map<Node, Set<String>>} a map of nodes to id sets for the 1636 */ 1637 function createIdMap(oldContent, newContent) { 1638 let idMap = new Map(); 1639 populateIdMapForNode(oldContent, idMap); 1640 populateIdMapForNode(newContent, idMap); 1641 return idMap; 1642 } 1643 1644 //============================================================================= 1645 // This is what ends up becoming the Idiomorph global object 1646 //============================================================================= 1647 return { 1648 morph, 1649 defaults 1650 } 1651 })(); 1652 1653function normalizeAttributesForComparison(element) { 1654 const isFileInput = element instanceof HTMLInputElement && element.type === 'file'; 1655 if (!isFileInput) { 1656 if ('value' in element) { 1657 element.setAttribute('value', element.value); 1658 } 1659 else if (element.hasAttribute('value')) { 1660 element.setAttribute('value', ''); 1661 } 1662 } 1663 Array.from(element.children).forEach((child) => { 1664 normalizeAttributesForComparison(child); 1665 }); 1666} 1667 1668const syncAttributes = (fromEl, toEl) => { 1669 for (let i = 0; i < fromEl.attributes.length; i++) { 1670 const attr = fromEl.attributes[i]; 1671 toEl.setAttribute(attr.name, attr.value); 1672 } 1673}; 1674function executeMorphdom(rootFromElement, rootToElement, modifiedFieldElements, getElementValue, externalMutationTracker) { 1675 const originalElementIdsToSwapAfter = []; 1676 const originalElementsToPreserve = new Map(); 1677 const markElementAsNeedingPostMorphSwap = (id, replaceWithClone) => { 1678 const oldElement = originalElementsToPreserve.get(id); 1679 if (!(oldElement instanceof HTMLElement)) { 1680 throw new Error(`Original element with id ${id} not found`); 1681 } 1682 originalElementIdsToSwapAfter.push(id); 1683 if (!replaceWithClone) { 1684 return null; 1685 } 1686 const clonedOldElement = cloneHTMLElement(oldElement); 1687 oldElement.replaceWith(clonedOldElement); 1688 return clonedOldElement; 1689 }; 1690 rootToElement.querySelectorAll('[data-live-preserve]').forEach((newElement) => { 1691 const id = newElement.id; 1692 if (!id) { 1693 throw new Error('The data-live-preserve attribute requires an id attribute to be set on the element'); 1694 } 1695 const oldElement = rootFromElement.querySelector(`#${id}`); 1696 if (!(oldElement instanceof HTMLElement)) { 1697 throw new Error(`The element with id "${id}" was not found in the original HTML`); 1698 } 1699 newElement.removeAttribute('data-live-preserve'); 1700 originalElementsToPreserve.set(id, oldElement); 1701 syncAttributes(newElement, oldElement); 1702 }); 1703 Idiomorph.morph(rootFromElement, rootToElement, { 1704 callbacks: { 1705 beforeNodeMorphed: (fromEl, toEl) => { 1706 if (!(fromEl instanceof Element) || !(toEl instanceof Element)) { 1707 return true; 1708 } 1709 if (fromEl === rootFromElement) { 1710 return true; 1711 } 1712 if (fromEl.id && originalElementsToPreserve.has(fromEl.id)) { 1713 if (fromEl.id === toEl.id) { 1714 return false;
1715 } 1716 const clonedFromEl = markElementAsNeedingPostMorphSwap(fromEl.id, true); 1717 if (!clonedFromEl) { 1718 throw new Error('missing clone'); 1719 } 1720 Idiomorph.morph(clonedFromEl, toEl); 1721 return false; 1722 } 1723 if (fromEl instanceof HTMLElement && toEl instanceof HTMLElement) { 1724 if (typeof fromEl.__x !== 'undefined') { 1725 if (!window.Alpine) { 1726 throw new Error('Unable to access Alpine.js though the global window.Alpine variable. Please make sure Alpine.js is loaded before Symfony UX LiveComponent.'); 1727 } 1728 if (typeof window.Alpine.morph !== 'function') { 1729 throw new Error('Unable to access Alpine.js morph function. Please make sure the Alpine.js Morph plugin is installed and loaded, see https://alpinejs.dev/plugins/morph for more information.'); 1730 } 1731 window.Alpine.morph(fromEl.__x, toEl); 1732 } 1733 if (externalMutationTracker.wasElementAdded(fromEl)) { 1734 fromEl.insertAdjacentElement('afterend', toEl); 1735 return false; 1736 } 1737 if (modifiedFieldElements.includes(fromEl)) { 1738 setValueOnElement(toEl, getElementValue(fromEl)); 1739 } 1740 if (fromEl === document.activeElement && 1741 fromEl !== document.body && 1742 null !== getModelDirectiveFromElement(fromEl, false)) { 1743 setValueOnElement(toEl, getElementValue(fromEl)); 1744 } 1745 const elementChanges = externalMutationTracker.getChangedElement(fromEl); 1746 if (elementChanges) { 1747 elementChanges.applyToElement(toEl); 1748 } 1749 if (fromEl.nodeName.toUpperCase() !== 'OPTION' && fromEl.isEqualNode(toEl)) { 1750 const normalizedFromEl = cloneHTMLElement(fromEl); 1751 normalizeAttributesForComparison(normalizedFromEl); 1752 const normalizedToEl = cloneHTMLElement(toEl); 1753 normalizeAttributesForComparison(normalizedToEl); 1754 if (normalizedFromEl.isEqualNode(normalizedToEl)) { 1755 return false; 1756 } 1757 } 1758 } 1759 if (fromEl.hasAttribute('data-skip-morph') || (fromEl.id && fromEl.id !== toEl.id)) { 1760 fromEl.innerHTML = toEl.innerHTML; 1761 return true; 1762 } 1763 if (fromEl.parentElement?.hasAttribute('data-skip-morph')) { 1764 return false; 1765 } 1766 return !fromEl.hasAttribute('data-live-ignore'); 1767 }, 1768 beforeNodeRemoved(node) { 1769 if (!(node instanceof HTMLElement)) { 1770 return true; 1771 } 1772 if (node.id && originalElementsToPreserve.has(node.id)) { 1773 markElementAsNeedingPostMorphSwap(node.id, false); 1774 return true; 1775 } 1776 if (externalMutationTracker.wasElementAdded(node)) { 1777 return false; 1778 } 1779 return !node.hasAttribute('data-live-ignore'); 1780 }, 1781 }, 1782 }); 1783 originalElementIdsToSwapAfter.forEach((id) => { 1784 const newElement = rootFromElement.querySelector(`#${id}`); 1785 const originalElement = originalElementsToPreserve.get(id); 1786 if (!(newElement instanceof HTMLElement) || !(originalElement instanceof HTMLElement)) { 1787 throw new Error('Missing elements.'); 1788 } 1789 newElement.replaceWith(originalElement); 1790 }); 1791} 1792 1793class UnsyncedInputsTracker { 1794 constructor(component, modelElementResolver) { 1795 this.elementEventListeners = [ 1796 { event: 'input', callback: (event) => this.handleInputEvent(event) }, 1797 ]; 1798 this.component = component; 1799 this.modelElementResolver = modelElementResolver; 1800 this.unsyncedInputs = new UnsyncedInputContainer(); 1801 } 1802 activate() {
1803 this.elementEventListeners.forEach(({ event, callback }) => { 1804 this.component.element.addEventListener(event, callback); 1805 }); 1806 } 1807 deactivate() { 1808 this.elementEventListeners.forEach(({ event, callback }) => { 1809 this.component.element.removeEventListener(event, callback); 1810 }); 1811 } 1812 markModelAsSynced(modelName) { 1813 this.unsyncedInputs.markModelAsSynced(modelName); 1814 } 1815 handleInputEvent(event) { 1816 const target = event.target; 1817 if (!target) { 1818 return; 1819 } 1820 this.updateModelFromElement(target); 1821 } 1822 updateModelFromElement(element) { 1823 if (!elementBelongsToThisComponent(element, this.component)) { 1824 return; 1825 } 1826 if (!(element instanceof HTMLElement)) { 1827 throw new Error('Could not update model for non HTMLElement'); 1828 } 1829 const modelName = this.modelElementResolver.getModelName(element); 1830 this.unsyncedInputs.add(element, modelName); 1831 } 1832 getUnsyncedInputs() { 1833 return this.unsyncedInputs.allUnsyncedInputs(); 1834 } 1835 getUnsyncedModels() { 1836 return Array.from(this.unsyncedInputs.getUnsyncedModelNames()); 1837 } 1838 resetUnsyncedFields() { 1839 this.unsyncedInputs.resetUnsyncedFields(); 1840 } 1841} 1842class UnsyncedInputContainer { 1843 constructor() { 1844 this.unsyncedNonModelFields = []; 1845 this.unsyncedModelNames = []; 1846 this.unsyncedModelFields = new Map(); 1847 } 1848 add(element, modelName = null) { 1849 if (modelName) { 1850 this.unsyncedModelFields.set(modelName, element); 1851 if (!this.unsyncedModelNames.includes(modelName)) { 1852 this.unsyncedModelNames.push(modelName); 1853 } 1854 return; 1855 } 1856 this.unsyncedNonModelFields.push(element); 1857 } 1858 resetUnsyncedFields() { 1859 this.unsyncedModelFields.forEach((value, key) => { 1860 if (!this.unsyncedModelNames.includes(key)) { 1861 this.unsyncedModelFields.delete(key); 1862 } 1863 }); 1864 } 1865 allUnsyncedInputs() { 1866 return [...this.unsyncedNonModelFields, ...this.unsyncedModelFields.values()]; 1867 } 1868 markModelAsSynced(modelName) { 1869 const index = this.unsyncedModelNames.indexOf(modelName); 1870 if (index !== -1) { 1871 this.unsyncedModelNames.splice(index, 1); 1872 } 1873 } 1874 getUnsyncedModelNames() { 1875 return this.unsyncedModelNames; 1876 } 1877} 1878 1879function getDeepData(data, propertyPath) { 1880 const { currentLevelData, finalKey } = parseDeepData(data, propertyPath); 1881 if (currentLevelData === undefined) { 1882 return undefined; 1883 } 1884 return currentLevelData[finalKey]; 1885} 1886const parseDeepData = (data, propertyPath) => { 1887 const finalData = JSON.parse(JSON.stringify(data)); 1888 let currentLevelData = finalData; 1889 const parts = propertyPath.split('.'); 1890 for (let i = 0; i < parts.length - 1; i++) { 1891 currentLevelData = currentLevelData[parts[i]]; 1892 } 1893 const finalKey = parts[parts.length - 1]; 1894 return { 1895 currentLevelData, 1896 finalData, 1897 finalKey, 1898 parts, 1899 }; 1900}; 1901 1902class ValueStore { 1903 constructor(props) { 1904 this.props = {}; 1905 this.dirtyProps = {}; 1906 this.pendingProps = {}; 1907 this.updatedPropsFromParent = {}; 1908 this.props = props; 1909 } 1910 get(name) { 1911 const normalizedName = normalizeModelName(name); 1912 if (this.dirtyProps[normalizedName] !== undefined) { 1913 return this.dirtyProps[normalizedName]; 1914 } 1915 if (this.pendingProps[normalizedName] !== undefined) { 1916 return this.pendingProps[normalizedName]; 1917 } 1918 if (this.props[normalizedName] !== undefined) { 1919 return this.props[normalizedName]; 1920 } 1921 return getDeepData(this.props, normalizedName); 1922 } 1923 has(name) { 1924 return this.get(name) !== undefined; 1925 } 1926 set(name, value) { 1927 const normalizedName = normalizeModelName(name); 1928 if (this.get(normalizedName) === value) { 1929 return false; 1930 } 1931 this.dirtyProps[normalizedName] = value; 1932 return true; 1933 } 1934 getOriginalProps() { 1935 return { ...this.props }; 1936 } 1937 getDirtyProps() { 1938 return { ...this.dirtyProps }; 1939 }
1940 getUpdatedPropsFromParent() { 1941 return { ...this.updatedPropsFromParent }; 1942 } 1943 flushDirtyPropsToPending() { 1944 this.pendingProps = { ...this.dirtyProps }; 1945 this.dirtyProps = {}; 1946 } 1947 reinitializeAllProps(props) { 1948 this.props = props; 1949 this.updatedPropsFromParent = {}; 1950 this.pendingProps = {}; 1951 } 1952 pushPendingPropsBackToDirty() { 1953 this.dirtyProps = { ...this.pendingProps, ...this.dirtyProps }; 1954 this.pendingProps = {}; 1955 } 1956 storeNewPropsFromParent(props) { 1957 let changed = false; 1958 for (const [key, value] of Object.entries(props)) { 1959 const currentValue = this.get(key); 1960 if (currentValue !== value) { 1961 changed = true; 1962 } 1963 } 1964 if (changed) { 1965 this.updatedPropsFromParent = props; 1966 } 1967 return changed; 1968 } 1969} 1970 1971class Component { 1972 constructor(element, name, props, listeners, id, backend, elementDriver) { 1973 this.fingerprint = ''; 1974 this.defaultDebounce = 150; 1975 this.backendRequest = null; 1976 this.pendingActions = []; 1977 this.pendingFiles = {}; 1978 this.isRequestPending = false; 1979 this.requestDebounceTimeout = null; 1980 this.element = element; 1981 this.name = name; 1982 this.backend = backend; 1983 this.elementDriver = elementDriver; 1984 this.id = id; 1985 this.listeners = new Map(); 1986 listeners.forEach((listener) => { 1987 if (!this.listeners.has(listener.event)) { 1988 this.listeners.set(listener.event, []); 1989 } 1990 this.listeners.get(listener.event)?.push(listener.action); 1991 }); 1992 this.valueStore = new ValueStore(props); 1993 this.unsyncedInputsTracker = new UnsyncedInputsTracker(this, elementDriver); 1994 this.hooks = new HookManager(); 1995 this.resetPromise(); 1996 this.externalMutationTracker = new ExternalMutationTracker(this.element, (element) => elementBelongsToThisComponent(element, this)); 1997 this.externalMutationTracker.start(); 1998 } 1999 addPlugin(plugin) { 2000 plugin.attachToComponent(this); 2001 } 2002 connect() { 2003 registerComponent(this); 2004 this.hooks.triggerHook('connect', this); 2005 this.unsyncedInputsTracker.activate(); 2006 this.externalMutationTracker.start(); 2007 } 2008 disconnect() { 2009 unregisterComponent(this); 2010 this.hooks.triggerHook('disconnect', this); 2011 this.clearRequestDebounceTimeout(); 2012 this.unsyncedInputsTracker.deactivate(); 2013 this.externalMutationTracker.stop(); 2014 } 2015 on(hookName, callback) { 2016 this.hooks.register(hookName, callback); 2017 } 2018 off(hookName, callback) { 2019 this.hooks.unregister(hookName, callback); 2020 } 2021 set(model, value, reRender = false, debounce = false) { 2022 const promise = this.nextRequestPromise; 2023 const modelName = normalizeModelName(model); 2024 if (!this.valueStore.has(modelName)) { 2025 throw new Error(`Invalid model name "${model}".`); 2026 } 2027 const isChanged = this.valueStore.set(modelName, value); 2028 this.hooks.triggerHook('model:set', model, value, this); 2029 this.unsyncedInputsTracker.markModelAsSynced(modelName); 2030 if (reRender && isChanged) { 2031 this.debouncedStartRequest(debounce); 2032 } 2033 return promise; 2034 } 2035 getData(model) { 2036 const modelName = normalizeModelName(model); 2037 if (!this.valueStore.has(modelName)) { 2038 throw new Error(`Invalid model "${model}".`); 2039 } 2040 return this.valueStore.get(modelName); 2041 } 2042 action(name, args = {}, debounce = false) { 2043 const promise = this.nextRequestPromise; 2044 this.pendingActions.push({ 2045 name, 2046 args, 2047 }); 2048 this.debouncedStartRequest(debounce); 2049 return promise; 2050 } 2051 files(key, input) { 2052 this.pendingFiles[key] = input; 2053 } 2054 render() { 2055 const promise = this.nextRequestPromise; 2056 this.tryStartingRequest(); 2057 return promise; 2058 } 2059 getUnsyncedModels() { 2060 return this.unsyncedInputsTracker.getUnsyncedModels(); 2061 } 2062 emit(name, data, onlyMatchingComponentsNamed = null) { 2063 this.performEmit(name, data, false, onlyMatchingComponentsNamed); 2064 } 2065 emitUp(name, data, onlyMatchingComponentsNamed = null) { 2066 this.performEmit(name, data, true, onlyMatchingComponentsNamed); 2067 } 2068 emitSelf(name, data) { 2069 this.doEmit(name, data); 2070 } 2071 performEmit(name, data, emitUp, matchingName) { 2072 const components = findComponents(this, emitUp, matchingName);
2073 components.forEach((component) => { 2074 component.doEmit(name, data); 2075 }); 2076 } 2077 doEmit(name, data) { 2078 if (!this.listeners.has(name)) { 2079 return; 2080 } 2081 const actions = this.listeners.get(name) || []; 2082 actions.forEach((action) => { 2083 this.action(action, data, 1); 2084 }); 2085 } 2086 isTurboEnabled() { 2087 return typeof Turbo !== 'undefined' && !this.element.closest('[data-turbo="false"]'); 2088 } 2089 tryStartingRequest() { 2090 if (!this.backendRequest) { 2091 this.performRequest(); 2092 return; 2093 } 2094 this.isRequestPending = true; 2095 } 2096 performRequest() { 2097 const thisPromiseResolve = this.nextRequestPromiseResolve; 2098 this.resetPromise(); 2099 this.unsyncedInputsTracker.resetUnsyncedFields(); 2100 const filesToSend = {}; 2101 for (const [key, value] of Object.entries(this.pendingFiles)) { 2102 if (value.files) { 2103 filesToSend[key] = value.files; 2104 } 2105 } 2106 const requestConfig = { 2107 props: this.valueStore.getOriginalProps(), 2108 actions: this.pendingActions, 2109 updated: this.valueStore.getDirtyProps(), 2110 children: {}, 2111 updatedPropsFromParent: this.valueStore.getUpdatedPropsFromParent(), 2112 files: filesToSend, 2113 }; 2114 this.hooks.triggerHook('request:started', requestConfig); 2115 this.backendRequest = this.backend.makeRequest(requestConfig.props, requestConfig.actions, requestConfig.updated, requestConfig.children, requestConfig.updatedPropsFromParent, requestConfig.files); 2116 this.hooks.triggerHook('loading.state:started', this.element, this.backendRequest); 2117 this.pendingActions = []; 2118 this.valueStore.flushDirtyPropsToPending(); 2119 this.isRequestPending = false; 2120 this.backendRequest.promise.then(async (response) => { 2121 const backendResponse = new BackendResponse(response); 2122 const html = await backendResponse.getBody(); 2123 for (const input of Object.values(this.pendingFiles)) { 2124 input.value = ''; 2125 } 2126 const headers = backendResponse.response.headers; 2127 if (!headers.get('Content-Type')?.includes('application/vnd.live-component+html') && 2128 !headers.get('X-Live-Redirect')) { 2129 const controls = { displayError: true }; 2130 this.valueStore.pushPendingPropsBackToDirty(); 2131 this.hooks.triggerHook('response:error', backendResponse, controls); 2132 if (controls.displayError) { 2133 this.renderError(html); 2134 } 2135 this.backendRequest = null; 2136 thisPromiseResolve(backendResponse); 2137 return response; 2138 } 2139 this.processRerender(html, backendResponse); 2140 this.backendRequest = null; 2141 thisPromiseResolve(backendResponse); 2142 if (this.isRequestPending) { 2143 this.isRequestPending = false; 2144 this.performRequest(); 2145 } 2146 return response; 2147 }); 2148 } 2149 processRerender(html, backendResponse) { 2150 const controls = { shouldRender: true }; 2151 this.hooks.triggerHook('render:started', html, backendResponse, controls); 2152 if (!controls.shouldRender) { 2153 return; 2154 } 2155 if (backendResponse.response.headers.get('Location')) { 2156 if (this.isTurboEnabled()) { 2157 Turbo.visit(backendResponse.response.headers.get('Location')); 2158 } 2159 else { 2160 window.location.href = backendResponse.response.headers.get('Location') || ''; 2161 } 2162 return; 2163 } 2164 this.hooks.triggerHook('loading.state:finished', this.element); 2165 const modifiedModelValues = {}; 2166 Object.keys(this.valueStore.getDirtyProps()).forEach((modelName) => { 2167 modifiedModelValues[modelName] = this.valueStore.get(modelName); 2168 }); 2169 let newElement; 2170 try { 2171 newElement = htmlToElement(html); 2172 if (!newElement.matches('[data-controller~=live]')) { 2173 throw new Error('A live component template must contain a single root controller element.'); 2174 } 2175 } 2176 catch (error) { 2177 console.error(`There was a problem with the '${this.name}' component HTML returned:`, { 2178 id: this.id, 2179 }); 2180 throw error; 2181 } 2182 this.externalMutationTracker.handlePendingChanges(); 2183 this.externalMutationTracker.stop(); 2184 executeMorphdom(this.element, newElement, this.unsyncedInputsTracker.getUnsyncedInputs(), (element) => getValueFromElement(element, this.valueStore), this.externalMutationTracker); 2185 this.externalMutationTracker.start(); 2186 const newProps = this.elementDriver.getComponentProps(); 2187 this.valueStore.reinitializeAllProps(newProps); 2188 const eventsToEmit = this.elementDriver.getEventsToEmit(); 2189 const browserEventsToDispatch = this.elementDriver.getBrowserEventsToDispatch();
2190 Object.keys(modifiedModelValues).forEach((modelName) => { 2191 this.valueStore.set(modelName, modifiedModelValues[modelName]); 2192 }); 2193 eventsToEmit.forEach(({ event, data, target, componentName }) => { 2194 if (target === 'up') { 2195 this.emitUp(event, data, componentName); 2196 return; 2197 } 2198 if (target === 'self') { 2199 this.emitSelf(event, data); 2200 return; 2201 } 2202 this.emit(event, data, componentName); 2203 }); 2204 browserEventsToDispatch.forEach(({ event, payload }) => { 2205 this.element.dispatchEvent(new CustomEvent(event, { 2206 detail: payload, 2207 bubbles: true, 2208 })); 2209 }); 2210 this.hooks.triggerHook('render:finished', this); 2211 } 2212 calculateDebounce(debounce) { 2213 if (debounce === true) { 2214 return this.defaultDebounce; 2215 } 2216 if (debounce === false) { 2217 return 0; 2218 } 2219 return debounce; 2220 } 2221 clearRequestDebounceTimeout() { 2222 if (this.requestDebounceTimeout) { 2223 clearTimeout(this.requestDebounceTimeout); 2224 this.requestDebounceTimeout = null; 2225 } 2226 } 2227 debouncedStartRequest(debounce) { 2228 this.clearRequestDebounceTimeout(); 2229 this.requestDebounceTimeout = window.setTimeout(() => { 2230 this.render(); 2231 }, this.calculateDebounce(debounce)); 2232 } 2233 renderError(html) { 2234 let modal = document.getElementById('live-component-error'); 2235 if (modal) { 2236 modal.innerHTML = ''; 2237 } 2238 else { 2239 modal = document.createElement('div'); 2240 modal.id = 'live-component-error'; 2241 modal.style.padding = '50px'; 2242 modal.style.backgroundColor = 'rgba(0, 0, 0, .5)'; 2243 modal.style.zIndex = '100000'; 2244 modal.style.position = 'fixed'; 2245 modal.style.top = '0px'; 2246 modal.style.bottom = '0px'; 2247 modal.style.left = '0px'; 2248 modal.style.right = '0px'; 2249 modal.style.display = 'flex'; 2250 modal.style.flexDirection = 'column'; 2251 } 2252 const iframe = document.createElement('iframe'); 2253 iframe.style.borderRadius = '5px'; 2254 iframe.style.flexGrow = '1'; 2255 modal.appendChild(iframe); 2256 document.body.prepend(modal); 2257 document.body.style.overflow = 'hidden'; 2258 if (iframe.contentWindow) { 2259 iframe.contentWindow.document.open(); 2260 iframe.contentWindow.document.write(html); 2261 iframe.contentWindow.document.close(); 2262 } 2263 const closeModal = (modal) => { 2264 if (modal) { 2265 modal.outerHTML = ''; 2266 } 2267 document.body.style.overflow = 'visible'; 2268 }; 2269 modal.addEventListener('click', () => closeModal(modal)); 2270 modal.setAttribute('tabindex', '0'); 2271 modal.addEventListener('keydown', (e) => { 2272 if (e.key === 'Escape') { 2273 closeModal(modal); 2274 } 2275 }); 2276 modal.focus(); 2277 } 2278 resetPromise() { 2279 this.nextRequestPromise = new Promise((resolve) => { 2280 this.nextRequestPromiseResolve = resolve; 2281 }); 2282 } 2283 _updateFromParentProps(props) { 2284 const isChanged = this.valueStore.storeNewPropsFromParent(props); 2285 if (isChanged) { 2286 this.render(); 2287 } 2288 } 2289} 2290function proxifyComponent(component) { 2291 return new Proxy(component, { 2292 get(component, prop) { 2293 if (prop in component || typeof prop !== 'string') { 2294 if (typeof component[prop] === 'function') { 2295 const callable = component[prop]; 2296 return (...args) => { 2297 return callable.apply(component, args); 2298 }; 2299 } 2300 return Reflect.get(component, prop); 2301 } 2302 if (component.valueStore.has(prop)) { 2303 return component.getData(prop); 2304 } 2305 return (args) => { 2306 return component.action.apply(component, [prop, args]); 2307 }; 2308 }, 2309 set(target, property, value) { 2310 if (property in target) { 2311 target[property] = value; 2312 return true; 2313 } 2314 target.set(property, value); 2315 return true; 2316 }, 2317 }); 2318} 2319 2320class StimulusElementDriver { 2321 constructor(controller) { 2322 this.controller = controller; 2323 } 2324 getModelName(element) { 2325 const modelDirective = getModelDirectiveFromElement(element, false); 2326 if (!modelDirective) { 2327 return null; 2328 } 2329 return modelDirective.action; 2330 } 2331 getComponentProps() { 2332 return this.controller.propsValue; 2333 } 2334 getEventsToEmit() { 2335 return this.controller.eventsToEmitValue; 2336 } 2337 getBrowserEventsToDispatch() { 2338 return this.controller.eventsToDispatchValue; 2339 } 2340} 2341 2342function getModelBinding (modelDirective) { 2343 let shouldRender = true; 2344 let targetEventName = null; 2345 let debounce = false;
2346 modelDirective.modifiers.forEach((modifier) => { 2347 switch (modifier.name) { 2348 case 'on': 2349 if (!modifier.value) { 2350 throw new Error(`The "on" modifier in ${modelDirective.getString()} requires a value - e.g. on(change).`); 2351 } 2352 if (!['input', 'change'].includes(modifier.value)) { 2353 throw new Error(`The "on" modifier in ${modelDirective.getString()} only accepts the arguments "input" or "change".`); 2354 } 2355 targetEventName = modifier.value; 2356 break; 2357 case 'norender': 2358 shouldRender = false; 2359 break; 2360 case 'debounce': 2361 debounce = modifier.value ? Number.parseInt(modifier.value) : true; 2362 break; 2363 default: 2364 throw new Error(`Unknown modifier "${modifier.name}" in data-model="${modelDirective.getString()}".`); 2365 } 2366 }); 2367 const [modelName, innerModelName] = modelDirective.action.split(':'); 2368 return { 2369 modelName, 2370 innerModelName: innerModelName || null, 2371 shouldRender, 2372 debounce, 2373 targetEventName, 2374 }; 2375} 2376 2377class ChildComponentPlugin { 2378 constructor(component) { 2379 this.parentModelBindings = []; 2380 this.component = component; 2381 const modelDirectives = getAllModelDirectiveFromElements(this.component.element); 2382 this.parentModelBindings = modelDirectives.map(getModelBinding); 2383 } 2384 attachToComponent(component) { 2385 component.on('request:started', (requestData) => { 2386 requestData.children = this.getChildrenFingerprints(); 2387 }); 2388 component.on('model:set', (model, value) => { 2389 this.notifyParentModelChange(model, value); 2390 }); 2391 } 2392 getChildrenFingerprints() { 2393 const fingerprints = {};
2394 this.getChildren().forEach((child) => { 2395 if (!child.id) { 2396 throw new Error('missing id'); 2397 } 2398 fingerprints[child.id] = { 2399 fingerprint: child.fingerprint, 2400 tag: child.element.tagName.toLowerCase(), 2401 }; 2402 }); 2403 return fingerprints; 2404 } 2405 notifyParentModelChange(modelName, value) { 2406 const parentComponent = findParent(this.component); 2407 if (!parentComponent) { 2408 return; 2409 } 2410 this.parentModelBindings.forEach((modelBinding) => { 2411 const childModelName = modelBinding.innerModelName || 'value'; 2412 if (childModelName !== modelName) { 2413 return; 2414 } 2415 parentComponent.set(modelBinding.modelName, value, modelBinding.shouldRender, modelBinding.debounce); 2416 }); 2417 } 2418 getChildren() { 2419 return findChildren(this.component); 2420 } 2421} 2422 2423class LazyPlugin { 2424 constructor() { 2425 this.intersectionObserver = null; 2426 } 2427 attachToComponent(component) { 2428 if ('lazy' !== component.element.attributes.getNamedItem('loading')?.value) { 2429 return; 2430 } 2431 component.on('connect', () => { 2432 this.getObserver().observe(component.element); 2433 }); 2434 component.on('disconnect', () => { 2435 this.intersectionObserver?.unobserve(component.element); 2436 }); 2437 } 2438 getObserver() { 2439 if (!this.intersectionObserver) { 2440 this.intersectionObserver = new IntersectionObserver((entries, observer) => { 2441 entries.forEach((entry) => { 2442 if (entry.isIntersecting) { 2443 entry.target.dispatchEvent(new CustomEvent('live:appear')); 2444 observer.unobserve(entry.target); 2445 } 2446 }); 2447 }); 2448 } 2449 return this.intersectionObserver; 2450 } 2451} 2452 2453class LoadingPlugin { 2454 attachToComponent(component) { 2455 component.on('loading.state:started', (element, request) => { 2456 this.startLoading(component, element, request); 2457 }); 2458 component.on('loading.state:finished', (element) => { 2459 this.finishLoading(component, element); 2460 }); 2461 this.finishLoading(component, component.element); 2462 } 2463 startLoading(component, targetElement, backendRequest) { 2464 this.handleLoadingToggle(component, true, targetElement, backendRequest); 2465 } 2466 finishLoading(component, targetElement) { 2467 this.handleLoadingToggle(component, false, targetElement, null); 2468 } 2469 handleLoadingToggle(component, isLoading, targetElement, backendRequest) { 2470 if (isLoading) { 2471 this.addAttributes(targetElement, ['busy']); 2472 } 2473 else { 2474 this.removeAttributes(targetElement, ['busy']); 2475 } 2476 this.getLoadingDirectives(component, targetElement).forEach(({ element, directives }) => { 2477 if (isLoading) { 2478 this.addAttributes(element, ['data-live-is-loading']); 2479 } 2480 else { 2481 this.removeAttributes(element, ['data-live-is-loading']); 2482 } 2483 directives.forEach((directive) => { 2484 this.handleLoadingDirective(element, isLoading, directive, backendRequest); 2485 }); 2486 }); 2487 } 2488 handleLoadingDirective(element, isLoading, directive, backendRequest) { 2489 const finalAction = parseLoadingAction(directive.action, isLoading); 2490 const targetedActions = []; 2491 const targetedModels = []; 2492 let delay = 0; 2493 const validModifiers = new Map(); 2494 validModifiers.set('delay', (modifier) => { 2495 if (!isLoading) { 2496 return; 2497 } 2498 delay = modifier.value ? Number.parseInt(modifier.value) : 200; 2499 }); 2500 validModifiers.set('action', (modifier) => { 2501 if (!modifier.value) { 2502 throw new Error(`The "action" in data-loading must have an action name - e.g. action(foo). It's missing for "${directive.getString()}"`); 2503 } 2504 targetedActions.push(modifier.value); 2505 }); 2506 validModifiers.set('model', (modifier) => { 2507 if (!modifier.value) { 2508 throw new Error(`The "model" in data-loading must have an action name - e.g. model(foo). It's missing for "${directive.getString()}"`); 2509 } 2510 targetedModels.push(modifier.value); 2511 });
2512 directive.modifiers.forEach((modifier) => { 2513 if (validModifiers.has(modifier.name)) { 2514 const callable = validModifiers.get(modifier.name) ?? (() => { }); 2515 callable(modifier); 2516 return; 2517 } 2518 throw new Error(`Unknown modifier "${modifier.name}" used in data-loading="${directive.getString()}". Available modifiers are: ${Array.from(validModifiers.keys()).join(', ')}.`); 2519 }); 2520 if (isLoading && 2521 targetedActions.length > 0 && 2522 backendRequest && 2523 !backendRequest.containsOneOfActions(targetedActions)) { 2524 return; 2525 } 2526 if (isLoading && 2527 targetedModels.length > 0 && 2528 backendRequest && 2529 !backendRequest.areAnyModelsUpdated(targetedModels)) { 2530 return; 2531 } 2532 let loadingDirective; 2533 switch (finalAction) { 2534 case 'show': 2535 loadingDirective = () => this.showElement(element); 2536 break; 2537 case 'hide': 2538 loadingDirective = () => this.hideElement(element); 2539 break; 2540 case 'addClass': 2541 loadingDirective = () => this.addClass(element, directive.args); 2542 break; 2543 case 'removeClass': 2544 loadingDirective = () => this.removeClass(element, directive.args); 2545 break; 2546 case 'addAttribute': 2547 loadingDirective = () => this.addAttributes(element, directive.args); 2548 break; 2549 case 'removeAttribute': 2550 loadingDirective = () => this.removeAttributes(element, directive.args); 2551 break; 2552 default: 2553 throw new Error(`Unknown data-loading action "${finalAction}"`); 2554 } 2555 if (delay) { 2556 window.setTimeout(() => { 2557 if (backendRequest && !backendRequest.isResolved) { 2558 loadingDirective(); 2559 } 2560 }, delay); 2561 return; 2562 } 2563 loadingDirective(); 2564 } 2565 getLoadingDirectives(component, element) { 2566 const loadingDirectives = []; 2567 let matchingElements = [...Array.from(element.querySelectorAll('[data-loading]'))]; 2568 matchingElements = matchingElements.filter((elt) => elementBelongsToThisComponent(elt, component)); 2569 if (element.hasAttribute('data-loading')) { 2570 matchingElements = [element, ...matchingElements]; 2571 } 2572 matchingElements.forEach((element) => { 2573 if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) { 2574 throw new Error('Invalid Element Type'); 2575 } 2576 const directives = parseDirectives(element.dataset.loading || 'show'); 2577 loadingDirectives.push({ 2578 element, 2579 directives, 2580 }); 2581 }); 2582 return loadingDirectives; 2583 } 2584 showElement(element) { 2585 element.style.display = 'revert'; 2586 } 2587 hideElement(element) { 2588 element.style.display = 'none'; 2589 } 2590 addClass(element, classes) { 2591 element.classList.add(...combineSpacedArray(classes)); 2592 } 2593 removeClass(element, classes) { 2594 element.classList.remove(...combineSpacedArray(classes)); 2595 if (element.classList.length === 0) { 2596 element.removeAttribute('class'); 2597 } 2598 } 2599 addAttributes(element, attributes) { 2600 attributes.forEach((attribute) => { 2601 element.setAttribute(attribute, ''); 2602 }); 2603 } 2604 removeAttributes(element, attributes) { 2605 attributes.forEach((attribute) => { 2606 element.removeAttribute(attribute); 2607 }); 2608 } 2609} 2610const parseLoadingAction = (action, isLoading) => { 2611 switch (action) { 2612 case 'show': 2613 return isLoading ? 'show' : 'hide'; 2614 case 'hide': 2615 return isLoading ? 'hide' : 'show'; 2616 case 'addClass': 2617 return isLoading ? 'addClass' : 'removeClass'; 2618 case 'removeClass': 2619 return isLoading ? 'removeClass' : 'addClass'; 2620 case 'addAttribute': 2621 return isLoading ? 'addAttribute' : 'removeAttribute'; 2622 case 'removeAttribute': 2623 return isLoading ? 'removeAttribute' : 'addAttribute'; 2624 } 2625 throw new Error(`Unknown data-loading action "${action}"`); 2626}; 2627 2628class PageUnloadingPlugin { 2629 constructor() { 2630 this.isConnected = false;
2631 } 2632 attachToComponent(component) { 2633 component.on('render:started', (html, response, controls) => { 2634 if (!this.isConnected) { 2635 controls.shouldRender = false; 2636 } 2637 }); 2638 component.on('connect', () => { 2639 this.isConnected = true; 2640 }); 2641 component.on('disconnect', () => { 2642 this.isConnected = false; 2643 }); 2644 } 2645} 2646 2647class PollingDirector { 2648 constructor(component) { 2649 this.isPollingActive = true; 2650 this.pollingIntervals = []; 2651 this.component = component; 2652 } 2653 addPoll(actionName, duration) { 2654 this.polls.push({ actionName, duration }); 2655 if (this.isPollingActive) { 2656 this.initiatePoll(actionName, duration); 2657 } 2658 } 2659 startAllPolling() { 2660 if (this.isPollingActive) { 2661 return; 2662 } 2663 this.isPollingActive = true; 2664 this.polls.forEach(({ actionName, duration }) => { 2665 this.initiatePoll(actionName, duration); 2666 }); 2667 } 2668 stopAllPolling() { 2669 this.isPollingActive = false; 2670 this.pollingIntervals.forEach((interval) => { 2671 clearInterval(interval); 2672 }); 2673 } 2674 clearPolling() { 2675 this.stopAllPolling(); 2676 this.polls = []; 2677 this.startAllPolling(); 2678 } 2679 initiatePoll(actionName, duration) { 2680 let callback; 2681 if (actionName === '$render') { 2682 callback = () => { 2683 this.component.render(); 2684 }; 2685 } 2686 else { 2687 callback = () => { 2688 this.component.action(actionName, {}, 0); 2689 }; 2690 } 2691 const timer = window.setInterval(() => { 2692 callback(); 2693 }, duration); 2694 this.pollingIntervals.push(timer); 2695 } 2696} 2697 2698class PollingPlugin { 2699 attachToComponent(component) { 2700 this.element = component.element; 2701 this.pollingDirector = new PollingDirector(component); 2702 this.initializePolling(); 2703 component.on('connect', () => { 2704 this.pollingDirector.startAllPolling(); 2705 }); 2706 component.on('disconnect', () => { 2707 this.pollingDirector.stopAllPolling(); 2708 }); 2709 component.on('render:finished', () => { 2710 this.initializePolling(); 2711 }); 2712 } 2713 addPoll(actionName, duration) { 2714 this.pollingDirector.addPoll(actionName, duration); 2715 } 2716 clearPolling() { 2717 this.pollingDirector.clearPolling(); 2718 } 2719 initializePolling() { 2720 this.clearPolling(); 2721 if (this.element.dataset.poll === undefined) { 2722 return; 2723 } 2724 const rawPollConfig = this.element.dataset.poll; 2725 const directives = parseDirectives(rawPollConfig || '$render'); 2726 directives.forEach((directive) => { 2727 let duration = 2000; 2728 directive.modifiers.forEach((modifier) => { 2729 switch (modifier.name) { 2730 case 'delay': 2731 if (modifier.value) { 2732 duration = Number.parseInt(modifier.value); 2733 } 2734 break; 2735 default: 2736 console.warn(`Unknown modifier "${modifier.name}" in data-poll "${rawPollConfig}".`); 2737 } 2738 }); 2739 this.addPoll(directive.action, duration); 2740 }); 2741 } 2742} 2743 2744function isValueEmpty(value) { 2745 if (null === value || value === '' || undefined === value || (Array.isArray(value) && value.length === 0)) { 2746 return true; 2747 } 2748 if (typeof value !== 'object') { 2749 return false; 2750 } 2751 for (const key of Object.keys(value)) { 2752 if (!isValueEmpty(value[key])) { 2753 return false; 2754 } 2755 } 2756 return true; 2757} 2758function toQueryString(data) { 2759 const buildQueryStringEntries = (data, entries = {}, baseKey = '') => { 2760 Object.entries(data).forEach(([iKey, iValue]) => { 2761 const key = baseKey === '' ? iKey : `${baseKey}[${iKey}]`; 2762 if ('' === baseKey && isValueEmpty(iValue)) { 2763 entries[key] = ''; 2764 } 2765 else if (null !== iValue) { 2766 if (typeof iValue === 'object') {
2767 entries = { ...entries, ...buildQueryStringEntries(iValue, entries, key) }; 2768 } 2769 else { 2770 entries[key] = encodeURIComponent(iValue) 2771 .replace(/%20/g, '+') 2772 .replace(/%2C/g, ','); 2773 } 2774 } 2775 }); 2776 return entries; 2777 }; 2778 const entries = buildQueryStringEntries(data); 2779 return Object.entries(entries) 2780 .map(([key, value]) => `${key}=${value}`) 2781 .join('&'); 2782} 2783function fromQueryString(search) { 2784 search = search.replace('?', ''); 2785 if (search === '') 2786 return {}; 2787 const insertDotNotatedValueIntoData = (key, value, data) => { 2788 const [first, second, ...rest] = key.split('.'); 2789 if (!second) { 2790 data[key] = value; 2791 return value; 2792 } 2793 if (data[first] === undefined) { 2794 data[first] = Number.isNaN(Number.parseInt(second)) ? {} : []; 2795 } 2796 insertDotNotatedValueIntoData([second, ...rest].join('.'), value, data[first]); 2797 }; 2798 const entries = search.split('&').map((i) => i.split('=')); 2799 const data = {}; 2800 entries.forEach(([key, value]) => { 2801 value = decodeURIComponent(value.replace(/\+/g, '%20')); 2802 if (!key.includes('[')) { 2803 data[key] = value; 2804 } 2805 else { 2806 if ('' === value) 2807 return; 2808 const dotNotatedKey = key.replace(/\[/g, '.').replace(/]/g, ''); 2809 insertDotNotatedValueIntoData(dotNotatedKey, value, data); 2810 } 2811 }); 2812 return data; 2813} 2814class UrlUtils extends URL { 2815 has(key) { 2816 const data = this.getData(); 2817 return Object.keys(data).includes(key); 2818 } 2819 set(key, value) { 2820 const data = this.getData(); 2821 data[key] = value; 2822 this.setData(data); 2823 } 2824 get(key) { 2825 return this.getData()[key]; 2826 } 2827 remove(key) { 2828 const data = this.getData(); 2829 delete data[key]; 2830 this.setData(data); 2831 } 2832 getData() { 2833 if (!this.search) { 2834 return {}; 2835 } 2836 return fromQueryString(this.search); 2837 } 2838 setData(data) { 2839 this.search = toQueryString(data); 2840 } 2841} 2842class HistoryStrategy { 2843 static replace(url) { 2844 history.replaceState(history.state, '', url); 2845 } 2846} 2847 2848class QueryStringPlugin { 2849 constructor(mapping) { 2850 this.mapping = mapping; 2851 } 2852 attachToComponent(component) { 2853 component.on('render:finished', (component) => { 2854 const urlUtils = new UrlUtils(window.location.href); 2855 const currentUrl = urlUtils.toString(); 2856 Object.entries(this.mapping).forEach(([prop, mapping]) => { 2857 const value = component.valueStore.get(prop); 2858 urlUtils.set(mapping.name, value); 2859 }); 2860 if (currentUrl !== urlUtils.toString()) { 2861 HistoryStrategy.replace(urlUtils); 2862 } 2863 }); 2864 } 2865} 2866 2867class SetValueOntoModelFieldsPlugin { 2868 attachToComponent(component) { 2869 this.synchronizeValueOfModelFields(component); 2870 component.on('render:finished', () => { 2871 this.synchronizeValueOfModelFields(component); 2872 }); 2873 } 2874 synchronizeValueOfModelFields(component) { 2875 component.element.querySelectorAll('[data-model]').forEach((element) => { 2876 if (!(element instanceof HTMLElement)) { 2877 throw new Error('Invalid element using data-model.'); 2878 } 2879 if (element instanceof HTMLFormElement) { 2880 return; 2881 } 2882 if (!elementBelongsToThisComponent(element, component)) { 2883 return; 2884 } 2885 const modelDirective = getModelDirectiveFromElement(element); 2886 if (!modelDirective) { 2887 return; 2888 } 2889 const modelName = modelDirective.action; 2890 if (component.getUnsyncedModels().includes(modelName)) { 2891 return; 2892 } 2893 if (component.valueStore.has(modelName)) { 2894 setValueOnElement(element, component.valueStore.get(modelName)); 2895 } 2896 if (element instanceof HTMLSelectElement && !element.multiple) { 2897 component.valueStore.set(modelName, getValueFromElement(element, component.valueStore)); 2898 } 2899 }); 2900 } 2901} 2902 2903class ValidatedFieldsPlugin { 2904 attachToComponent(component) { 2905 component.on('model:set', (modelName) => { 2906 this.handleModelSet(modelName, component.valueStore); 2907 }); 2908 } 2909 handleModelSet(modelName, valueStore) { 2910 if (valueStore.has('validatedFields')) { 2911 const validatedFields = [...valueStore.get('validatedFields')]; 2912 if (!validatedFields.includes(modelName)) { 2913 validatedFields.push(modelName); 2914 } 2915 valueStore.set('validatedFields', validatedFields); 2916 } 2917 } 2918} 2919 2920class LiveControllerDefault extends Controller { 2921 constructor() { 2922 super(...arguments); 2923 this.pendingActionTriggerModelElement = null; 2924 this.elementEventListeners = [ 2925 { event: 'input', callback: (event) => this.handleInputEvent(event) }, 2926 { event: 'change', callback: (event) => this.handleChangeEvent(event) }, 2927 ]; 2928 this.pendingFiles = {}; 2929 } 2930 initialize() { 2931 this.mutationObserver = new MutationObserver(this.onMutations.bind(this)); 2932 this.createComponent(); 2933 } 2934 connect() { 2935 this.connectComponent(); 2936 this.mutationObserver.observe(this.element, { 2937 attributes: true, 2938 }); 2939 } 2940 disconnect() { 2941 this.disconnectComponent(); 2942 this.mutationObserver.disconnect(); 2943 } 2944 update(event) { 2945 if (event.type === 'input' || event.type === 'change') { 2946 throw new Error(`Since LiveComponents 2.3, you no longer need data-action="live#update" on form elements. Found on element: ${getElementAsTagText(event.currentTarget)}`); 2947 } 2948 this.updateModelFromElementEvent(event.currentTarget, null); 2949 } 2950 action(event) { 2951 const params = event.params; 2952 if (!params.action) { 2953 throw new Error(`No action name provided on element: ${getElementAsTagText(event.currentTarget)}. Did you forget to add the "data-live-action-param" attribute?`); 2954 } 2955 const rawAction = params.action; 2956 const actionArgs = { ...params }; 2957 delete actionArgs.action; 2958 const directives = parseDirectives(rawAction); 2959 let debounce = false;
2960 directives.forEach((directive) => { 2961 let pendingFiles = {}; 2962 const validModifiers = new Map(); 2963 validModifiers.set('stop', () => { 2964 event.stopPropagation(); 2965 }); 2966 validModifiers.set('self', () => { 2967 if (event.target !== event.currentTarget) { 2968 return; 2969 } 2970 }); 2971 validModifiers.set('debounce', (modifier) => { 2972 debounce = modifier.value ? Number.parseInt(modifier.value) : true; 2973 }); 2974 validModifiers.set('files', (modifier) => { 2975 if (!modifier.value) { 2976 pendingFiles = this.pendingFiles; 2977 } 2978 else if (this.pendingFiles[modifier.value]) { 2979 pendingFiles[modifier.value] = this.pendingFiles[modifier.value]; 2980 } 2981 }); 2982 directive.modifiers.forEach((modifier) => { 2983 if (validModifiers.has(modifier.name)) { 2984 const callable = validModifiers.get(modifier.name) ?? (() => { }); 2985 callable(modifier); 2986 return; 2987 } 2988 console.warn(`Unknown modifier ${modifier.name} in action "${rawAction}". Available modifiers are: ${Array.from(validModifiers.keys()).join(', ')}.`); 2989 }); 2990 for (const [key, input] of Object.entries(pendingFiles)) { 2991 if (input.files) { 2992 this.component.files(key, input); 2993 } 2994 delete this.pendingFiles[key]; 2995 } 2996 this.component.action(directive.action, actionArgs, debounce); 2997 if (getModelDirectiveFromElement(event.currentTarget, false)) { 2998 this.pendingActionTriggerModelElement = event.currentTarget; 2999 } 3000 }); 3001 } 3002 $render() { 3003 return this.component.render(); 3004 } 3005 emit(event) { 3006 this.getEmitDirectives(event).forEach(({ name, data, nameMatch }) => { 3007 this.component.emit(name, data, nameMatch); 3008 }); 3009 } 3010 emitUp(event) { 3011 this.getEmitDirectives(event).forEach(({ name, data, nameMatch }) => { 3012 this.component.emitUp(name, data, nameMatch); 3013 }); 3014 } 3015 emitSelf(event) { 3016 this.getEmitDirectives(event).forEach(({ name, data }) => { 3017 this.component.emitSelf(name, data); 3018 }); 3019 } 3020 $updateModel(model, value, shouldRender = true, debounce = true) { 3021 return this.component.set(model, value, shouldRender, debounce); 3022 } 3023 propsUpdatedFromParentValueChanged() { 3024 this.component._updateFromParentProps(this.propsUpdatedFromParentValue); 3025 } 3026 fingerprintValueChanged() { 3027 this.component.fingerprint = this.fingerprintValue; 3028 } 3029 getEmitDirectives(event) { 3030 const params = event.params; 3031 if (!params.event) { 3032 throw new Error(`No event name provided on element: ${getElementAsTagText(event.currentTarget)}. Did you forget to add the "data-live-event-param" attribute?`); 3033 } 3034 const eventInfo = params.event; 3035 const eventArgs = { ...params }; 3036 delete eventArgs.event; 3037 const directives = parseDirectives(eventInfo); 3038 const emits = []; 3039 directives.forEach((directive) => { 3040 let nameMatch = null; 3041 directive.modifiers.forEach((modifier) => { 3042 switch (modifier.name) { 3043 case 'name': 3044 nameMatch = modifier.value; 3045 break; 3046 default: 3047 throw new Error(`Unknown modifier ${modifier.name} in event "${eventInfo}".`); 3048 } 3049 }); 3050 emits.push({ 3051 name: directive.action, 3052 data: eventArgs, 3053 nameMatch, 3054 }); 3055 }); 3056 return emits; 3057 } 3058 createComponent() { 3059 const id = this.element.id || null; 3060 this.component = new Component(this.element, this.nameValue, this.propsValue, this.listenersValue, id, LiveControllerDefault.backendFactory(this), new StimulusElementDriver(this)); 3061 this.proxiedComponent = proxifyComponent(this.component); 3062 Object.defineProperty(this.element, '__component', { 3063 value: this.proxiedComponent, 3064 writable: true, 3065 }); 3066 if (this.hasDebounceValue) { 3067 this.component.defaultDebounce = this.debounceValue; 3068 } 3069 const plugins = [ 3070 new LoadingPlugin(), 3071 new LazyPlugin(), 3072 new ValidatedFieldsPlugin(), 3073 new PageUnloadingPlugin(), 3074 new PollingPlugin(), 3075 new SetValueOntoModelFieldsPlugin(), 3076 new QueryStringPlugin(this.queryMappingValue), 3077 new ChildComponentPlugin(this.component), 3078 ]; 3079 plugins.forEach((plugin) => { 3080 this.component.addPlugin(plugin); 3081 }); 3082 } 3083 connectComponent() { 3084 this.component.connect(); 3085 this.mutationObserver.observe(this.element, { 3086 attributes: true, 3087 });
3088 this.elementEventListeners.forEach(({ event, callback }) => { 3089 this.component.element.addEventListener(event, callback); 3090 }); 3091 this.dispatchEvent('connect'); 3092 } 3093 disconnectComponent() { 3094 this.component.disconnect(); 3095 this.elementEventListeners.forEach(({ event, callback }) => { 3096 this.component.element.removeEventListener(event, callback); 3097 }); 3098 this.dispatchEvent('disconnect'); 3099 } 3100 handleInputEvent(event) { 3101 const target = event.target; 3102 if (!target) { 3103 return; 3104 } 3105 this.updateModelFromElementEvent(target, 'input'); 3106 } 3107 handleChangeEvent(event) { 3108 const target = event.target; 3109 if (!target) { 3110 return; 3111 } 3112 this.updateModelFromElementEvent(target, 'change'); 3113 } 3114 updateModelFromElementEvent(element, eventName) { 3115 if (!elementBelongsToThisComponent(element, this.component)) { 3116 return; 3117 } 3118 if (!(element instanceof HTMLElement)) { 3119 throw new Error('Could not update model for non HTMLElement'); 3120 } 3121 if (element instanceof HTMLInputElement && element.type === 'file') { 3122 const key = element.name; 3123 if (element.files?.length) { 3124 this.pendingFiles[key] = element; 3125 } 3126 else if (this.pendingFiles[key]) { 3127 delete this.pendingFiles[key]; 3128 } 3129 } 3130 const modelDirective = getModelDirectiveFromElement(element, false); 3131 if (!modelDirective) { 3132 return; 3133 } 3134 const modelBinding = getModelBinding(modelDirective); 3135 if (!modelBinding.targetEventName) { 3136 modelBinding.targetEventName = 'input'; 3137 } 3138 if (this.pendingActionTriggerModelElement === element) { 3139 modelBinding.shouldRender = false;
3140 } 3141 if (eventName === 'change' && modelBinding.targetEventName === 'input') { 3142 modelBinding.targetEventName = 'change'; 3143 } 3144 if (eventName && modelBinding.targetEventName !== eventName) { 3145 return; 3146 } 3147 if (false === modelBinding.debounce) { 3148 if (modelBinding.targetEventName === 'input') { 3149 modelBinding.debounce = true; 3150 } 3151 else { 3152 modelBinding.debounce = 0; 3153 } 3154 } 3155 const finalValue = getValueFromElement(element, this.component.valueStore); 3156 this.component.set(modelBinding.modelName, finalValue, modelBinding.shouldRender, modelBinding.debounce); 3157 } 3158 dispatchEvent(name, detail = {}, canBubble = true, cancelable = false) { 3159 detail.controller = this; 3160 detail.component = this.proxiedComponent; 3161 this.dispatch(name, { detail, prefix: 'live', cancelable, bubbles: canBubble }); 3162 } 3163 onMutations(mutations) { 3164 mutations.forEach((mutation) => { 3165 if (mutation.type === 'attributes' && 3166 mutation.attributeName === 'id' && 3167 this.element.id !== this.component.id) { 3168 this.disconnectComponent(); 3169 this.createComponent(); 3170 this.connectComponent(); 3171 } 3172 }); 3173 } 3174} 3175LiveControllerDefault.values = { 3176 name: String, 3177 url: String, 3178 props: { type: Object, default: {} }, 3179 propsUpdatedFromParent: { type: Object, default: {} }, 3180 listeners: { type: Array, default: [] }, 3181 eventsToEmit: { type: Array, default: [] }, 3182 eventsToDispatch: { type: Array, default: [] }, 3183 debounce: { type: Number, default: 150 }, 3184 fingerprint: { type: String, default: '' }, 3185 requestMethod: { type: String, default: 'post' }, 3186 queryMapping: { type: Object, default: {} }, 3187}; 3188LiveControllerDefault.backendFactory = (controller) => new Backend(controller.urlValue, controller.requestMethodValue); 3189 3190export { Component, LiveControllerDefault as default, getComponent };
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.