simplyflow 0.8.1 → 0.9.0

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.
package/src/bind.mjs CHANGED
@@ -1,10 +1,7 @@
1
1
  import { throttledEffect, destroy } from './state.mjs'
2
2
  import { escape_html, fixed_content } from './bind.transformers.mjs'
3
3
  import * as render from './bind.render.mjs'
4
-
5
- if (!Symbol.bindTemplate) {
6
- Symbol.bindTemplate = Symbol('bindTemplate')
7
- }
4
+ import { DEP } from './symbols.mjs'
8
5
 
9
6
  /**
10
7
  * Implements one way databinding, updating dom elements with matching attributes
@@ -48,6 +45,7 @@ class SimplyBind
48
45
  },
49
46
  renderers: {
50
47
  'INPUT':render.input,
48
+ 'TEXTAREA':render.input,
51
49
  'BUTTON':render.button,
52
50
  'SELECT':render.select,
53
51
  'A':render.anchor,
@@ -255,7 +253,7 @@ class SimplyBind
255
253
  clone.children[0].setAttribute(attribute+'-key',index)
256
254
  }
257
255
  // keep track of the used template, so if that changes, the item can be updated
258
- clone.children[0][Symbol.bindTemplate] = template
256
+ clone.children[0][DEP.TEMPLATE] = template
259
257
 
260
258
  // return clone, not the firstChild, so that all whitespace is cloned as well
261
259
  return clone
@@ -2,9 +2,11 @@
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'
5
+ import { signal as domSignal, trackDomField, trackDomList } from './dom.mjs'
6
6
  import { throttledEffect, effect, untracked, batch } from './state.mjs'
7
7
  import { getValueByPath } from './bind.mjs'
8
+ import { DEP } from './symbols.mjs'
9
+
8
10
  /**
9
11
  * This function is used by default to render dom elements with the `data-flow-field` attribute.
10
12
  * It will switch to only switching in template content if the context has any templates.
@@ -147,7 +149,8 @@ export function setValueByPath(root, path, value)
147
149
  export function arrayByTemplates(context)
148
150
  {
149
151
  const attribute = this.options.attribute
150
-
152
+ const attributes = [attribute+'-field',attribute+'-list',attribute+'-map']
153
+ const attrQuery = '['+attributes.join('],[')+']'
151
154
  let items = context.element.querySelectorAll(':scope > ['+attribute+'-key]')
152
155
  // do single merge strategy for now, in future calculate optimal merge strategy from a number
153
156
  // now just do a delete if a key <= last key, insert if a key >= last key
@@ -165,19 +168,24 @@ export function arrayByTemplates(context)
165
168
  item.remove()
166
169
  } else {
167
170
  // check that all data-bind params start with current json path or ':root', otherwise replaceChild
168
- let bindings = Array.from(item.querySelectorAll(`[${attribute}]`))
169
- if (item.matches(`[${attribute}]`)) {
171
+ let bindings = Array.from(item.querySelectorAll(attrQuery))
172
+ if (item.matches(attrQuery)) {
170
173
  bindings.unshift(item)
171
174
  }
172
175
  let needsReplacement = bindings.find(b => {
173
- let databind = b.getAttribute(attribute)
174
- return (databind.substr(0,5)!==':root'
175
- && databind.substr(0, context.path.length)!==context.path)
176
+ for (let attr of attributes) {
177
+ let databind = b.getAttribute(attr)
178
+ if (databind && databind.substr(0,5)!==':root'
179
+ && databind.substr(0, context.path.length)!==context.path) {
180
+ return true
181
+ }
182
+ }
183
+ return false
176
184
  })
177
185
  if (!needsReplacement) {
178
- if (item[Symbol.bindTemplate]) {
186
+ if (item[DEP.TEMPLATE]) {
179
187
  let newTemplate = this.findTemplate(context.templates, context.list[lastKey])
180
- if (newTemplate != item[Symbol.bindTemplate]){
188
+ if (newTemplate != item[DEP.TEMPLATE]){
181
189
  needsReplacement = true
182
190
  if (!newTemplate) {
183
191
  skipped++
@@ -211,35 +219,7 @@ export function arrayByTemplates(context)
211
219
  }
212
220
  }
213
221
  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
- })
222
+ trackDomList.call(this, context.element)
243
223
  }
244
224
  }
245
225
 
@@ -277,7 +257,7 @@ export function objectByTemplates(context)
277
257
  }
278
258
  }
279
259
  let newTemplate = this.findTemplate(context.templates, context.list[context.index])
280
- if (newTemplate != item[Symbol.bindTemplate]){
260
+ if (newTemplate != item[DEP.TEMPLATE]){
281
261
  let clone = this.applyTemplate(context)
282
262
  context.element.replaceChild(clone, item)
283
263
  }
@@ -300,7 +280,7 @@ export function fieldByTemplates(context)
300
280
  context.parent = getParentPath(context.element)
301
281
  if (rendered) {
302
282
  if (template) {
303
- if (rendered?.[Symbol.bindTemplate] != template) {
283
+ if (rendered?.[DEP.TEMPLATE] != template) {
304
284
  const clone = this.applyTemplate(context)
305
285
  context.element.replaceChild(clone, rendered)
306
286
  }
@@ -488,8 +468,6 @@ export function meta(context)
488
468
  }
489
469
  }
490
470
 
491
- const domSignals = new WeakMap()
492
-
493
471
  /**
494
472
  * sets the innerHTML and title and id properties of any HTML element
495
473
  */
@@ -508,29 +486,8 @@ export function element(context, ...extraprops)
508
486
  const props = ['innerHTML','title','id','className'].concat(extraprops)
509
487
  setProperties(el, value, ...props)
510
488
  if (this.options.twoway) {
511
- batch(() => {
512
- updateProperties.call(this, context, props, valueIsString)
513
- })
514
- }
515
- }
516
-
517
- export function updateProperties(context, props, valueIsString) {
518
- if (domSignals.has(context.element)) {
519
- return
489
+ trackDomField.call(this, context.element, props, valueIsString)
520
490
  }
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
- })
534
491
  }
535
492
 
536
493
  /**
package/src/dom.mjs CHANGED
@@ -1,11 +1,28 @@
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
+ import { DEP } from '../src/symbols.mjs'
2
6
 
7
+ /**
8
+ * Tracks element => signal mapping so that each element only has one signal
9
+ */
10
+ const domSignals = new WeakMap()
11
+
12
+ /**
13
+ * Tracks element => mutationObservers
14
+ */
15
+ const observers = new WeakMap()
16
+
17
+ /**
18
+ * A dom signal is a Proxy, to track access to properties
19
+ */
3
20
  const domSignalHandler = {
4
21
  get: (target, property, receiver) => {
5
- if (property===Symbol.xRay) {
22
+ if (property===DEP.XRAY) {
6
23
  return target // don't notifyGet here, this is only called by set
7
24
  }
8
- if (property===Symbol.Signal) {
25
+ if (property===DEP.SIGNAL) {
9
26
  return true
10
27
  }
11
28
  const value = target?.[property]
@@ -34,8 +51,17 @@ const domSignalHandler = {
34
51
  }
35
52
  }
36
53
 
54
+ /**
55
+ * This function returns a dom signal. Using this in an effect() function
56
+ * will automatically trigger the effect if a property of the dom signal
57
+ * changes.
58
+ * Valid options are any of the mutationObserver options, like characterData, subtree, etc.
59
+ * @param HTMLElement el
60
+ * @param Object options
61
+ * @returns Proxy
62
+ */
37
63
  export function signal(el, options) {
38
- if (el[Symbol.xRay]) {
64
+ if (el[DEP.XRAY]) {
39
65
  return el
40
66
  }
41
67
  if (!signals.has(el)) {
@@ -45,8 +71,9 @@ export function signal(el, options) {
45
71
  return signals.get(el)
46
72
  }
47
73
 
48
- const observers = new WeakMap()
49
-
74
+ /**
75
+ * This sets up the mutationObserver that calls notifySet on changes in the DOM
76
+ */
50
77
  function domListen(el, signal, options) {
51
78
  const defaultOptions = {
52
79
  characterData: true,
@@ -108,3 +135,77 @@ function domListen(el, signal, options) {
108
135
  }
109
136
  }
110
137
  }
138
+
139
+ /**
140
+ * This function sets up the dom signal on an element, provided it has a `data-flow-list` attribute
141
+ * @param HTMLElement element - the element to track
142
+ * @returns Proxy
143
+ */
144
+ export function trackDomList(element)
145
+ {
146
+ const path = this.getBindingPath(element)
147
+ if (!path) {
148
+ throw new Error('Could not find binding path for element', { cause: element })
149
+ }
150
+ const s = signal(element, {
151
+ childList: true
152
+ })
153
+ throttledEffect(() => {
154
+ const children = Array.from(s.children)
155
+ untracked(() => { // don't track access to the data, only track dom changes
156
+ batch(() => { // apply all changes in the list as one change
157
+ let key=0
158
+ const currentList = getValueByPath(this.options.root, path)
159
+ const source = currentList.slice() // make sure changes in currentList don't affect the original source
160
+ for (const item of children) {
161
+ if (item.tagName==='TEMPLATE') {
162
+ continue
163
+ }
164
+ if (item.dataset.flowKey) { //FIXME: could be other attribute name
165
+ if (item.dataset.flowKey!=key) {
166
+ setValueByPath(this.options.root, path+'.'+key,
167
+ source[item.dataset.flowKey])
168
+ }
169
+ key++
170
+ }
171
+ }
172
+ if (currentList.length>key) {
173
+ // remove extra values
174
+ currentList.length = key
175
+ }
176
+ })
177
+ })
178
+ })
179
+ return s
180
+ }
181
+
182
+ /**
183
+ * This function sets up the dom signal on an element, provided it has a `data-flow-field` attribute
184
+ * @param HTMLElement element - the element to track
185
+ * @returns Proxy
186
+ */
187
+ export function trackDomField(element, props, valueIsString) {
188
+ if (domSignals.has(element)) {
189
+ return
190
+ }
191
+ const path = this.getBindingPath(element)
192
+ if (!path) {
193
+ throw new Error('Could not find binding path for element', { cause: element })
194
+ }
195
+ const s = signal(element)
196
+ domSignals.set(element, s)
197
+ //TODO: run reverse transformers (extract)
198
+ batch(() => { // avoids cyclical dependencies - check why
199
+ throttledEffect(() => {
200
+ let updateValue = s.innerHTML //FIXME: incorrect: in an anchor this could be s.href - use extract here
201
+ if (!valueIsString) {
202
+ updateValue = getProperties(s, ...props)
203
+ }
204
+ untracked(() => { // don't track changes in data, only in the dom
205
+ // don't trigger this effect when the data changes (root.path)
206
+ setValueByPath(this.options.root, path, updateValue)
207
+ })
208
+ })
209
+ })
210
+ return s
211
+ }
@@ -0,0 +1,130 @@
1
+ import '../flow.mjs'
2
+
3
+ export default {
4
+ html: {
5
+ anchor: html`<div class="anchor"></div>`
6
+ },
7
+ css: {
8
+ anchor: css`
9
+ .anchor {
10
+ anchor-name: --cursor-anchor;
11
+ position: absolute;
12
+ z-index: 10000;
13
+ width: 10px;
14
+ height: 10px;
15
+ transform: rotate(45deg);
16
+ transform-origin: top left;
17
+ background: red;
18
+ display: none;
19
+ }`
20
+ },
21
+ actions: {
22
+ anchorPosition: function(position) {
23
+ this.state.anchor.position = position
24
+ },
25
+ anchorShow: function() {
26
+ this.state.anchor.visible = true
27
+ },
28
+ anchorHide: function() {
29
+ this.state.anchor.visible = false
30
+ }
31
+ },
32
+ hooks: {
33
+ start: function() {
34
+ this.container.insertAdjacentHTML('beforeend','<simply-render rel="anchor"></simply-render>')
35
+ this.state.anchor = {
36
+ element: this.container.querySelector('.anchor'),
37
+ offset: this.container.getBoundingClientRect(),
38
+ visible: false,
39
+ position: {
40
+ x: 0,
41
+ y: 0
42
+ }
43
+ }
44
+ setTimeout(() => {
45
+ this.state.anchor.element = this.container.querySelector('.anchor')
46
+ this.state.anchor.offset = this.container.getBoundingClientRect()
47
+ },100)
48
+ this.selectionListener = document.addEventListener('selectionchange', () => {
49
+ const selection = window.getSelection()
50
+ if (!selection.rangeCount || selection.isCollapsed) {
51
+ this.state.anchor.visible = false
52
+ return
53
+ }
54
+ if (!this.container.contains(selection.anchorNode)) {
55
+ this.state.anchor.visible = false
56
+ return
57
+ }
58
+ if (!selection.anchorNode.parentElement.closest('[contenteditable]')) {
59
+ this.state.anchor.visible = false
60
+ return
61
+ }
62
+ this.state.anchor.visible = true
63
+ const position = getCursorPosition(this.container)
64
+ this.state.anchor.position = position
65
+ })
66
+ simply.state.effect(() => {
67
+ const pos = this.state.anchor.position
68
+ const offset = this.state.anchor.offset
69
+ simply.state.batch(() => {
70
+ this.state.anchor.element.style.top = (pos.y + pos.height + offset.top) + 'px'
71
+ this.state.anchor.element.style.left = (pos.x + offset.left) + 'px'
72
+ })
73
+ })
74
+ simply.state.effect(() => {
75
+ const visible = this.state.anchor.visible
76
+ console.log('anchor visible ', visible)
77
+ if (visible) {
78
+ this.state.anchor.element.style.display = 'block'
79
+ } else {
80
+ this.state.anchor.element.style.display = 'none'
81
+ }
82
+ })
83
+
84
+ }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * This function returns the cursor position and height, if the cursor is in
90
+ * the given element. The x and y position are calculated relative to the top
91
+ * left of the given element. This function does not alter the DOM in any way.
92
+ */
93
+ function getCursorPosition(element) {
94
+ const selection = window.getSelection();
95
+ if (!selection.rangeCount) return null;
96
+
97
+ const range = document.createRange();
98
+ range.setStart(selection.focusNode, selection.focusOffset);
99
+ range.collapse(true);
100
+
101
+ // Try getClientRects() first — often non-empty even on empty lines
102
+ const elementRect = element.getBoundingClientRect();
103
+
104
+ const cursorNode = selection.focusNode;
105
+ const cursorElement = cursorNode.nodeType === Node.TEXT_NODE
106
+ ? cursorNode.parentElement
107
+ : cursorNode;
108
+
109
+ let x,y,height;
110
+ const rects = range.getClientRects();
111
+ if (rects.length > 0) {
112
+ x = rects[0].left - elementRect.left
113
+ y = rects[0].top - elementRect.top
114
+ height = rects[0].height
115
+ } else {
116
+ // Fallback for truly empty element: use padding from CSS
117
+ const style = window.getComputedStyle(cursorElement);
118
+ const lineHeight = parseFloat(style.lineHeight);
119
+ height = isNaN(lineHeight) ? parseFloat(style.fontSize) : lineHeight
120
+ const cursorElementRect = cursorElement.getBoundingClientRect();
121
+ x = cursorElementRect.left - elementRect.left + parseFloat(style.paddingLeft)
122
+ y = cursorElementRect.top - elementRect.top + parseFloat(style.paddingTop)
123
+ }
124
+ return {
125
+ x,
126
+ y,
127
+ height,
128
+ element: cursorElement
129
+ }
130
+ }