@muze-labs/simplyflow 0.9.0 → 0.10.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/include.mjs CHANGED
@@ -1,239 +1 @@
1
- function throttle(callbackFunction, intervalTime)
2
- {
3
- let eventId = 0
4
- return function throttledCallback(...params) {
5
- if (eventId) {
6
- return
7
- }
8
- eventId = globalThis.setTimeout(() => {
9
- eventId = 0
10
- callbackFunction.apply(this, params)
11
- }, intervalTime)
12
- }
13
- }
14
-
15
- const runWhenIdle = (() => {
16
- if (globalThis.requestIdleCallback) {
17
- return (callback) => {
18
- globalThis.requestIdleCallback(callback, {timeout: 500})
19
- }
20
- }
21
- return globalThis.requestAnimationFrame || ((callback) => globalThis.setTimeout(callback, 0))
22
- })()
23
-
24
- function rebaseHref(relative, base, cacheBuster)
25
- {
26
- const url = new URL(relative, base)
27
- if (cacheBuster) {
28
- url.searchParams.set('cb', cacheBuster)
29
- }
30
- return url.href
31
- }
32
-
33
- function cloneScript(script, base, cacheBuster)
34
- {
35
- const clone = globalThis.document.createElement('script')
36
- for (const attr of script.attributes) {
37
- clone.setAttribute(attr.name, attr.value)
38
- }
39
- clone.removeAttribute('data-simply-location')
40
-
41
- if (clone.hasAttribute('src')) {
42
- clone.src = rebaseHref(clone.getAttribute('src'), base, cacheBuster)
43
- } else {
44
- clone.textContent = script.textContent
45
- }
46
- return clone
47
- }
48
-
49
- function insertScript(script, placeholder)
50
- {
51
- placeholder.parentNode.insertBefore(script, placeholder)
52
- placeholder.parentNode.removeChild(placeholder)
53
- }
54
-
55
- function shouldWaitForScript(script)
56
- {
57
- // Async scripts are explicitly independent. Every other external script from
58
- // an include is treated as ordered, including scripts that used `defer`;
59
- // dynamically inserted `defer` scripts do not reliably model parser defer.
60
- return script.hasAttribute('src') && !script.hasAttribute('async')
61
- }
62
-
63
- function insertAndWaitForScript(script, placeholder)
64
- {
65
- return new Promise((resolve) => {
66
- const done = () => {
67
- script.removeEventListener('load', done)
68
- script.removeEventListener('error', done)
69
- resolve()
70
- }
71
- script.addEventListener('load', done)
72
- script.addEventListener('error', done)
73
- insertScript(script, placeholder)
74
- })
75
- }
76
-
77
- function findIncludeLinks(container)
78
- {
79
- const selector = 'link[rel="simply-include"],link[rel="simply-include-once"]'
80
- const links = Array.from(container.querySelectorAll(selector))
81
- if (container.matches?.(selector)) {
82
- links.unshift(container)
83
- }
84
- return links
85
- }
86
-
87
- class SimplyIncludes
88
- {
89
- constructor(options={})
90
- {
91
- this.container = options.container || globalThis.document
92
- this.cacheBuster = options.cacheBuster ?? defaultCacheBuster
93
- this.included = Object.create(null)
94
- this.scriptLocations = []
95
- this.destroyed = false
96
- this.handleChanges = throttle(() => {
97
- runWhenIdle(() => {
98
- if (!this.destroyed) {
99
- this.includeLinks(findIncludeLinks(this.container))
100
- }
101
- })
102
- }, 10)
103
- if (options.observe !== false) {
104
- this.observer = new MutationObserver(this.handleChanges)
105
- this.observer.observe(this.container, {
106
- subtree: true,
107
- childList: true,
108
- })
109
- this.handleChanges()
110
- }
111
- }
112
-
113
- async scripts(scripts, base)
114
- {
115
- const arr = scripts.slice()
116
- for (const script of arr) {
117
- if (this.destroyed) {
118
- return
119
- }
120
- const clone = cloneScript(script, base, this.cacheBuster)
121
- const node = this.scriptLocations[script.dataset.simplyLocation]
122
- if (!node?.parentNode) {
123
- continue
124
- }
125
-
126
- // Included scripts should behave like normal document-order scripts by default:
127
- // each blocking external script must finish loading and running before the next
128
- // script from the include is inserted. Dynamically inserted scripts are async by
129
- // default, so async=false and waiting for load are both needed here.
130
- const waitForLoad = shouldWaitForScript(clone)
131
- if (waitForLoad) {
132
- clone.async = false // important: set the property, not the boolean attribute
133
- await insertAndWaitForScript(clone, node)
134
- } else {
135
- insertScript(clone, node)
136
- }
137
- }
138
- }
139
-
140
- html(html, link)
141
- {
142
- const fragment = globalThis.document.createRange().createContextualFragment(html)
143
- const stylesheets = fragment.querySelectorAll('link[rel="stylesheet"],style')
144
- for (const stylesheet of stylesheets) {
145
- const href = stylesheet.getAttribute('href')
146
- if (href) {
147
- stylesheet.href = rebaseHref(href, link.href, this.cacheBuster)
148
- }
149
- globalThis.document.head.appendChild(stylesheet)
150
- }
151
-
152
- // Scripts imported through a fragment do not execute reliably in document order.
153
- // Placeholders preserve their positions while scripts are reinserted sequentially.
154
- const scriptsFragment = globalThis.document.createDocumentFragment()
155
- const scripts = fragment.querySelectorAll('script')
156
- if (scripts.length) {
157
- for (const script of scripts) {
158
- const placeholder = globalThis.document.createComment(script.src || 'inline script')
159
- script.parentNode.insertBefore(placeholder, script)
160
- script.dataset.simplyLocation = this.scriptLocations.length
161
- this.scriptLocations.push(placeholder)
162
- scriptsFragment.appendChild(script)
163
- }
164
- globalThis.setTimeout(() => {
165
- this.scripts(Array.from(scriptsFragment.children), link ? link.href : globalThis.location.href)
166
- }, 10)
167
- }
168
-
169
- link.parentNode.insertBefore(fragment, link)
170
- }
171
-
172
- async includeLinks(links)
173
- {
174
- const remainingLinks = links.reduce((remainder, link) => {
175
- if (link.rel === 'simply-include-once' && this.included[link.href]) {
176
- link.parentNode.removeChild(link)
177
- } else {
178
- this.included[link.href] = true
179
- link.rel = 'simply-include-loading'
180
- remainder.push(link)
181
- }
182
- return remainder
183
- }, [])
184
-
185
- for (const link of remainingLinks) {
186
- if (this.destroyed || !link.href) {
187
- continue
188
- }
189
- try {
190
- const response = await fetch(link.href)
191
- if (!response.ok) {
192
- console.warn(`simplyflow/include: failed to load "${link.href}" (${response.status})`)
193
- link.rel = 'simply-include-error'
194
- continue
195
- }
196
- const html = await response.text()
197
- if (this.destroyed || !link.parentNode) {
198
- continue
199
- }
200
- this.html(html, link)
201
- link.parentNode?.removeChild(link)
202
- } catch (error) {
203
- console.warn(`simplyflow/include: failed to load "${link.href}"`, { cause: error })
204
- link.rel = 'simply-include-error'
205
- }
206
- }
207
- }
208
-
209
- destroy()
210
- {
211
- this.destroyed = true
212
- this.observer?.disconnect()
213
- this.observer = undefined
214
- }
215
- }
216
-
217
- export function includes(options={})
218
- {
219
- return new SimplyIncludes(options)
220
- }
221
-
222
- let defaultCacheBuster = null
223
- const defaultInclude = () => new SimplyIncludes({
224
- container: globalThis.document,
225
- cacheBuster: defaultCacheBuster,
226
- observe: false
227
- })
228
-
229
- export const include = {
230
- get cacheBuster() {
231
- return defaultCacheBuster
232
- },
233
- set cacheBuster(value) {
234
- defaultCacheBuster = value
235
- },
236
- scripts: (scripts, base) => defaultInclude().scripts(scripts, base),
237
- html: (html, link) => defaultInclude().html(html, link),
238
- links: (links) => defaultInclude().includeLinks(Array.from(links))
239
- }
1
+ export * from '@muze-labs/simplyflow-app/include'
@@ -1,17 +1,17 @@
1
- import { bind } from './bind.mjs'
2
- import * as model from './model.mjs'
3
- import * as state from './state.mjs'
1
+ import { bind } from '@muze-labs/simplyflow-bind'
2
+ import * as model from '@muze-labs/simplyflow-model'
3
+ import * as state from '@muze-labs/simplyflow-state'
4
4
  import './render.mjs'
5
- import * as dom from './dom.mjs'
6
- import { app } from './app.mjs'
7
- import { actions } from './action.mjs'
8
- import { behaviors } from './behavior.mjs'
9
- import { commands } from './command.mjs'
10
- import { include, includes } from './include.mjs'
11
- import { shortcuts } from './shortcut.mjs'
12
- import path from './path.mjs'
13
- import { routes, SimplyRoute } from './route.mjs'
14
- import { html, css } from './highlight.mjs'
5
+ import * as dom from '@muze-labs/simplyflow-bind/dom'
6
+ import { app } from '@muze-labs/simplyflow-app'
7
+ import { actions } from '@muze-labs/simplyflow-app/action'
8
+ import { behaviors } from '@muze-labs/simplyflow-app/behavior'
9
+ import { commands } from '@muze-labs/simplyflow-app/command'
10
+ import { include, includes } from '@muze-labs/simplyflow-app/include'
11
+ import { shortcuts } from '@muze-labs/simplyflow-app/shortcut'
12
+ import path from '@muze-labs/simplyflow-app/path'
13
+ import { routes, SimplyRoute } from '@muze-labs/simplyflow-app/route'
14
+ import { html, css } from '@muze-labs/simplyflow-app/highlight'
15
15
 
16
16
  if (!globalThis.simply) {
17
17
  globalThis.simply = {}
package/src/model.mjs CHANGED
@@ -1,290 +1 @@
1
- import { signal, isSignal, raw, throttledEffect, batch } from './state.mjs'
2
-
3
- /**
4
- * This class implements a pluggable data model, where you can
5
- * add effects that are run only when either an option for that
6
- * effect changes, or when an effect earlier in the chain of
7
- * effects changes.
8
- */
9
- class SimplyFlowModel {
10
-
11
- /**
12
- * Creates a new datamodel, with a state property that contains
13
- * all the data passed to this constructor
14
- * @param state Object with all the data for this model
15
- * @throws Error if state is not set
16
- */
17
- constructor(state) {
18
- if (!state) {
19
- throw new Error('no options set')
20
- }
21
- if (state.data==null || typeof state.data[Symbol.iterator] !== 'function') {
22
- console.warn('SimplyFlowModel: options.data is not iterable')
23
- }
24
- this.state = signal(state)
25
- if (!this.state.options) {
26
- this.state.options = {}
27
- }
28
- this.effects = [{current:this.state.data}]
29
- this.view = {
30
- current: this.state.data
31
- }
32
- }
33
-
34
- /**
35
- * Adds an effect to run whenever a signal it depends on
36
- * changes. this.state is the usual signal.
37
- * The `fn` function param is not itself an effect, but must return
38
- * and effect function. `fn` takes one param, which is the data signal.
39
- * This signal will always have at least a `current` property.
40
- * The result of the effect function is pushed on to the this.effects
41
- * list. And the last effect added is set as this.view
42
- */
43
- addEffect(fn) {
44
- if (!fn || typeof fn !=='function') {
45
- throw new Error('addEffect requires an effect function as its parameter', { cause: fn })
46
- }
47
- const dataSignal = this.effects[this.effects.length-1]
48
- const connectedSignal = fn.call(this, dataSignal)
49
- if (!isSignal(connectedSignal)) {
50
- throw new Error('addEffect function parameter must return a Signal', { cause: fn })
51
- }
52
- this.view = connectedSignal
53
- this.effects.push(this.view)
54
- }
55
- }
56
-
57
- export function model(options) {
58
- return new SimplyFlowModel(options)
59
- }
60
-
61
- /**
62
- * Returns a function for model.addEffect that sorts the input data
63
- *
64
- * Options:
65
- * - direction (string) default 'asc' - change to 'desc' to sort in descending order
66
- * - sortBy (string) (optional) - used by the default sorting function to select the property to sort on
67
- * - sortFn (function) (required - set by default) - the sort function to use
68
- */
69
- export function sort(options={}) {
70
- return function(data) {
71
- // initialize the sort options, only gets called once
72
- this.state.options.sort = Object.assign({
73
- direction: 'asc',
74
- sortBy: null,
75
- sortFn: ((a,b) => {
76
- const sort = this.state.options.sort
77
- const sortBy = sort.sortBy
78
- if (!sort.sortBy) {
79
- return 0
80
- }
81
- const direction = sort.sortDirection || sort.direction || 'asc'
82
- const larger = direction == 'asc' ? 1 : -1
83
- const smaller = direction == 'asc' ? -1 : 1
84
- if (typeof a?.[sortBy] === 'undefined') {
85
- if (typeof b?.[sortBy] === 'undefined') {
86
- return 0
87
- }
88
- return larger
89
- }
90
- if (typeof b?.[sortBy] === 'undefined') {
91
- return smaller
92
- }
93
- if (a[sortBy]<b[sortBy]) {
94
- return smaller
95
- } else if (a[sortBy]>b[sortBy]) {
96
- return larger
97
- } else {
98
- return 0
99
- }
100
- })
101
- }, options);
102
- // then return the effect, which is called when
103
- // either the data or the sort options change
104
- return throttledEffect(() => {
105
- const sort = this.state.options.sort
106
- const direction = sort?.sortDirection || sort?.direction
107
- if (sort?.sortBy && direction) {
108
- // Read through the signal proxy so replacing `.sortFn` is tracked,
109
- // then call the raw comparator with the model as `this`.
110
- const trackedSortFn = sort.sortFn
111
- const sortFn = raw(sort).sortFn || trackedSortFn
112
- return data.current.toSorted((a, b) => sortFn.call(this, a, b))
113
- }
114
- return data.current
115
- }, 50)
116
- }
117
- }
118
-
119
- /**
120
- * Returns a function for model.addEffect that implements paging
121
- * for the input data. It will return a slice of the data matching
122
- * the page and pageSize options.
123
- *
124
- * Options:
125
- * - page (int) default 1 - which page to show, starts at 1
126
- * - pageSize (int) default 20 - how many items in a single page
127
- * - max (int) (calculated) - how many pages in total
128
- */
129
- export function paging(options={}) {
130
- return function(data) {
131
- // initialize the paging options
132
- this.state.options.paging = Object.assign({
133
- page: 1,
134
- pageSize: 20,
135
- max: 1
136
- }, options)
137
- return throttledEffect(() => {
138
- return batch(() => {
139
- const paging = this.state.options.paging
140
- if (!paging.pageSize) {
141
- paging.pageSize = 20
142
- }
143
- paging.max = Math.ceil(data.current.length / paging.pageSize)
144
- paging.page = Math.max(1, Math.min(paging.max, paging.page))
145
-
146
- const start = (paging.page-1) * paging.pageSize
147
- const end = start + paging.pageSize
148
- return data.current.slice(start, end)
149
- })
150
- }, 50)
151
- }
152
- }
153
-
154
- /**
155
- * Returns a function for model.addEffect that filters rows from the data,
156
- * using a custom filter function `options.matches`
157
- *
158
- * Options:
159
- * - name (string) (required) - the name of this filter, must be unique
160
- * - matches (function) (required) - the filter function to apply to the data
161
- * - enabled (bool) (required) - filter is applied only when enabled is set to true
162
- */
163
- export function filter(options) {
164
- if (!options?.name || typeof options.name!=='string') {
165
- throw new Error('filter requires options.name to be a string')
166
- }
167
- if (!options.matches || typeof options.matches!=='function') {
168
- throw new Error('filter requires options.matches to be a function')
169
- }
170
- return function(data) {
171
- if (this.state.options[options.name]) {
172
- throw new Error('a filter with this name already exists on this model')
173
- }
174
- this.state.options[options.name] = options
175
- return throttledEffect(() => {
176
- const filterOptions = this.state.options[options.name]
177
- if (filterOptions.enabled) {
178
- // Read through the signal proxy so replacing `.matches` is tracked
179
- // as a dependency of this effect. The proxied function may already
180
- // be bound to the options object, so call the raw function explicitly
181
- // with the model as `this`.
182
- const trackedMatches = filterOptions.matches
183
- const matches = raw(filterOptions).matches || trackedMatches
184
- return data.current.filter(row => matches.call(this, row))
185
- }
186
- return data.current
187
- }, 50)
188
- }
189
- }
190
-
191
- /**
192
- * Returns a function for model.addEffect that filters the data to only contain
193
- * columns (properties) that aren't hidden. Automatically runs again if any columns
194
- * hidden property changes.
195
- *
196
- * Options:
197
- * - columns (object) (required) - an object with properties describing each column. Each
198
- * property must be an object with an optional `hidden` property. If set to a truthy value,
199
- * and property in the dataset with the same name, will be filtered out.
200
- */
201
- export function columns(options={}) {
202
- const columnOptions = options?.columns && typeof options.columns === 'object'
203
- ? options.columns
204
- : options
205
- if (!columnOptions
206
- || typeof columnOptions!=='object'
207
- || Object.keys(columnOptions).length===0) {
208
- throw new Error('columns requires options to be an object with at least one property')
209
- }
210
- return function(data) {
211
- this.state.options.columns = columnOptions
212
- return throttledEffect(() => {
213
- return data.current.map(input => {
214
- let result = {}
215
- for (let key of Object.keys(this.state.options.columns)) {
216
- if (!this.state.options.columns[key]?.hidden) {
217
- result[key] = input[key] ?? null
218
- }
219
- }
220
- return result
221
- })
222
- }, 50)
223
- }
224
- }
225
-
226
- /**
227
- * Returns a function for use with model.addEffect, with the given options set
228
- * as model.options.scroll. The effect will return a slice of the input data, which
229
- * makes it easy to render just a part (slice) of the whole data.
230
- *
231
- * Options are:
232
- * - offset (int) default 0 (optional) - the offset in the data to start the slice
233
- * - rowCount (int) default 20 (optional / calculated) - the number of rows in the slice
234
- * - rowHeight (int) default 26 (optional) - the height of a single row in pixels
235
- * - itemsPerRow (int) default 1 (optional) - the number of items on a single row
236
- * - size (int) default data.current.length (calculated) - how many rows inside data.current before slicing
237
- * - scrollbar (HTMLElement) defualt null (optional) - if set, an effect is added to update this elements
238
- * height if data.current.length changes
239
- * - container (HTMLElement) default null (optional) - if set, a scroll listener is added to this element,
240
- * which will update the options.offset signal and trigger the slice effect. It will also set the rowCount.
241
- */
242
- export function scroll(options) {
243
-
244
- return function(data) {
245
- this.state.options.scroll = Object.assign({
246
- offset: 0,
247
- rowHeight: 26,
248
- rowCount: 20,
249
- itemsPerRow: 1,
250
- size: data.current.length
251
- }, options)
252
- const scrollOptions = this.state.options.scroll
253
-
254
- const scrollbar = scrollOptions.scrollbar
255
- || scrollOptions.container?.querySelector('[data-flow-scrollbar]')
256
- if (scrollbar) {
257
- if (scrollOptions.container) {
258
- scrollOptions.container.addEventListener('scroll', (evt) => {
259
- scrollOptions.offset = Math.floor(scrollOptions.container.scrollTop
260
- / (scrollOptions.rowHeight*scrollOptions.itemsPerRow)
261
- )
262
- })
263
- }
264
-
265
- throttledEffect(() => {
266
- scrollOptions.size = data.current.length * scrollOptions.rowHeight
267
- scrollbar.style.height = scrollOptions.size + 'px'
268
- }, 50)
269
- }
270
-
271
- return throttledEffect(() => {
272
- if (scrollOptions.container) {
273
- //TODO: add a resize listener so that if the size of the container
274
- // changes, the rowCount is calculated again
275
- scrollOptions.rowCount = Math.ceil(
276
- scrollOptions.container.getBoundingClientRect().height
277
- / scrollOptions.rowHeight
278
- )
279
- }
280
- scrollOptions.data = data.current
281
- let start = Math.min(scrollOptions.offset, data.current.length-1)
282
- let end = start + scrollOptions.rowCount
283
- if (end > data.current.length) {
284
- end = data.current.length
285
- start = end - scrollOptions.rowCount
286
- }
287
- return data.current.slice(start, end)
288
- }, 50)
289
- }
290
- }
1
+ export * from '@muze-labs/simplyflow-model'
package/src/path.mjs CHANGED
@@ -1,47 +1 @@
1
- const path = {
2
- get(dataset, pointer) {
3
- if (typeof pointer !== 'string') {
4
- return pointer
5
- }
6
- if (!pointer) {
7
- return dataset
8
- }
9
- return pointer.split('.').reduce(function(acc, name) {
10
- if (acc == null) {
11
- return null
12
- }
13
- if (!Reflect.has(Object(acc), name)) {
14
- return null
15
- }
16
- return acc[name]
17
- }, dataset)
18
- },
19
- set: function(dataset, pointer, value) {
20
- const parent = path.get(dataset, path.parent(pointer))
21
- if (parent == null) {
22
- throw new TypeError(`simplyflow/path: cannot set "${pointer}" because its parent path does not exist`)
23
- }
24
- parent[path.pop(pointer)] = value
25
- },
26
- pop: function(pointer) {
27
- return pointer.split('.').pop()
28
- },
29
- push: function(pointer, name) {
30
- return (pointer ? pointer + '.' : '') + name
31
- },
32
- parent: function(pointer) {
33
- const names = pointer.split('.')
34
- names.pop()
35
- return names.join('.')
36
- },
37
- parents: function(dataset, pointer) {
38
- let result = []
39
- while (pointer) {
40
- pointer = path.parent(pointer)
41
- result.unshift(pointer)
42
- }
43
- return result
44
- }
45
- }
46
-
47
- export default path
1
+ export { default } from '@muze-labs/simplyflow-app/path'