morph.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. let resolveStep = () => {}
  2. let logger = () => {}
  3. export function morph(from, toHtml, options) {
  4. monkeyPatchDomSetAttributeToAllowAtSymbols()
  5. // We're defining these globals and methods inside this function (instead of outside)
  6. // because it's an async function and if run twice, they would overwrite
  7. // each other.
  8. let context = createMorphContext(options)
  9. // Finally we morph the element
  10. let toEl = typeof toHtml === 'string' ? createElement(toHtml) : toHtml
  11. if (window.Alpine && window.Alpine.closestDataStack && ! from._x_dataStack) {
  12. // Just in case a part of this template uses Alpine scope from somewhere
  13. // higher in the DOM tree, we'll find that state and replace it on the root
  14. // element so everything is synced up accurately.
  15. toEl._x_dataStack = window.Alpine.closestDataStack(from)
  16. // We will kick off a clone on the root element.
  17. toEl._x_dataStack && window.Alpine.cloneNode(from, toEl)
  18. }
  19. context.patch(from, toEl)
  20. return from
  21. }
  22. export function morphBetween(startMarker, endMarker, toHtml, options = {}) {
  23. monkeyPatchDomSetAttributeToAllowAtSymbols()
  24. let context = createMorphContext(options)
  25. // Setup from block...
  26. let fromContainer = startMarker.parentNode
  27. let fromBlock = new Block(startMarker, endMarker)
  28. // Setup to block...
  29. let toContainer = typeof toHtml === 'string'
  30. ? (() => {
  31. let container = document.createElement('div')
  32. container.insertAdjacentHTML('beforeend', toHtml)
  33. return container
  34. })()
  35. : toHtml
  36. let toStartMarker = document.createComment('[morph-start]')
  37. let toEndMarker = document.createComment('[morph-end]')
  38. toContainer.insertBefore(toStartMarker, toContainer.firstChild)
  39. toContainer.appendChild(toEndMarker)
  40. let toBlock = new Block(toStartMarker, toEndMarker)
  41. if (window.Alpine && window.Alpine.closestDataStack) {
  42. toContainer._x_dataStack = window.Alpine.closestDataStack(fromContainer)
  43. toContainer._x_dataStack && window.Alpine.cloneNode(fromContainer, toContainer)
  44. }
  45. // Run the patch
  46. context.patchChildren(fromBlock, toBlock)
  47. }
  48. function createMorphContext(options = {}) {
  49. let defaultGetKey = el => el.getAttribute('key')
  50. let noop = () => {}
  51. let context = {
  52. key: options.key || defaultGetKey,
  53. lookahead: options.lookahead || false,
  54. updating: options.updating || noop,
  55. updated: options.updated || noop,
  56. removing: options.removing || noop,
  57. removed: options.removed || noop,
  58. adding: options.adding || noop,
  59. added: options.added || noop
  60. }
  61. context.patch = function(from, to) {
  62. if (context.differentElementNamesTypesOrKeys(from, to)) {
  63. return context.swapElements(from, to)
  64. }
  65. let updateChildrenOnly = false
  66. let skipChildren = false
  67. // If we used `shouldSkip()` here and append the `skipChildren` function on the end, it will cause the signature of the `updating`
  68. // hook to change. For example, when it was `shouldSkip()` the signature was `updating: (el, toEl, childrenOnly, skip)`. But if
  69. // we append `skipChildren()`, it would make the signature `updating: (el, toEl, childrenOnly, skipChildren, skip)`. This is
  70. // a breaking change due to how the `shouldSkip()` function is structured.
  71. //
  72. // So we're using `shouldSkipChildren()` instead which doesn't have this problem as it allows us to pass in the `skipChildren()`
  73. // function as an earlier parameter and then append it to the `updating` hook signature manually. The signature of `updating`
  74. // hook is now `updating: (el, toEl, childrenOnly, skip, skipChildren)`.
  75. let skipUntil = predicate => context.skipUntilCondition = predicate
  76. if (shouldSkipChildren(context.updating, () => skipChildren = true, skipUntil, from, to, () => updateChildrenOnly = true)) return
  77. // Initialize the server-side HTML element with Alpine...
  78. if (from.nodeType === 1 && window.Alpine) {
  79. window.Alpine.cloneNode(from, to)
  80. if (from._x_teleport && to._x_teleport) {
  81. context.patch(from._x_teleport, to._x_teleport)
  82. }
  83. }
  84. if (textOrComment(to)) {
  85. context.patchNodeValue(from, to)
  86. context.updated(from, to)
  87. return
  88. }
  89. if (! updateChildrenOnly) {
  90. context.patchAttributes(from, to)
  91. }
  92. context.updated(from, to)
  93. if (! skipChildren) {
  94. context.patchChildren(from, to)
  95. }
  96. }
  97. context.differentElementNamesTypesOrKeys = function(from, to) {
  98. return from.nodeType != to.nodeType
  99. || from.nodeName != to.nodeName
  100. || context.getKey(from) != context.getKey(to)
  101. }
  102. context.swapElements = function(from, to) {
  103. if (shouldSkip(context.removing, from)) return
  104. let toCloned = to.cloneNode(true)
  105. if (shouldSkip(context.adding, toCloned)) return
  106. from.replaceWith(toCloned)
  107. context.removed(from)
  108. context.added(toCloned)
  109. }
  110. context.patchNodeValue = function(from, to) {
  111. let value = to.nodeValue
  112. if (from.nodeValue !== value) {
  113. // Change text node...
  114. from.nodeValue = value
  115. }
  116. }
  117. context.patchAttributes = function(from, to) {
  118. if (from._x_transitioning) return
  119. if (from._x_isShown && ! to._x_isShown) {
  120. return
  121. }
  122. if (! from._x_isShown && to._x_isShown) {
  123. return
  124. }
  125. let domAttributes = Array.from(from.attributes)
  126. let toAttributes = Array.from(to.attributes)
  127. for (let i = domAttributes.length - 1; i >= 0; i--) {
  128. let name = domAttributes[i].name;
  129. if (! to.hasAttribute(name)) {
  130. // Remove attribute...
  131. from.removeAttribute(name)
  132. }
  133. }
  134. for (let i = toAttributes.length - 1; i >= 0; i--) {
  135. let name = toAttributes[i].name
  136. let value = toAttributes[i].value
  137. if (from.getAttribute(name) !== value) {
  138. from.setAttribute(name, value)
  139. }
  140. }
  141. }
  142. context.patchChildren = function(from, to) {
  143. let fromKeys = context.keyToMap(from.children)
  144. let fromKeyHoldovers = {}
  145. let currentTo = getFirstNode(to)
  146. let currentFrom = getFirstNode(from)
  147. while (currentTo) {
  148. // If the "from" element has a dynamically bound "id" (x-bind:id="..."),
  149. // Let's transfer it to the "to" element so that there isn't a key mismatch...
  150. seedingMatchingId(currentTo, currentFrom)
  151. let toKey = context.getKey(currentTo)
  152. let fromKey = context.getKey(currentFrom)
  153. if (context.skipUntilCondition) {
  154. let fromDone = ! currentFrom || context.skipUntilCondition(currentFrom)
  155. let toDone = ! currentTo || context.skipUntilCondition(currentTo)
  156. if (fromDone && toDone) {
  157. context.skipUntilCondition = null
  158. } else {
  159. if (! fromDone) currentFrom = currentFrom && getNextSibling(from, currentFrom)
  160. if (! toDone) currentTo = currentTo && getNextSibling(to, currentTo)
  161. continue
  162. }
  163. }
  164. // Add new elements...
  165. if (! currentFrom) {
  166. if (toKey && fromKeyHoldovers[toKey]) {
  167. // Add element (from key)...
  168. let holdover = fromKeyHoldovers[toKey]
  169. from.appendChild(holdover)
  170. currentFrom = holdover
  171. fromKey = context.getKey(currentFrom)
  172. } else {
  173. if(! shouldSkip(context.adding, currentTo)) {
  174. // Add element...
  175. let clone = currentTo.cloneNode(true)
  176. from.appendChild(clone)
  177. context.added(clone)
  178. }
  179. currentTo = getNextSibling(to, currentTo)
  180. continue
  181. }
  182. }
  183. // Handle conditional markers (presumably added by backends like Livewire)...
  184. let isIf = node => node && node.nodeType === 8 && node.textContent === '[if BLOCK]><![endif]'
  185. let isEnd = node => node && node.nodeType === 8 && node.textContent === '[if ENDBLOCK]><![endif]'
  186. if (isIf(currentTo) && isIf(currentFrom)) {
  187. let nestedIfCount = 0
  188. let fromBlockStart = currentFrom
  189. while (currentFrom) {
  190. let next = getNextSibling(from, currentFrom)
  191. if (isIf(next)) {
  192. nestedIfCount++
  193. } else if (isEnd(next) && nestedIfCount > 0) {
  194. nestedIfCount--
  195. } else if (isEnd(next) && nestedIfCount === 0) {
  196. currentFrom = next
  197. break;
  198. }
  199. currentFrom = next
  200. }
  201. let fromBlockEnd = currentFrom
  202. nestedIfCount = 0
  203. let toBlockStart = currentTo
  204. while (currentTo) {
  205. let next = getNextSibling(to, currentTo)
  206. if (isIf(next)) {
  207. nestedIfCount++
  208. } else if (isEnd(next) && nestedIfCount > 0) {
  209. nestedIfCount--
  210. } else if (isEnd(next) && nestedIfCount === 0) {
  211. currentTo = next
  212. break;
  213. }
  214. currentTo = next
  215. }
  216. let toBlockEnd = currentTo
  217. let fromBlock = new Block(fromBlockStart, fromBlockEnd)
  218. let toBlock = new Block(toBlockStart, toBlockEnd)
  219. context.patchChildren(fromBlock, toBlock)
  220. continue
  221. }
  222. // Lookaheads should only apply to non-text-or-comment elements...
  223. if (currentFrom.nodeType === 1 && context.lookahead && ! currentFrom.isEqualNode(currentTo)) {
  224. let nextToElementSibling = getNextSibling(to, currentTo)
  225. let found = false
  226. while (! found && nextToElementSibling) {
  227. if (nextToElementSibling.nodeType === 1 && currentFrom.isEqualNode(nextToElementSibling)) {
  228. found = true; // This ";" needs to be here...
  229. currentFrom = context.addNodeBefore(from, currentTo, currentFrom)
  230. fromKey = context.getKey(currentFrom)
  231. }
  232. nextToElementSibling = getNextSibling(to, nextToElementSibling)
  233. }
  234. }
  235. if (toKey !== fromKey) {
  236. if (! toKey && fromKey) {
  237. // No "to" key...
  238. fromKeyHoldovers[fromKey] = currentFrom; // This ";" needs to be here...
  239. currentFrom = context.addNodeBefore(from, currentTo, currentFrom)
  240. fromKeyHoldovers[fromKey].remove()
  241. currentFrom = getNextSibling(from, currentFrom)
  242. currentTo = getNextSibling(to, currentTo)
  243. continue
  244. }
  245. if (toKey && ! fromKey) {
  246. if (fromKeys[toKey]) {
  247. // No "from" key...
  248. currentFrom.replaceWith(fromKeys[toKey])
  249. currentFrom = fromKeys[toKey]
  250. fromKey = context.getKey(currentFrom)
  251. }
  252. }
  253. if (toKey && fromKey) {
  254. let fromKeyNode = fromKeys[toKey]
  255. if (fromKeyNode) {
  256. // Move "from" key...
  257. fromKeyHoldovers[fromKey] = currentFrom
  258. currentFrom.replaceWith(fromKeyNode)
  259. currentFrom = fromKeyNode
  260. fromKey = context.getKey(currentFrom)
  261. } else {
  262. // Swap elements with keys...
  263. fromKeyHoldovers[fromKey] = currentFrom; // This ";" needs to be here...
  264. currentFrom = context.addNodeBefore(from, currentTo, currentFrom)
  265. fromKeyHoldovers[fromKey].remove()
  266. currentFrom = getNextSibling(from, currentFrom)
  267. currentTo = getNextSibling(to, currentTo)
  268. continue
  269. }
  270. }
  271. }
  272. // Get next from sibling before patching in case the node is replaced
  273. let currentFromNext = currentFrom && getNextSibling(from, currentFrom) //dom.next(from, fromChildren, currentFrom))
  274. // Patch elements
  275. context.patch(currentFrom, currentTo)
  276. currentTo = currentTo && getNextSibling(to, currentTo) // dom.next(from, toChildren, currentTo))
  277. currentFrom = currentFromNext
  278. }
  279. // Cleanup extra forms.
  280. let removals = []
  281. // We need to collect the "removals" first before actually
  282. // removing them so we don't mess with the order of things.
  283. while (currentFrom) {
  284. if (! shouldSkip(context.removing, currentFrom)) removals.push(currentFrom)
  285. // currentFrom = dom.next(fromChildren, currentFrom)
  286. currentFrom = getNextSibling(from, currentFrom)
  287. }
  288. // Now we can do the actual removals.
  289. while (removals.length) {
  290. let domForRemoval = removals.shift()
  291. domForRemoval.remove()
  292. context.removed(domForRemoval)
  293. }
  294. }
  295. context.getKey = function(el) {
  296. return el && el.nodeType === 1 && context.key(el)
  297. }
  298. context.keyToMap = function(els) {
  299. let map = {}
  300. for (let el of els) {
  301. let theKey = context.getKey(el)
  302. if (theKey) {
  303. map[theKey] = el
  304. }
  305. }
  306. return map
  307. }
  308. context.addNodeBefore = function(parent, node, beforeMe) {
  309. if(! shouldSkip(context.adding, node)) {
  310. let clone = node.cloneNode(true)
  311. parent.insertBefore(clone, beforeMe)
  312. context.added(clone)
  313. return clone
  314. }
  315. return node
  316. }
  317. return context
  318. }
  319. // These are legacy holdovers that don't do anything anymore...
  320. morph.step = () => {}
  321. morph.log = () => {}
  322. function shouldSkip(hook, ...args) {
  323. let skip = false
  324. hook(...args, () => skip = true)
  325. return skip
  326. }
  327. // Due to the structure of the `shouldSkip()` function, we can't pass in the `skipChildren`
  328. // function as an argument as it would change the signature of the existing hooks. So we
  329. // are using this function instead which doesn't have this problem as we can pass the
  330. // `skipChildren` function in as an earlier argument and then append it to the end
  331. // of the hook signature manually.
  332. function shouldSkipChildren(hook, skipChildren, skipUntil, ...args) {
  333. let skip = false
  334. hook(...args, () => skip = true, skipChildren, skipUntil)
  335. return skip
  336. }
  337. let patched = false
  338. export function createElement(html) {
  339. const template = document.createElement('template')
  340. template.innerHTML = html
  341. return template.content.firstElementChild
  342. }
  343. export function textOrComment(el) {
  344. return el.nodeType === 3
  345. || el.nodeType === 8
  346. }
  347. // "Block"s are used when morphing with conditional markers.
  348. // They allow us to patch isolated portions of a list of
  349. // siblings in a DOM tree...
  350. class Block {
  351. constructor(start, end) {
  352. // We're assuming here that the start and end caps are comment blocks...
  353. this.startComment = start
  354. this.endComment = end
  355. }
  356. get children() {
  357. let children = [];
  358. let currentNode = this.startComment.nextSibling
  359. while (currentNode && currentNode !== this.endComment) {
  360. children.push(currentNode)
  361. currentNode = currentNode.nextSibling
  362. }
  363. return children
  364. }
  365. appendChild(child) {
  366. this.endComment.before(child)
  367. }
  368. get firstChild() {
  369. let first = this.startComment.nextSibling
  370. if (first === this.endComment) return
  371. return first
  372. }
  373. nextNode(reference) {
  374. let next = reference.nextSibling
  375. if (next === this.endComment) return
  376. return next
  377. }
  378. insertBefore(newNode, reference) {
  379. reference.before(newNode)
  380. return newNode
  381. }
  382. }
  383. function getFirstNode(parent) {
  384. return parent.firstChild
  385. }
  386. function getNextSibling(parent, reference) {
  387. let next
  388. if (parent instanceof Block) {
  389. next = parent.nextNode(reference)
  390. } else {
  391. next = reference.nextSibling
  392. }
  393. return next
  394. }
  395. function monkeyPatchDomSetAttributeToAllowAtSymbols() {
  396. if (patched) return
  397. patched = true
  398. // Because morphdom may add attributes to elements containing "@" symbols
  399. // like in the case of an Alpine `@click` directive, we have to patch
  400. // the standard Element.setAttribute method to allow this to work.
  401. let original = Element.prototype.setAttribute
  402. let hostDiv = document.createElement('div')
  403. Element.prototype.setAttribute = function newSetAttribute(name, value) {
  404. if (! name.includes('@')) {
  405. return original.call(this, name, value)
  406. }
  407. hostDiv.innerHTML = `<span ${name}="${value}"></span>`
  408. let attr = hostDiv.firstElementChild.getAttributeNode(name)
  409. hostDiv.firstElementChild.removeAttributeNode(attr)
  410. this.setAttributeNode(attr)
  411. }
  412. }
  413. function seedingMatchingId(to, from) {
  414. let fromId = from && from._x_bindings && from._x_bindings.id
  415. if (! fromId) return
  416. if (! to.setAttribute) return
  417. to.setAttribute('id', fromId)
  418. to.id = fromId
  419. }