simplyflow 0.8.2 → 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
@@ -5,6 +5,8 @@
5
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++
@@ -249,7 +257,7 @@ export function objectByTemplates(context)
249
257
  }
250
258
  }
251
259
  let newTemplate = this.findTemplate(context.templates, context.list[context.index])
252
- if (newTemplate != item[Symbol.bindTemplate]){
260
+ if (newTemplate != item[DEP.TEMPLATE]){
253
261
  let clone = this.applyTemplate(context)
254
262
  context.element.replaceChild(clone, item)
255
263
  }
@@ -272,7 +280,7 @@ export function fieldByTemplates(context)
272
280
  context.parent = getParentPath(context.element)
273
281
  if (rendered) {
274
282
  if (template) {
275
- if (rendered?.[Symbol.bindTemplate] != template) {
283
+ if (rendered?.[DEP.TEMPLATE] != template) {
276
284
  const clone = this.applyTemplate(context)
277
285
  context.element.replaceChild(clone, rendered)
278
286
  }
package/src/dom.mjs CHANGED
@@ -2,15 +2,27 @@ import { signals, signal as stateSignal, notifyGet, notifySet, makeContext,
2
2
  throttledEffect, effect, untracked, batch } from './state.mjs'
3
3
  import { getValueByPath } from './bind.mjs'
4
4
  import { setValueByPath, getProperties } from './bind.render.mjs'
5
+ import { DEP } from '../src/symbols.mjs'
5
6
 
7
+ /**
8
+ * Tracks element => signal mapping so that each element only has one signal
9
+ */
6
10
  const domSignals = new WeakMap()
7
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
+ */
8
20
  const domSignalHandler = {
9
21
  get: (target, property, receiver) => {
10
- if (property===Symbol.xRay) {
22
+ if (property===DEP.XRAY) {
11
23
  return target // don't notifyGet here, this is only called by set
12
24
  }
13
- if (property===Symbol.Signal) {
25
+ if (property===DEP.SIGNAL) {
14
26
  return true
15
27
  }
16
28
  const value = target?.[property]
@@ -39,8 +51,17 @@ const domSignalHandler = {
39
51
  }
40
52
  }
41
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
+ */
42
63
  export function signal(el, options) {
43
- if (el[Symbol.xRay]) {
64
+ if (el[DEP.XRAY]) {
44
65
  return el
45
66
  }
46
67
  if (!signals.has(el)) {
@@ -50,8 +71,9 @@ export function signal(el, options) {
50
71
  return signals.get(el)
51
72
  }
52
73
 
53
- const observers = new WeakMap()
54
-
74
+ /**
75
+ * This sets up the mutationObserver that calls notifySet on changes in the DOM
76
+ */
55
77
  function domListen(el, signal, options) {
56
78
  const defaultOptions = {
57
79
  characterData: true,
@@ -114,6 +136,11 @@ function domListen(el, signal, options) {
114
136
  }
115
137
  }
116
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
+ */
117
144
  export function trackDomList(element)
118
145
  {
119
146
  const path = this.getBindingPath(element)
@@ -149,8 +176,14 @@ export function trackDomList(element)
149
176
  })
150
177
  })
151
178
  })
179
+ return s
152
180
  }
153
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
+ */
154
187
  export function trackDomField(element, props, valueIsString) {
155
188
  if (domSignals.has(element)) {
156
189
  return
@@ -174,4 +207,5 @@ export function trackDomField(element, props, valueIsString) {
174
207
  })
175
208
  })
176
209
  })
210
+ return s
177
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
+ }
@@ -0,0 +1,315 @@
1
+ import anchor from './anchor.mjs'
2
+ import '../flow.mjs'
3
+
4
+
5
+ const simplyToolbarCSS = css`
6
+ :host {
7
+ --simply-button-font: arial, helvetica, sans-serif;
8
+ --simply-button-font-size: 11px;
9
+ --simply-button-width: 50px;
10
+ --simply-button-height: 50px;
11
+ --simply-button-color: #333;
12
+ --simply-button-primary: #ea5922;
13
+ }
14
+ .simply-button {
15
+ height: var(--simply-button-height);
16
+ border-top: 1px solid transparent;
17
+ border-bottom: 2px solid transparent;
18
+ transition: background 0.2s ease;
19
+ font-size: var(--simply-button-font-size);
20
+ letter-spacing: 0;
21
+ font-family: var(--simply-button-font);
22
+ white-space: nowrap;
23
+ user-select: none;
24
+ vertical-align: top;
25
+ min-width: var(--simply-button-width);
26
+ text-align: center;
27
+ cursor: pointer;
28
+ padding: 0 4px;
29
+ text-transform: none;
30
+ background: transparent;
31
+ outline: none;
32
+ box-shadow: none;
33
+ border-radius: 0;
34
+ color: var(--simply-button-color);
35
+ position: relative;
36
+ }
37
+ .simply-button:hover {
38
+ border-bottom: 2px solid var(--simply-button-primary);
39
+ box-shadow: none;
40
+ }
41
+ .simply-button .ds-icon {
42
+ height: 26px;
43
+ font-size: 26px;
44
+ padding: 0 4px;
45
+ display: block;
46
+ margin: -2px auto -2px;
47
+ position: relative;
48
+ }
49
+ .simply-button.ds-selected {
50
+ border-top-color: var(--ds-grey-40);
51
+ background-color: var(--ds-grey-light);
52
+ border-left: 1px solid var(--ds-grey-40);
53
+ border-right: 1px solid var(--ds-white);
54
+ }
55
+ .simply-button:active {
56
+ border-bottom: 2px solid var(--ds-primary);
57
+ box-shadow: none;
58
+ }
59
+ .simply-toolbar {
60
+ white-space: nowrap;
61
+ min-width: 100%;
62
+ min-height: 50px;
63
+ display: flex;
64
+ position: relative;
65
+ }
66
+ .simply-toolbar-main {
67
+ border-top: 2px solid var(--simply-button-primary);
68
+ background: linear-gradient(180deg, white 0, white 95%, #CCC 100%);
69
+ }
70
+ .simply-toolbar-inline {
71
+ min-width: 100px;
72
+ }
73
+ .simply-toolbar-inline .ds-button,
74
+ .simply-toolbar .ds-button {
75
+ margin: 0;
76
+ }
77
+ .simply-toolbar-sub .simply-toolbar {
78
+ background: #EEE;
79
+ min-height: 40px;
80
+ }
81
+ .simply-toolbar-sub .simply-button {
82
+ height: 40px;
83
+ min-width: 40px;
84
+ }
85
+ .simply-toolbar-sub .simply-button .ds-icon {
86
+ height: 20px;
87
+ font-size: 20px;
88
+ }
89
+ .simply-toolbar-highlight {
90
+ background: var(--ds-primary-gradient-bump);
91
+ color: var(--ds-primary-contrast);
92
+ }
93
+ .simply-toolbar .simply-toolbar-title {
94
+ margin-top: 0;
95
+ }
96
+ .simply-toolbar-spacer {
97
+ border-left: 1px solid #ccc;
98
+ height: 60px;
99
+ position: absolute;
100
+ display: inline-block;
101
+ }
102
+ .simply-button-expands:not(.ds-selected)::after {
103
+ content: "";
104
+ display: block;
105
+ position: absolute;
106
+ bottom: 2px;
107
+ left: 50%;
108
+ margin-left: -3px;
109
+ width: 0;
110
+ border-top: 3px solid #888;
111
+ border-bottom: 0;
112
+ border-left: 3px solid transparent;
113
+ border-right: 3px solid transparent;
114
+ }
115
+ .simply-button-expanded {
116
+ background: #EEE;
117
+ }
118
+ .simply-button.simply-button-expanded::after {
119
+ display: none;
120
+ }
121
+ .simply-toolbar .simply-push-right {
122
+ margin-left: auto;
123
+ }
124
+ .simply-toolbar input[type="text"] {
125
+ margin-right:0;
126
+ margin-bottom: 0;
127
+ margin-top: 10px;
128
+ font-size: small;
129
+ line-height: 1.2em;
130
+ height: 35px;
131
+ }
132
+ .simply-toolbar-header {
133
+ border-top-width: 5px;
134
+ }
135
+ .ds-nightmode .simply-toolbar {
136
+ background: linear-gradient(var(--ds-grey-90) 0%, var(--ds-grey-90) 95%, black 100%);
137
+ color: var(--ds-white);
138
+ }
139
+ .ds-nightmode .simply-button {
140
+ color: var(--ds-white);
141
+ }
142
+ .ds-nightmode .simply-button.ds-selected {
143
+ background-color: var(--ds-grey-80);
144
+ border-left-color: var(--ds-black);
145
+ border-top-color: var(--ds-black);
146
+ border-right-color: var(--ds-grey-60);
147
+ }
148
+ .ds-nightmode .simply-button[disabled] {
149
+ background-color: transparent;
150
+ color: var(--ds-grey-60);
151
+ }
152
+ .simply-toolbar.ds-hidden {
153
+ height: 0px;
154
+ overflow: hidden;
155
+ min-height: 0px;
156
+ }`
157
+
158
+
159
+ //TODO: allow app to specify which toolbar to show instead of fixed toolbars.floatToolbarText.buttons
160
+ const simplyToolbarContents = html`
161
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@muze-nl/theds@0.2.7/dist/theds.css">
162
+ <style>
163
+ ${simplyToolbarCSS}
164
+ </style>
165
+ <nav class="simply-toolbar simply-toolbar-main simply-toolbar-inline" data-flow-list="toolbars.floatToolbarText.buttons">
166
+ <template rel="simply-toolbar"></template>
167
+ </nav>
168
+ <div class="simply-toolbar-sub" data-flow-map="toolbars.floatToolbarText.toolbars">
169
+ <template>
170
+ <nav class="simply-toolbar ds-hidden" data-flow-field=":key" data-flow-transform="simplyToolbar">
171
+ <div data-flow-list="buttons">
172
+ <template rel="simply-toolbar"></template>
173
+ </div>
174
+ </nav>
175
+ </template>
176
+ </div>`
177
+
178
+ export default {
179
+ css: {
180
+ simplyToolbarFloat: css`
181
+ :root {
182
+ --ds-shadow-light: rgba(0,0,0,0.07);
183
+ --ds-shadow-middle: rgba(0,0,0,0.09);
184
+ --ds-shadow-dark: rgba(0,0,0,0.11);
185
+ --ds-shadow-small:
186
+ 0 1px 1px var(--ds-shadow-dark),
187
+ 0 2px 2px var(--ds-shadow-middle),
188
+ 0 4px 4px var(--ds-shadow-light)
189
+ ;
190
+ }
191
+ .simply-toolbar-float {
192
+ margin: 0;
193
+ padding: 0;
194
+ border: 0;
195
+ width: auto;
196
+ position-anchor: --cursor-anchor;
197
+ position-area: end span-all;
198
+ position: absolute;
199
+ min-width:100px;
200
+ min-height: 50px;
201
+ background: white;
202
+ z-index: 10000;
203
+ margin-top: -4px;
204
+ box-shadow: var(--ds-shadow-small);
205
+ }`
206
+ },
207
+ html: {
208
+ 'simply-toolbar':
209
+ html`<button class="ds-button simply-button" data-flow-field=":value" data-flow-transform="simplyToolbarButton">
210
+ <svg class="ds-icon ds-icon-feather">
211
+ <use xlink:href="feather-sprite.svg#x" data-flow-transform="simplyIcon" data-flow-field="icon">
212
+ </use></svg>
213
+ <span data-flow-field="label"></span>
214
+ </button>`,
215
+ 'simply-toolbar-float':
216
+ html`<div class="simply-toolbar simply-toolbar-float simply-toolbar-inline" popover="manual"></div>`
217
+ },
218
+ transformers: {
219
+ simplyToolbar: function(context, next) {
220
+ context.element.id = context.value
221
+ },
222
+ simplyToolbarButton: function(context, next) {
223
+ const el = context.element
224
+ el.value = context.value.command
225
+ if (context.value.command=="expand") {
226
+ el.classList.add('simply-button-expands')
227
+ }
228
+ if (context.value.command) {
229
+ el.dataset.simplyCommand = context.value.command
230
+ }
231
+ if (context.value.value) {
232
+ el.value = context.value.value
233
+ }
234
+ // skip next()
235
+ },
236
+ simplyIcon: function(context, next) {
237
+ const url = new URL(context.element.getAttribute('xlink:href'), document.location)
238
+ url.hash = context.value
239
+ context.element.setAttribute('xlink:href', url.href)
240
+ // skip next()
241
+ }
242
+ },
243
+ commands: {
244
+ toggle: function(el, value) {
245
+
246
+ },
247
+ align: function(el, value) {
248
+
249
+ },
250
+ expand: function(el, value) {
251
+ const toolbar = el.closest('.simply-toolbar')
252
+ const subToolbars = toolbar.nextElementSibling;
253
+ if (!subToolbars) {
254
+ console.error('no subtoolbars')
255
+ return
256
+ }
257
+ const current = Array.from(subToolbars.querySelectorAll('.simply-toolbar:not(.ds-hidden)'))
258
+ for( let t of current) {
259
+ t.classList.add('ds-hidden')
260
+ }
261
+ const selectedToolbar = subToolbars.querySelector('#'+value)
262
+ if (selectedToolbar) {
263
+ selectedToolbar.classList.remove('ds-hidden')
264
+ const buttons = Array.from(toolbar.querySelectorAll('.simply-button-expanded'))
265
+ for (let button of buttons) {
266
+ button.classList.remove('simply-button-expanded')
267
+ }
268
+ el.classList.add('simply-button-expanded')
269
+ } else {
270
+ console.error('toolbar '+value+' not found')
271
+ }
272
+ }
273
+ },
274
+ actions: {
275
+ showToolbar: function(position) {
276
+ this.state.toolbar.showPopover()
277
+ },
278
+ hideToolbar: function() {
279
+ this.state.toolbar.hidePopover()
280
+ }
281
+ },
282
+ hooks: {
283
+ start: function() {
284
+ this.state.toolbar = this.container.querySelector('simply-edit-focus-toolbar')
285
+ if (!this.state.toolbar) {
286
+ this.container.insertAdjacentHTML('beforeend','<simply-render rel="simply-toolbar-float"></simply-render>')
287
+ setTimeout(() => {
288
+ const toolbar = document.querySelector('.simply-toolbar-float')
289
+ const shadow = toolbar.attachShadow({ mode: "open"})
290
+ shadow.innerHTML = simplyToolbarContents
291
+ this.state.toolbar = toolbar
292
+ simply.state.effect(() => {
293
+ let visible = this.state.anchor.visible
294
+ if (visible) {
295
+ this.actions.showToolbar()
296
+ } else {
297
+ this.actions.hideToolbar()
298
+ }
299
+ })
300
+ // databinding doesn't reach into shadowRoot by default, so set it up here
301
+ simply.bind({
302
+ root: this.state,
303
+ container: toolbar.shadowRoot,
304
+ transformers: this.transformers
305
+ })
306
+ // same for commands, set container explicitly to the shadowRoot
307
+ simply.command({ app: this, container: toolbar.shadowRoot, commands: this.commands})
308
+ }, 100)
309
+ }
310
+ }
311
+ },
312
+ components: {
313
+ anchor
314
+ }
315
+ }