simplyflow 0.7.9 → 0.8.1

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.
@@ -3,7 +3,8 @@
3
3
  * Will be used unless overriden in the SimplyBind options parameter
4
4
  */
5
5
  import { signal as domSignal } from './dom.mjs'
6
-
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,37 @@ export function arrayByTemplates(context)
176
210
  length++
177
211
  }
178
212
  }
213
+ if (this.options.twoway) {
214
+ const s = domSignal(context.element, {
215
+ childList: true
216
+ })
217
+ throttledEffect(() => {
218
+ const children = Array.from(s.children)
219
+ batch(() => {
220
+ untracked(() => {
221
+ let key=0
222
+ const currentList = context.value.slice()
223
+ for (const item of children) {
224
+ if (item.tagName==='TEMPLATE') {
225
+ continue
226
+ }
227
+ if (item.dataset.flowKey) {
228
+ if (item.dataset.flowKey!=key) {
229
+ setValueByPath(this.options.root, context.path+'.'+key,
230
+ currentList[item.dataset.flowKey])
231
+ }
232
+ key++
233
+ }
234
+ }
235
+ if (context.value.length>key) {
236
+ // remove extra values
237
+ const source = getValueByPath(this.options.root, context.path)
238
+ source.length = key
239
+ }
240
+ })
241
+ })
242
+ })
243
+ }
179
244
  }
180
245
 
181
246
  /**
@@ -271,7 +336,7 @@ export function input(context)
271
336
  const el = context.element
272
337
  let value = context.value
273
338
 
274
- element(context)
339
+ element.call(this, context)
275
340
  if (typeof value == 'undefined') {
276
341
  value = ''
277
342
  }
@@ -291,8 +356,7 @@ export function input(context)
291
356
  */
292
357
  export function button(context)
293
358
  {
294
- element(context)
295
- setProperties(context.element, context.value, 'value')
359
+ element.call(this, context, 'value')
296
360
  }
297
361
 
298
362
  /**
@@ -377,8 +441,12 @@ export function setSelectOptions(select,options)
377
441
  */
378
442
  export function anchor(context)
379
443
  {
380
- element(context)
381
- setProperties(context.element, context.value, 'target', 'href', 'name', 'newwindow', 'nofollow')
444
+ element.call(this, context, 'target', 'href', 'name', 'newwindow', 'nofollow')
445
+ if (this.options.twoway) {
446
+ batch(() => {
447
+ updateProperties.call(this, context, ['target', 'href', 'name', 'newwindow', 'nofollow'])
448
+ })
449
+ }
382
450
  }
383
451
 
384
452
  /**
@@ -387,6 +455,11 @@ export function anchor(context)
387
455
  export function image(context)
388
456
  {
389
457
  setProperties(context.element, context.value, 'title', 'alt', 'src', 'id')
458
+ if (this.options.twoway) {
459
+ batch(() => {
460
+ updateProperties.call(this, context, ['title', 'alt', 'src', 'id'])
461
+ })
462
+ }
390
463
  }
391
464
 
392
465
  /**
@@ -395,6 +468,11 @@ export function image(context)
395
468
  export function iframe(context)
396
469
  {
397
470
  setProperties(context.element, context.value, 'title', 'src', 'id')
471
+ if (this.options.twoway) {
472
+ batch(() => {
473
+ updateProperties.call(this, context, ['title','src','id'])
474
+ })
475
+ }
398
476
  }
399
477
 
400
478
  /**
@@ -402,25 +480,57 @@ export function iframe(context)
402
480
  */
403
481
  export function meta(context)
404
482
  {
405
- setProperties(context.element, context.value, 'content', 'id')
483
+ setProperties(context.element, context.value, 'content', 'id')
484
+ if (this.options.twoway) {
485
+ batch(() => {
486
+ updateProperties.call(this, context, ['content','id'])
487
+ })
488
+ }
406
489
  }
407
490
 
491
+ const domSignals = new WeakMap()
492
+
408
493
  /**
409
494
  * sets the innerHTML and title and id properties of any HTML element
410
495
  */
411
- export function element(context)
496
+ export function element(context, ...extraprops)
412
497
  {
413
498
  const el = context.element
414
499
  let value = context.value
415
-
416
- if (typeof value=='undefined' || value==null) {
417
- value = ''
500
+ let valueIsString = false
501
+ if (typeof value!='undefined' && value!==null) {
502
+ let strValue = ''+value
503
+ if (typeof value!='object' || strValue.substring(0,8)!='[object ') {
504
+ value = { innerHTML: value }
505
+ valueIsString = true
506
+ }
507
+ }
508
+ const props = ['innerHTML','title','id','className'].concat(extraprops)
509
+ setProperties(el, value, ...props)
510
+ if (this.options.twoway) {
511
+ batch(() => {
512
+ updateProperties.call(this, context, props, valueIsString)
513
+ })
418
514
  }
419
- let strValue = ''+value
420
- if (typeof value!='object' || strValue.substring(0,8)!='[object ') {
421
- value = { innerHTML: value }
515
+ }
516
+
517
+ export function updateProperties(context, props, valueIsString) {
518
+ if (domSignals.has(context.element)) {
519
+ return
422
520
  }
423
- setProperties(el, value, 'innerHTML', 'title', 'id', 'className')
521
+ const s = domSignal(context.element)
522
+ domSignals.set(context.element, s)
523
+ //TODO: run reverse transformers (extract)
524
+ throttledEffect(() => {
525
+ let updateValue = s.innerHTML //incorrect: in an anchor this could be s.href
526
+ if (!valueIsString) {
527
+ updateValue = getProperties(s, ...props)
528
+ }
529
+ untracked(() => {
530
+ // don't trigger this effect when the data changes (root.path)
531
+ setValueByPath(this.options.root, context.path, updateValue)
532
+ })
533
+ })
424
534
  }
425
535
 
426
536
  /**
@@ -447,6 +557,18 @@ export function setProperties(el, data, ...properties) {
447
557
  }
448
558
  }
449
559
 
560
+ export function getProperties(el, ...properties) {
561
+ const result = {}
562
+ for (const property of properties) {
563
+ switch(property) {
564
+ default:
565
+ result[property] = el[property]
566
+ break
567
+ }
568
+ }
569
+ return result
570
+ }
571
+
450
572
  /**
451
573
  * Returns true if a matches b, either by having the
452
574
  * same string value, or matching string :empty against a falsy value
package/src/dom.mjs CHANGED
@@ -34,20 +34,29 @@ const domSignalHandler = {
34
34
  }
35
35
  }
36
36
 
37
- export function signal(el) {
37
+ export function signal(el, options) {
38
38
  if (el[Symbol.xRay]) {
39
39
  return el
40
40
  }
41
41
  if (!signals.has(el)) {
42
42
  signals.set(el, new Proxy(el, domSignalHandler))
43
- domListen(el, signals.get(el))
43
+ domListen(el, signals.get(el), options)
44
44
  }
45
45
  return signals.get(el)
46
46
  }
47
47
 
48
48
  const observers = new WeakMap()
49
49
 
50
- function domListen(el, signal) {
50
+ function domListen(el, signal, options) {
51
+ const defaultOptions = {
52
+ characterData: true,
53
+ subtree: true,
54
+ attributes: true,
55
+ attributesOldValue: true
56
+ }
57
+ if (!options) {
58
+ options = defaultOptions
59
+ }
51
60
  let oldContentHTML = el.innerHTML
52
61
  let oldContentText = el.innerText
53
62
  if (!observers.has(el)) {
@@ -68,18 +77,20 @@ function domListen(el, signal) {
68
77
  changes.innerText = oldContentText
69
78
  oldContentText = el.innerText
70
79
  }
80
+ } else if (mutation.type==='childList') {
81
+ changes.children = { //FIXME: overwrites changes in this list path if list is rendered multiple times
82
+ was: Array.from(el.children) //FIXME; fill in 'now'
83
+ }
84
+ changes.length = -1 //FIXME: don't do this :)
85
+ } else {
86
+ console.log('nothing to do for',el,mutation.type)
71
87
  }
72
88
  }
73
89
  for (const prop in changes) {
74
90
  notifySet(signal, makeContext(prop, { was: changes[prop], now: el[prop] }))
75
91
  }
76
92
  })
77
- observer.observe(el, {
78
- characterData: true,
79
- subtree: true,
80
- attributes: true,
81
- attributesOldValue: true
82
- })
93
+ observer.observe(el, options)
83
94
  observers.set(el, observer)
84
95
  //@TODO: unregister the observer when el is removed from the dom (after a timeout)
85
96
  if (el.matches('input, textarea, select')) {
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
  }