simplyflow 0.7.9 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,8 +2,9 @@
2
2
  * Default renderers for data binding
3
3
  * Will be used unless overriden in the SimplyBind options parameter
4
4
  */
5
- import { signal as domSignal } from './dom.mjs'
6
-
5
+ import { signal as domSignal, trackDomField, trackDomList } from './dom.mjs'
6
+ import { throttledEffect, effect, untracked, batch } from './state.mjs'
7
+ import { getValueByPath } from './bind.mjs'
7
8
  /**
8
9
  * This function is used by default to render dom elements with the `data-flow-field` attribute.
9
10
  * It will switch to only switching in template content if the context has any templates.
@@ -23,16 +24,6 @@ export function field(context)
23
24
  }
24
25
  } else if (this.options.renderers['*']) {
25
26
  this.options.renderers['*'].call(this, context)
26
- // FIXME: should call a setter (defined in field type) to set the value back into root data
27
- if (this.options.twoway) {
28
- // TODO: make content-editable if editmode is toggled on
29
- // how do you toggle editmode? global signal?
30
- // make uneditable if editmode is toggled off
31
- const s = domSignal(context.element)
32
- effect(() => {
33
- setValueByPath(this.options.root, context.path, s.innerHTML)
34
- })
35
- }
36
27
  }
37
28
  return context
38
29
  }
@@ -46,6 +37,8 @@ export function list(context)
46
37
  if (!Array.isArray(context.value)) {
47
38
  context.value = [context.value]
48
39
  }
40
+ // make sure this effect is triggered if the length of the array changes
41
+ const length = context.value.length
49
42
  if (!context.templates?.length) {
50
43
  console.error('No templates found in', context.element)
51
44
  } else {
@@ -70,37 +63,78 @@ export function map(context)
70
63
  return context
71
64
  }
72
65
 
66
+ function isInt(s) {
67
+ if (parseInt(s)==s) {
68
+ return true
69
+ }
70
+ }
71
+
72
+ /**
73
+ * This function sets a given value on the given path, starting at the given root.
74
+ * It will automatically create objects if a path part does not yet exist.
75
+ * @param root the root object
76
+ * @param path a JSON path
77
+ * @param value the value to set
78
+ */
73
79
  export function setValueByPath(root, path, value)
74
80
  {
75
- let parts = path.split('.')
76
- let curr = root
77
- let part
78
- part = parts.shift()
79
- let prev = null
80
- let prevPart = null
81
- while (part && curr) {
82
- part = decodeURIComponent(part)
83
- if (part=='0' && !Array.isArray(curr)) {
84
- // ignore so that data-flow-list="nonarray" will work
85
- } else if (part==':key') {
86
- // FIXME: should change the key, not the value... not supported yet?
87
- throw new Error('setting key not yet supported')
88
- curr = prevPart
89
- } else if (part==':value') {
90
- // do nothing
91
- } else if (Array.isArray(curr) && typeof curr[part]=='undefined') {
92
- prev = curr[0]
93
- curr = curr[0][part] // so that data-flow-field="array.foo" works
94
- } else {
95
- prev = curr
96
- curr = curr[part]
97
- }
98
- prevPart = part
81
+ batch(() => {
82
+ let parts = path.split('.')
83
+ let curr = root
84
+ let part
99
85
  part = parts.shift()
100
- }
101
- if (prev && prevPart && prev[prevPart]!==value) {
102
- prev[prevPart] = value
103
- }
86
+ let prev = null
87
+ let prevPart = null
88
+ let prevCurr = curr
89
+ while (part && curr) {
90
+ prevCurr = curr
91
+ part = decodeURIComponent(part)
92
+ if (part=='0' && !Array.isArray(curr)) {
93
+ // ignore so that data-flow-list="nonarray" will work
94
+ } else if (part==':key') {
95
+ // FIXME: should change the key, not the value... not supported yet?
96
+ throw new Error('setting key not yet supported')
97
+ curr = prevPart
98
+ } else if (part==':value') {
99
+ // do nothing
100
+ } else if (Array.isArray(curr) && !isInt(part) && typeof curr[part]=='undefined') {
101
+ prev = curr[0]
102
+ curr = curr[0][part] // so that data-flow-field="array.foo" works
103
+ } else {
104
+ prev = curr
105
+ curr = curr[part]
106
+ }
107
+ prevPart = part
108
+ part = parts.shift()
109
+ if (part && !curr) {
110
+ // path in html does not exist yet, so create it
111
+ const intKey = parseInt(part)
112
+ if (intKey>=0 && part===''+intKey) {
113
+ prevCurr[prevPart] = []
114
+ } else {
115
+ prevCurr[prevPart] = {}
116
+ }
117
+ curr = prevCurr[prevPart]
118
+ }
119
+ }
120
+ if (prev && prevPart && prev[prevPart]!==value) {
121
+ if (value && typeof value=='object') {
122
+ curr = prev[prevPart]
123
+ if (!curr) {
124
+ // last part of path in html does not exist yet, create it
125
+ prev[prevPart] = {}
126
+ curr = prev[prevPart]
127
+ }
128
+ for (const prop in value) {
129
+ if (curr[prop]!==value[prop]) {
130
+ curr[prop] = value[prop]
131
+ }
132
+ }
133
+ } else {
134
+ prev[prevPart] = value
135
+ }
136
+ }
137
+ })
104
138
  }
105
139
 
106
140
  /**
@@ -176,6 +210,9 @@ export function arrayByTemplates(context)
176
210
  length++
177
211
  }
178
212
  }
213
+ if (this.options.twoway) {
214
+ trackDomList.call(this, context.element)
215
+ }
179
216
  }
180
217
 
181
218
  /**
@@ -271,7 +308,7 @@ export function input(context)
271
308
  const el = context.element
272
309
  let value = context.value
273
310
 
274
- element(context)
311
+ element.call(this, context)
275
312
  if (typeof value == 'undefined') {
276
313
  value = ''
277
314
  }
@@ -291,8 +328,7 @@ export function input(context)
291
328
  */
292
329
  export function button(context)
293
330
  {
294
- element(context)
295
- setProperties(context.element, context.value, 'value')
331
+ element.call(this, context, 'value')
296
332
  }
297
333
 
298
334
  /**
@@ -377,8 +413,12 @@ export function setSelectOptions(select,options)
377
413
  */
378
414
  export function anchor(context)
379
415
  {
380
- element(context)
381
- setProperties(context.element, context.value, 'target', 'href', 'name', 'newwindow', 'nofollow')
416
+ element.call(this, context, 'target', 'href', 'name', 'newwindow', 'nofollow')
417
+ if (this.options.twoway) {
418
+ batch(() => {
419
+ updateProperties.call(this, context, ['target', 'href', 'name', 'newwindow', 'nofollow'])
420
+ })
421
+ }
382
422
  }
383
423
 
384
424
  /**
@@ -387,6 +427,11 @@ export function anchor(context)
387
427
  export function image(context)
388
428
  {
389
429
  setProperties(context.element, context.value, 'title', 'alt', 'src', 'id')
430
+ if (this.options.twoway) {
431
+ batch(() => {
432
+ updateProperties.call(this, context, ['title', 'alt', 'src', 'id'])
433
+ })
434
+ }
390
435
  }
391
436
 
392
437
  /**
@@ -395,6 +440,11 @@ export function image(context)
395
440
  export function iframe(context)
396
441
  {
397
442
  setProperties(context.element, context.value, 'title', 'src', 'id')
443
+ if (this.options.twoway) {
444
+ batch(() => {
445
+ updateProperties.call(this, context, ['title','src','id'])
446
+ })
447
+ }
398
448
  }
399
449
 
400
450
  /**
@@ -402,25 +452,34 @@ export function iframe(context)
402
452
  */
403
453
  export function meta(context)
404
454
  {
405
- setProperties(context.element, context.value, 'content', 'id')
455
+ setProperties(context.element, context.value, 'content', 'id')
456
+ if (this.options.twoway) {
457
+ batch(() => {
458
+ updateProperties.call(this, context, ['content','id'])
459
+ })
460
+ }
406
461
  }
407
462
 
408
463
  /**
409
464
  * sets the innerHTML and title and id properties of any HTML element
410
465
  */
411
- export function element(context)
466
+ export function element(context, ...extraprops)
412
467
  {
413
468
  const el = context.element
414
469
  let value = context.value
415
-
416
- if (typeof value=='undefined' || value==null) {
417
- value = ''
470
+ let valueIsString = false
471
+ if (typeof value!='undefined' && value!==null) {
472
+ let strValue = ''+value
473
+ if (typeof value!='object' || strValue.substring(0,8)!='[object ') {
474
+ value = { innerHTML: value }
475
+ valueIsString = true
476
+ }
418
477
  }
419
- let strValue = ''+value
420
- if (typeof value!='object' || strValue.substring(0,8)!='[object ') {
421
- value = { innerHTML: value }
478
+ const props = ['innerHTML','title','id','className'].concat(extraprops)
479
+ setProperties(el, value, ...props)
480
+ if (this.options.twoway) {
481
+ trackDomField.call(this, context.element, props, valueIsString)
422
482
  }
423
- setProperties(el, value, 'innerHTML', 'title', 'id', 'className')
424
483
  }
425
484
 
426
485
  /**
@@ -447,6 +506,18 @@ export function setProperties(el, data, ...properties) {
447
506
  }
448
507
  }
449
508
 
509
+ export function getProperties(el, ...properties) {
510
+ const result = {}
511
+ for (const property of properties) {
512
+ switch(property) {
513
+ default:
514
+ result[property] = el[property]
515
+ break
516
+ }
517
+ }
518
+ return result
519
+ }
520
+
450
521
  /**
451
522
  * Returns true if a matches b, either by having the
452
523
  * same string value, or matching string :empty against a falsy value
package/src/dom.mjs CHANGED
@@ -1,4 +1,9 @@
1
- import { signals, signal as stateSignal, notifyGet, notifySet, makeContext } from './state.mjs'
1
+ import { signals, signal as stateSignal, notifyGet, notifySet, makeContext,
2
+ throttledEffect, effect, untracked, batch } from './state.mjs'
3
+ import { getValueByPath } from './bind.mjs'
4
+ import { setValueByPath, getProperties } from './bind.render.mjs'
5
+
6
+ const domSignals = new WeakMap()
2
7
 
3
8
  const domSignalHandler = {
4
9
  get: (target, property, receiver) => {
@@ -34,20 +39,29 @@ const domSignalHandler = {
34
39
  }
35
40
  }
36
41
 
37
- export function signal(el) {
42
+ export function signal(el, options) {
38
43
  if (el[Symbol.xRay]) {
39
44
  return el
40
45
  }
41
46
  if (!signals.has(el)) {
42
47
  signals.set(el, new Proxy(el, domSignalHandler))
43
- domListen(el, signals.get(el))
48
+ domListen(el, signals.get(el), options)
44
49
  }
45
50
  return signals.get(el)
46
51
  }
47
52
 
48
53
  const observers = new WeakMap()
49
54
 
50
- function domListen(el, signal) {
55
+ function domListen(el, signal, options) {
56
+ const defaultOptions = {
57
+ characterData: true,
58
+ subtree: true,
59
+ attributes: true,
60
+ attributesOldValue: true
61
+ }
62
+ if (!options) {
63
+ options = defaultOptions
64
+ }
51
65
  let oldContentHTML = el.innerHTML
52
66
  let oldContentText = el.innerText
53
67
  if (!observers.has(el)) {
@@ -68,18 +82,20 @@ function domListen(el, signal) {
68
82
  changes.innerText = oldContentText
69
83
  oldContentText = el.innerText
70
84
  }
85
+ } else if (mutation.type==='childList') {
86
+ changes.children = { //FIXME: overwrites changes in this list path if list is rendered multiple times
87
+ was: Array.from(el.children) //FIXME; fill in 'now'
88
+ }
89
+ changes.length = -1 //FIXME: don't do this :)
90
+ } else {
91
+ console.log('nothing to do for',el,mutation.type)
71
92
  }
72
93
  }
73
94
  for (const prop in changes) {
74
95
  notifySet(signal, makeContext(prop, { was: changes[prop], now: el[prop] }))
75
96
  }
76
97
  })
77
- observer.observe(el, {
78
- characterData: true,
79
- subtree: true,
80
- attributes: true,
81
- attributesOldValue: true
82
- })
98
+ observer.observe(el, options)
83
99
  observers.set(el, observer)
84
100
  //@TODO: unregister the observer when el is removed from the dom (after a timeout)
85
101
  if (el.matches('input, textarea, select')) {
@@ -97,3 +113,65 @@ function domListen(el, signal) {
97
113
  }
98
114
  }
99
115
  }
116
+
117
+ export function trackDomList(element)
118
+ {
119
+ const path = this.getBindingPath(element)
120
+ if (!path) {
121
+ throw new Error('Could not find binding path for element', { cause: element })
122
+ }
123
+ const s = signal(element, {
124
+ childList: true
125
+ })
126
+ throttledEffect(() => {
127
+ const children = Array.from(s.children)
128
+ untracked(() => { // don't track access to the data, only track dom changes
129
+ batch(() => { // apply all changes in the list as one change
130
+ let key=0
131
+ const currentList = getValueByPath(this.options.root, path)
132
+ const source = currentList.slice() // make sure changes in currentList don't affect the original source
133
+ for (const item of children) {
134
+ if (item.tagName==='TEMPLATE') {
135
+ continue
136
+ }
137
+ if (item.dataset.flowKey) { //FIXME: could be other attribute name
138
+ if (item.dataset.flowKey!=key) {
139
+ setValueByPath(this.options.root, path+'.'+key,
140
+ source[item.dataset.flowKey])
141
+ }
142
+ key++
143
+ }
144
+ }
145
+ if (currentList.length>key) {
146
+ // remove extra values
147
+ currentList.length = key
148
+ }
149
+ })
150
+ })
151
+ })
152
+ }
153
+
154
+ export function trackDomField(element, props, valueIsString) {
155
+ if (domSignals.has(element)) {
156
+ return
157
+ }
158
+ const path = this.getBindingPath(element)
159
+ if (!path) {
160
+ throw new Error('Could not find binding path for element', { cause: element })
161
+ }
162
+ const s = signal(element)
163
+ domSignals.set(element, s)
164
+ //TODO: run reverse transformers (extract)
165
+ batch(() => { // avoids cyclical dependencies - check why
166
+ throttledEffect(() => {
167
+ let updateValue = s.innerHTML //FIXME: incorrect: in an anchor this could be s.href - use extract here
168
+ if (!valueIsString) {
169
+ updateValue = getProperties(s, ...props)
170
+ }
171
+ untracked(() => { // don't track changes in data, only in the dom
172
+ // don't trigger this effect when the data changes (root.path)
173
+ setValueByPath(this.options.root, path, updateValue)
174
+ })
175
+ })
176
+ })
177
+ }
package/src/edit.mjs ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * This function returns the cursor position and height, if the cursor is in
3
+ * the given element. The x and y position are calculated relative to the top
4
+ * left of the given element. This function does not alter the DOM in any way.
5
+ */
6
+ function getCursorPosition(element) {
7
+ const selection = window.getSelection();
8
+ if (!selection.rangeCount) return null;
9
+
10
+ const range = document.createRange();
11
+ range.setStart(selection.focusNode, selection.focusOffset);
12
+ range.collapse(true);
13
+
14
+ // Try getClientRects() first — often non-empty even on empty lines
15
+ const elementRect = element.getBoundingClientRect();
16
+
17
+ const cursorNode = selection.focusNode;
18
+ const cursorElement = cursorNode.nodeType === Node.TEXT_NODE
19
+ ? cursorNode.parentElement
20
+ : cursorNode;
21
+
22
+ let x,y,height;
23
+ const rects = range.getClientRects();
24
+ if (rects.length > 0) {
25
+ x = rects[0].left - elementRect.left
26
+ y = rects[0].top - elementRect.top
27
+ height = rects[0].height
28
+ } else {
29
+ // Fallback for truly empty element: use padding from CSS
30
+ const style = window.getComputedStyle(cursorElement);
31
+ const lineHeight = parseFloat(style.lineHeight);
32
+ height = isNaN(lineHeight) ? parseFloat(style.fontSize) : lineHeight
33
+ const cursorElementRect = cursorElement.getBoundingClientRect();
34
+ x = cursorElementRect.left - elementRect.left + parseFloat(style.paddingLeft)
35
+ y = cursorElementRect.top - elementRect.top + parseFloat(style.paddingTop)
36
+ }
37
+ return {
38
+ x,
39
+ y,
40
+ height,
41
+ element: cursorElement
42
+ }
43
+ }
44
+
45
+ export function edit(element)
46
+ {
47
+ return simply.app({
48
+ container: element,
49
+ actions: {
50
+ showToolbar: function(position) {
51
+ const containerRect = this.container.getBoundingClientRect()
52
+ this.toolbar.style.top = containerRect.top + position.y + position.height + 'px'
53
+ this.toolbar.style.left = containerRect.left + position.x + 'px'
54
+ this.toolbar.style.display = 'block'
55
+ },
56
+ hideToolbar: function() {
57
+ this.toolbar.style.display = 'none'
58
+ },
59
+ close: function() {
60
+ this.container.removeAttribute('contenteditable')
61
+ document.removeEventListener(this.selectionListener)
62
+ }
63
+ },
64
+ keyboard: {
65
+ default: {
66
+ 'Control+ ': function() {
67
+ if (this.toolbar.style.display == 'none') {
68
+ const position = getCursorPosition(this.container)
69
+ this.actions.showToolbar(position)
70
+ } else {
71
+ this.actions.hideToolbar()
72
+ }
73
+ }
74
+ }
75
+ },
76
+ hooks: {
77
+ start: function() {
78
+ this.container.setAttribute('contenteditable', true)
79
+ this.toolbar = document.querySelector('simply-edit-focus-toolbar')
80
+ if (!this.toolbar) {
81
+ this.toolbar = document.createElement('div')
82
+ this.toolbar.id = 'simply-edit-focus-toolbar'
83
+ this.toolbar.style.position ='absolute'
84
+ this.toolbar.style['z-index'] = 10000
85
+ this.toolbar.style.border = '1px solid blue'
86
+ this.toolbar.innerHTML = 'toolbar'
87
+ document.body.appendChild(this.toolbar)
88
+ }
89
+ this.selectionListener = document.addEventListener('selectionchange', () => {
90
+ console.log('selectionchange')
91
+ const selection = window.getSelection()
92
+ if (!selection.rangeCount || selection.isCollapsed) {
93
+ this.actions.hideToolbar()
94
+ console.log('no selection')
95
+ return
96
+ }
97
+ if (!this.container.contains(selection.anchorNode)) {
98
+ console.log('selection outside container')
99
+ return
100
+ }
101
+ const position = getCursorPosition(this.container)
102
+ console.log('position',position)
103
+ this.actions.showToolbar(position)
104
+ })
105
+ }
106
+ }
107
+ })
108
+ }
package/src/state.mjs CHANGED
@@ -1,4 +1,6 @@
1
- const iterate = Symbol('iterate')
1
+ if (!Symbol.iterate) {
2
+ Symbol.iterate = Symbol('iterate')
3
+ }
2
4
  if (!Symbol.xRay) {
3
5
  Symbol.xRay = Symbol('xRay')
4
6
  }
@@ -72,7 +74,8 @@ const signalHandler = {
72
74
  notifySet(receiver, makeContext(property, { was: current, now: value } ) )
73
75
  }
74
76
  if (typeof current === 'undefined') {
75
- notifySet(receiver, makeContext(iterate, {}))
77
+ notifySet(receiver, makeContext(Symbol.iterate, {}))
78
+ notifySet(receiver, makeContext('length', {}))
76
79
  }
77
80
  return true
78
81
  },
@@ -95,13 +98,13 @@ const signalHandler = {
95
98
  defineProperty: (target, property, descriptor) => {
96
99
  if (typeof target[property] === 'undefined') {
97
100
  let receiver = signals.get(target) // receiver is not part of the trap arguments, so retrieve it here
98
- notifySet(receiver, makeContext(iterate, {}))
101
+ notifySet(receiver, makeContext(Symbol.iterate, {}))
99
102
  }
100
103
  return Object.defineProperty(target, property, descriptor)
101
104
  },
102
105
  ownKeys: (target) => {
103
106
  let receiver = signals.get(target) // receiver is not part of the trap arguments, so retrieve it here
104
- notifyGet(receiver, iterate)
107
+ notifyGet(receiver, Symbol.iterate)
105
108
  return Reflect.ownKeys(target)
106
109
  }
107
110
 
@@ -120,6 +123,9 @@ export const signals = new WeakMap()
120
123
  * to allow reactive functions to be triggered when signal values change.
121
124
  */
122
125
  export function signal(v) {
126
+ if (!v) {
127
+ v = {}
128
+ }
123
129
  if (v[Symbol.Signal]) { // there can be only one signal for any value
124
130
  return v
125
131
  }