morph.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. import { dom, createElement, textOrComment} from './dom.js'
  2. let resolveStep = () => {}
  3. let logger = () => {}
  4. export function morph(from, toHtml, options) {
  5. monkeyPatchDomSetAttributeToAllowAtSymbols()
  6. // We're defining these globals and methods inside this function (instead of outside)
  7. // because it's an async function and if run twice, they would overwrite
  8. // each other.
  9. let fromEl
  10. let toEl
  11. let key
  12. ,lookahead
  13. ,updating
  14. ,updated
  15. ,removing
  16. ,removed
  17. ,adding
  18. ,added
  19. function assignOptions(options = {}) {
  20. let defaultGetKey = el => el.getAttribute('key')
  21. let noop = () => {}
  22. updating = options.updating || noop
  23. updated = options.updated || noop
  24. removing = options.removing || noop
  25. removed = options.removed || noop
  26. adding = options.adding || noop
  27. added = options.added || noop
  28. key = options.key || defaultGetKey
  29. lookahead = options.lookahead || false
  30. }
  31. function patch(from, to) {
  32. // This is a time saver, however, it won't catch differences in nested <template> tags.
  33. // I'm leaving this here as I believe it's an important speed improvement, I just
  34. // don't see a way to enable it currently:
  35. //
  36. // if (from.isEqualNode(to)) return
  37. if (differentElementNamesTypesOrKeys(from, to)) {
  38. // Swap elements...
  39. return patchElement(from, to)
  40. }
  41. let updateChildrenOnly = false
  42. if (shouldSkip(updating, from, to, () => updateChildrenOnly = true)) return
  43. window.Alpine && initializeAlpineOnTo(from, to, () => updateChildrenOnly = true)
  44. if (textOrComment(to)) {
  45. patchNodeValue(from, to)
  46. updated(from, to)
  47. return
  48. }
  49. if (! updateChildrenOnly) {
  50. patchAttributes(from, to)
  51. }
  52. updated(from, to)
  53. patchChildren(Array.from(from.childNodes), Array.from(to.childNodes), (toAppend) => {
  54. from.appendChild(toAppend)
  55. })
  56. }
  57. function differentElementNamesTypesOrKeys(from, to) {
  58. return from.nodeType != to.nodeType
  59. || from.nodeName != to.nodeName
  60. || getKey(from) != getKey(to)
  61. }
  62. function patchElement(from, to) {
  63. if (shouldSkip(removing, from)) return
  64. let toCloned = to.cloneNode(true)
  65. if (shouldSkip(adding, toCloned)) return
  66. dom.replace([from], from, toCloned)
  67. removed(from)
  68. added(toCloned)
  69. }
  70. function patchNodeValue(from, to) {
  71. let value = to.nodeValue
  72. if (from.nodeValue !== value) {
  73. // Change text node...
  74. from.nodeValue = value
  75. }
  76. }
  77. function patchAttributes(from, to) {
  78. if (from._x_transitioning) return
  79. if (from._x_isShown && ! to._x_isShown) {
  80. return
  81. }
  82. if (! from._x_isShown && to._x_isShown) {
  83. return
  84. }
  85. let domAttributes = Array.from(from.attributes)
  86. let toAttributes = Array.from(to.attributes)
  87. for (let i = domAttributes.length - 1; i >= 0; i--) {
  88. let name = domAttributes[i].name;
  89. if (! to.hasAttribute(name)) {
  90. // Remove attribute...
  91. from.removeAttribute(name)
  92. }
  93. }
  94. for (let i = toAttributes.length - 1; i >= 0; i--) {
  95. let name = toAttributes[i].name
  96. let value = toAttributes[i].value
  97. if (from.getAttribute(name) !== value) {
  98. from.setAttribute(name, value)
  99. }
  100. }
  101. }
  102. function patchChildren(fromChildren, toChildren, appendFn) {
  103. // I think I can get rid of this for now:
  104. let fromKeyDomNodeMap = keyToMap(fromChildren)
  105. let fromKeyHoldovers = {}
  106. let currentTo = dom.first(toChildren)
  107. let currentFrom = dom.first(fromChildren)
  108. while (currentTo) {
  109. let toKey = getKey(currentTo)
  110. let fromKey = getKey(currentFrom)
  111. // Add new elements
  112. if (! currentFrom) {
  113. if (toKey && fromKeyHoldovers[toKey]) {
  114. // Add element (from key)...
  115. let holdover = fromKeyHoldovers[toKey]
  116. fromChildren = dom.append(fromChildren, holdover, appendFn)
  117. currentFrom = holdover
  118. } else {
  119. if(! shouldSkip(adding, currentTo)) {
  120. // Add element...
  121. let clone = currentTo.cloneNode(true)
  122. fromChildren = dom.append(fromChildren, clone, appendFn)
  123. added(clone)
  124. }
  125. currentTo = dom.next(toChildren, currentTo)
  126. continue
  127. }
  128. }
  129. // Handle conditional markers (presumably added by backends like Livewire)...
  130. let isIf = node => node.nodeType === 8 && node.textContent === ' __BLOCK__ '
  131. let isEnd = node => node.nodeType === 8 && node.textContent === ' __ENDBLOCK__ '
  132. if (isIf(currentTo) && isIf(currentFrom)) {
  133. let newFromChildren = []
  134. let appendPoint
  135. let nestedIfCount = 0
  136. while (currentFrom) {
  137. let next = dom.next(fromChildren, currentFrom)
  138. if (isIf(next)) {
  139. nestedIfCount++
  140. } else if (isEnd(next) && nestedIfCount > 0) {
  141. nestedIfCount--
  142. } else if (isEnd(next) && nestedIfCount === 0) {
  143. currentFrom = dom.next(fromChildren, next)
  144. appendPoint = next
  145. break;
  146. }
  147. newFromChildren.push(next)
  148. currentFrom = next
  149. }
  150. let newToChildren = []
  151. nestedIfCount = 0
  152. while (currentTo) {
  153. let next = dom.next(toChildren, currentTo)
  154. if (isIf(next)) {
  155. nestedIfCount++
  156. } else if (isEnd(next) && nestedIfCount > 0) {
  157. nestedIfCount--
  158. } else if (isEnd(next) && nestedIfCount === 0) {
  159. currentTo = dom.next(toChildren, next)
  160. break;
  161. }
  162. newToChildren.push(next)
  163. currentTo = next
  164. }
  165. patchChildren(newFromChildren, newToChildren, node => appendPoint.before(node))
  166. continue
  167. }
  168. // Lookaheads should only apply to non-text-or-comment elements...
  169. if (currentFrom.nodeType === 1 && lookahead) {
  170. let nextToElementSibling = dom.next(toChildren, currentTo)
  171. let found = false
  172. while (! found && nextToElementSibling) {
  173. if (currentFrom.isEqualNode(nextToElementSibling)) {
  174. found = true; // This ";" needs to be here...
  175. [fromChildren, currentFrom] = addNodeBefore(fromChildren, currentTo, currentFrom)
  176. fromKey = getKey(currentFrom)
  177. }
  178. nextToElementSibling = dom.next(toChildren, nextToElementSibling)
  179. }
  180. }
  181. if (toKey !== fromKey) {
  182. if (! toKey && fromKey) {
  183. // No "to" key...
  184. fromKeyHoldovers[fromKey] = currentFrom; // This ";" needs to be here...
  185. [fromChildren, currentFrom] = addNodeBefore(fromChildren, currentTo, currentFrom)
  186. fromChildren = dom.remove(fromChildren, fromKeyHoldovers[fromKey])
  187. currentFrom = dom.next(fromChildren, currentFrom)
  188. currentTo = dom.next(toChildren, currentTo)
  189. continue
  190. }
  191. if (toKey && ! fromKey) {
  192. if (fromKeyDomNodeMap[toKey]) {
  193. // No "from" key...
  194. fromChildren = dom.replace(fromChildren, currentFrom, fromKeyDomNodeMap[toKey])
  195. currentFrom = fromKeyDomNodeMap[toKey]
  196. }
  197. }
  198. if (toKey && fromKey) {
  199. let fromKeyNode = fromKeyDomNodeMap[toKey]
  200. if (fromKeyNode) {
  201. // Move "from" key...
  202. fromKeyHoldovers[fromKey] = currentFrom
  203. fromChildren = dom.replace(fromChildren, currentFrom, fromKeyNode)
  204. currentFrom = fromKeyNode
  205. } else {
  206. // Swap elements with keys...
  207. fromKeyHoldovers[fromKey] = currentFrom; // This ";" needs to be here...
  208. [fromChildren, currentFrom] = addNodeBefore(fromChildren, currentTo, currentFrom)
  209. fromChildren = dom.remove(fromChildren, fromKeyHoldovers[fromKey])
  210. currentFrom = dom.next(fromChildren, currentFrom)
  211. currentTo = dom.next(toChildren, currentTo)
  212. continue
  213. }
  214. }
  215. }
  216. // Get next from sibling before patching in case the node is replaced
  217. let currentFromNext = currentFrom && dom.next(fromChildren, currentFrom)
  218. // Patch elements
  219. patch(currentFrom, currentTo)
  220. currentTo = currentTo && dom.next(toChildren, currentTo)
  221. currentFrom = currentFromNext
  222. }
  223. // Cleanup extra froms.
  224. let removals = []
  225. // We need to collect the "removals" first before actually
  226. // removing them so we don't mess with the order of things.
  227. while (currentFrom) {
  228. if(! shouldSkip(removing, currentFrom)) removals.push(currentFrom)
  229. currentFrom = dom.next(fromChildren, currentFrom)
  230. }
  231. // Now we can do the actual removals.
  232. while (removals.length) {
  233. let domForRemoval = removals.shift()
  234. domForRemoval.remove()
  235. removed(domForRemoval)
  236. }
  237. }
  238. function getKey(el) {
  239. return el && el.nodeType === 1 && key(el)
  240. }
  241. function keyToMap(els) {
  242. let map = {}
  243. els.forEach(el => {
  244. let theKey = getKey(el)
  245. if (theKey) {
  246. map[theKey] = el
  247. }
  248. })
  249. return map
  250. }
  251. function addNodeBefore(children, node, beforeMe) {
  252. if(! shouldSkip(adding, node)) {
  253. let clone = node.cloneNode(true)
  254. children = dom.before(children, beforeMe, clone)
  255. added(clone)
  256. return [children, clone]
  257. }
  258. return [children, node]
  259. }
  260. // Finally we morph the element
  261. assignOptions(options)
  262. fromEl = from
  263. toEl = typeof toHtml === 'string' ? createElement(toHtml) : toHtml
  264. // If there is no x-data on the element we're morphing,
  265. // let's seed it with the outer Alpine scope on the page.
  266. if (window.Alpine && window.Alpine.closestDataStack && ! from._x_dataStack) {
  267. toEl._x_dataStack = window.Alpine.closestDataStack(from)
  268. toEl._x_dataStack && window.Alpine.clone(from, toEl)
  269. }
  270. patch(from, toEl)
  271. // Release these for the garbage collector.
  272. fromEl = undefined
  273. toEl = undefined
  274. return from
  275. }
  276. morph.step = () => resolveStep()
  277. morph.log = (theLogger) => {
  278. logger = theLogger
  279. }
  280. function shouldSkip(hook, ...args) {
  281. let skip = false
  282. hook(...args, () => skip = true)
  283. return skip
  284. }
  285. function initializeAlpineOnTo(from, to, childrenOnly) {
  286. if (from.nodeType !== 1) return
  287. // If the element we are updating is an Alpine component...
  288. if (from._x_dataStack) {
  289. // Then temporarily clone it (with it's data) to the "to" element.
  290. // This should simulate backend Livewire being aware of Alpine changes.
  291. window.Alpine.clone(from, to)
  292. }
  293. }
  294. let patched = false
  295. function monkeyPatchDomSetAttributeToAllowAtSymbols() {
  296. if (patched) return
  297. patched = true
  298. // Because morphdom may add attributes to elements containing "@" symbols
  299. // like in the case of an Alpine `@click` directive, we have to patch
  300. // the standard Element.setAttribute method to allow this to work.
  301. let original = Element.prototype.setAttribute
  302. let hostDiv = document.createElement('div')
  303. Element.prototype.setAttribute = function newSetAttribute(name, value) {
  304. if (! name.includes('@')) {
  305. return original.call(this, name, value)
  306. }
  307. hostDiv.innerHTML = `<span ${name}="${value}"></span>`
  308. let attr = hostDiv.firstElementChild.getAttributeNode(name)
  309. hostDiv.firstElementChild.removeAttributeNode(attr)
  310. this.setAttributeNode(attr)
  311. }
  312. }