1 | import { TEXT, ATTRIBUTE, INPUT, EVENT, REPEAT } from "./constants.js"
|
2 | import { updateFormControl } from "./formControl.js"
|
3 | import {
|
4 | debounce,
|
5 | fragmentFromTemplate,
|
6 | getValueAtPath,
|
7 | last,
|
8 | walk,
|
9 | } from "./helpers.js"
|
10 | import { compareKeyedLists, getBlocks, parseEach, updateList } from "./list.js"
|
11 | import { getParts, getValueFromParts, hasMustache } from "./token.js"
|
12 | import { applyAttribute } from "./attribute.js"
|
13 | import { createContext } from "./context.js"
|
14 |
|
15 | const HYDRATE_ATTR = "mosaic-hydrate"
|
16 |
|
17 | export const render = (
|
18 | target,
|
19 | { getState, dispatch },
|
20 | template,
|
21 | updatedCallback,
|
22 | beforeMountCallback
|
23 | ) => {
|
24 | let observer = () => {
|
25 | let subscribers = new Set()
|
26 | return {
|
27 | publish: (cb) => {
|
28 | for (let fn of subscribers) {
|
29 | fn()
|
30 | }
|
31 | cb?.()
|
32 | },
|
33 | subscribe(fn) {
|
34 | subscribers.add(fn)
|
35 | },
|
36 | }
|
37 | }
|
38 |
|
39 | const _ = (a, b) => (a === undefined ? b : a)
|
40 |
|
41 | const createSubscription = {
|
42 | [TEXT]: ({ value, node, context }, { getState }) => {
|
43 | return {
|
44 | handler: () => {
|
45 | let state = context ? context.wrap(getState()) : getState()
|
46 | let a = node.textContent
|
47 | let b = getValueFromParts(state, getParts(value))
|
48 | if (a !== b) node.textContent = b
|
49 | },
|
50 | }
|
51 | },
|
52 | [ATTRIBUTE]: ({ value, node, name, context }, { getState }) => {
|
53 | return {
|
54 | handler: () => {
|
55 | let state = context ? context.wrap(getState()) : getState()
|
56 | let b = getValueFromParts(state, getParts(value))
|
57 |
|
58 | applyAttribute(node, name, b)
|
59 |
|
60 | if (node.nodeName === "OPTION") {
|
61 | let path = node.parentNode.getAttribute("name")
|
62 | let selected = getValueAtPath(path, state)
|
63 | node.selected = selected === b
|
64 | }
|
65 | },
|
66 | }
|
67 | },
|
68 | [INPUT]: ({ node, path, context }, { getState, dispatch }) => {
|
69 | node.addEventListener("input", () => {
|
70 | let value =
|
71 | node.getAttribute("type") === "checkbox" ? node.checked : node.value
|
72 |
|
73 | if (value.trim?.().length && !isNaN(value)) value = +value
|
74 |
|
75 | if (context) {
|
76 | let state = context.wrap(getState())
|
77 | state[path] = value
|
78 | dispatch({
|
79 | type: "MERGE",
|
80 | payload: state,
|
81 | })
|
82 | } else {
|
83 | dispatch({
|
84 | type: "SET",
|
85 | payload: {
|
86 | name: path,
|
87 | value,
|
88 | context,
|
89 | },
|
90 | })
|
91 | }
|
92 | })
|
93 |
|
94 | return {
|
95 | handler: () => {
|
96 | let state = context ? context.wrap(getState()) : getState()
|
97 | updateFormControl(node, getValueAtPath(path, state))
|
98 | },
|
99 | }
|
100 | },
|
101 | [EVENT]: (
|
102 | { node, eventType, actionType, context },
|
103 | { dispatch, getState }
|
104 | ) => {
|
105 | |
106 |
|
107 |
|
108 |
|
109 |
|
110 |
|
111 | node.addEventListener(eventType, (event) => {
|
112 | let isGlobal = actionType.startsWith("$")
|
113 |
|
114 | let action = {
|
115 | type: actionType,
|
116 | event,
|
117 | }
|
118 |
|
119 | if (!isGlobal)
|
120 | action.context = context ? context.wrap(getState()) : getState()
|
121 |
|
122 | dispatch(action)
|
123 |
|
124 | if (isGlobal) {
|
125 | node.dispatchEvent(
|
126 | new CustomEvent(actionType, {
|
127 | detail: action,
|
128 | bubbles: true,
|
129 | })
|
130 | )
|
131 | }
|
132 | })
|
133 | return {
|
134 | handler: () => {},
|
135 | }
|
136 | },
|
137 | [REPEAT]: (
|
138 | {
|
139 | node,
|
140 | context,
|
141 | map,
|
142 | path,
|
143 | identifier,
|
144 | index,
|
145 | key,
|
146 | blockIndex,
|
147 | hydrate,
|
148 | pickupNode,
|
149 | },
|
150 | { getState }
|
151 | ) => {
|
152 | let oldValue
|
153 | node.$t = blockIndex - 1
|
154 |
|
155 | const initialiseBlock = (rootNode, i, k, exitNode) => {
|
156 | walk(
|
157 | rootNode,
|
158 | multi(
|
159 | (node) => {
|
160 | if (node === exitNode) return false
|
161 | },
|
162 | bindAll(
|
163 | map,
|
164 | hydrate,
|
165 | createContext(
|
166 | (context?.get() || []).concat({
|
167 | path,
|
168 | identifier,
|
169 | key,
|
170 | index,
|
171 | i,
|
172 | k,
|
173 | })
|
174 | )
|
175 | ),
|
176 | (child) => (child.$t = blockIndex)
|
177 | )
|
178 | )
|
179 | }
|
180 |
|
181 | function firstChild(v) {
|
182 | return (v.nodeType === v.DOCUMENT_FRAGMENT_NODE && v.firstChild) || v
|
183 | }
|
184 |
|
185 | const createListItem = (datum, i) => {
|
186 | let k = datum[key]
|
187 | let frag = fragmentFromTemplate(node)
|
188 | initialiseBlock(firstChild(frag), i, k)
|
189 | return frag
|
190 | }
|
191 |
|
192 | if (hydrate) {
|
193 | let x = getValueAtPath(path, getState())
|
194 | let blocks = getBlocks(node)
|
195 |
|
196 | blocks.forEach((block, i) => {
|
197 | let datum = x[i]
|
198 | let k = datum?.[key]
|
199 | initialiseBlock(block[0], i, k, last(block).nextSibling)
|
200 | })
|
201 |
|
202 | pickupNode = last(last(blocks)).nextSibling
|
203 | }
|
204 |
|
205 | return {
|
206 | handler: () => {
|
207 | let state = context ? context.wrap(getState()) : getState()
|
208 |
|
209 | const newValue = Object.entries(getValueAtPath(path, state) || [])
|
210 | const delta = compareKeyedLists(key, oldValue, newValue)
|
211 |
|
212 | if (delta) {
|
213 | updateList(node, delta, newValue, createListItem)
|
214 | }
|
215 | oldValue = newValue.slice(0)
|
216 | },
|
217 | pickupNode,
|
218 | }
|
219 | },
|
220 | }
|
221 |
|
222 | const mediator = () => {
|
223 | const o = observer()
|
224 | return {
|
225 | bind(v) {
|
226 | let s = createSubscription[v.type](v, { getState, dispatch })
|
227 | o.subscribe(s.handler)
|
228 | return s
|
229 | },
|
230 |
|
231 |
|
232 |
|
233 | update(cb) {
|
234 | return o.publish(cb)
|
235 | },
|
236 | }
|
237 | }
|
238 |
|
239 | const { bind, update } = mediator()
|
240 |
|
241 | let blockCount = 0
|
242 |
|
243 | const parse = (frag) => {
|
244 | let index = 0
|
245 | let map = {}
|
246 |
|
247 | walk(frag, (node) => {
|
248 | let x = []
|
249 | let pickupNode
|
250 | switch (node.nodeType) {
|
251 | case node.TEXT_NODE: {
|
252 | let value = node.textContent
|
253 | if (hasMustache(value)) {
|
254 | x.push({
|
255 | type: TEXT,
|
256 | value,
|
257 | })
|
258 | }
|
259 | break
|
260 | }
|
261 | case node.ELEMENT_NODE: {
|
262 | let each = parseEach(node)
|
263 |
|
264 | if (each) {
|
265 | let ns = node.namespaceURI
|
266 | let m
|
267 |
|
268 | if (ns.endsWith("/svg")) {
|
269 | node.removeAttribute("each")
|
270 | let tpl = document.createElementNS(ns, "defs")
|
271 | tpl.innerHTML = node.outerHTML
|
272 | node.parentNode.replaceChild(tpl, node)
|
273 | node = tpl
|
274 | m = parse(node.firstChild)
|
275 | } else {
|
276 | if (node.nodeName !== "TEMPLATE") {
|
277 | node.removeAttribute("each")
|
278 | let tpl = document.createElement("template")
|
279 |
|
280 | tpl.innerHTML = node.outerHTML
|
281 | node.parentNode.replaceChild(tpl, node)
|
282 | node = tpl
|
283 | }
|
284 | m = parse(node.content.firstChild)
|
285 | }
|
286 |
|
287 | pickupNode = node.nextSibling
|
288 |
|
289 | x.push({
|
290 | type: REPEAT,
|
291 | map: m,
|
292 | blockIndex: blockCount++,
|
293 | ...each,
|
294 | pickupNode,
|
295 | })
|
296 |
|
297 | break
|
298 | }
|
299 |
|
300 | let attrs = node.attributes
|
301 | let i = attrs.length
|
302 | while (i--) {
|
303 | let { name, value } = attrs[i]
|
304 |
|
305 | if (
|
306 | name === ":name" &&
|
307 | value &&
|
308 | (node.nodeName === "INPUT" ||
|
309 | node.nodeName === "SELECT" ||
|
310 | node.nodeName === "TEXTAREA")
|
311 | ) {
|
312 | x.push({
|
313 | type: INPUT,
|
314 | path: value,
|
315 | })
|
316 |
|
317 | node.removeAttribute(name)
|
318 | node.setAttribute("name", value)
|
319 | } else if (name.startsWith(":on")) {
|
320 | node.removeAttribute(name)
|
321 | let eventType = name.split(":on")[1]
|
322 | x.push({
|
323 | type: EVENT,
|
324 | eventType,
|
325 | actionType: value,
|
326 | })
|
327 | } else if (name.startsWith(":")) {
|
328 | let prop = name.slice(1)
|
329 |
|
330 | let v = value || prop
|
331 |
|
332 | if (!v.includes("{{")) v = `{{${v}}}`
|
333 |
|
334 | x.push({
|
335 | type: ATTRIBUTE,
|
336 | name: prop,
|
337 | value: v,
|
338 | })
|
339 | node.removeAttribute(name)
|
340 | }
|
341 | }
|
342 | }
|
343 | }
|
344 | if (x.length) map[index] = x
|
345 | index++
|
346 | return pickupNode
|
347 | })
|
348 |
|
349 | return map
|
350 | }
|
351 |
|
352 | const multi =
|
353 | (...fns) =>
|
354 | (...args) => {
|
355 | for (let fn of fns) {
|
356 | let v = fn(...args)
|
357 | if (v === false) return false
|
358 | }
|
359 | }
|
360 |
|
361 | const bindAll = (bMap, hydrate = 0, context) => {
|
362 | let index = 0
|
363 | return (node) => {
|
364 | let k = index
|
365 | let p
|
366 | if (k in bMap) {
|
367 | bMap[k].forEach((v) => {
|
368 | let x = bind({
|
369 | ...v,
|
370 | node,
|
371 | context,
|
372 | hydrate,
|
373 | })
|
374 | p = x.pickupNode
|
375 | })
|
376 | node.$i = index
|
377 | }
|
378 | index++
|
379 | return p
|
380 | }
|
381 | }
|
382 |
|
383 | let frag = fragmentFromTemplate(template)
|
384 | let map = parse(frag)
|
385 | let hydrate = target.hasAttribute?.(HYDRATE_ATTR)
|
386 | if (hydrate) {
|
387 | walk(target, bindAll(map, 1))
|
388 | } else {
|
389 | walk(frag, bindAll(map))
|
390 | beforeMountCallback?.(frag)
|
391 | target.prepend(frag)
|
392 | update()
|
393 | target.setAttribute?.(HYDRATE_ATTR, 1)
|
394 | }
|
395 |
|
396 | return debounce(() => update(updatedCallback))
|
397 | }
|