@peter.prib/renderengine 0.2.7 → 0.2.10
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/README.md +66 -9
- package/REFERENCE.md +123 -0
- package/dist/renderengine-workspace.umd.js +5044 -2892
- package/dist/renderengine.umd.js +4850 -2888
- package/examples/data-model.html +40 -18
- package/package.json +1 -1
- package/src/charting/FormDialog.js +398 -385
- package/src/charting/RowsField.js +127 -0
- package/src/charting/datamodel/ClassificationLens.js +33 -0
- package/src/charting/datamodel/ClassificationScheme.js +98 -0
- package/src/charting/datamodel/DataModel.js +220 -4
- package/src/charting/datamodel/DataModelChecker.js +248 -2
- package/src/charting/datamodel/DataModelContext.js +40 -10
- package/src/charting/datamodel/DataModelEditCommand.js +47 -0
- package/src/charting/datamodel/DataModelPerspective.js +12 -2
- package/src/charting/datamodel/EnumerationType.js +64 -51
- package/src/charting/datamodel/ServiceDomain.js +9 -0
- package/src/charting/datamodel/SqlDdl.js +80 -0
- package/src/charting/datamodel/SqlDdlFileFormat.js +23 -0
- package/src/charting/datamodel/index.js +21 -15
- package/src/charting/datamodel/naming/CamelCaseStyle.js +13 -0
- package/src/charting/datamodel/naming/LowerWordsStyle.js +13 -0
- package/src/charting/datamodel/naming/NameStyle.js +57 -0
- package/src/charting/datamodel/naming/NamingStandard.js +132 -0
- package/src/charting/datamodel/naming/PascalCaseStyle.js +13 -0
- package/src/charting/datamodel/naming/SnakeCaseStyle.js +13 -0
- package/src/charting/datamodel/naming/TitleCaseStyle.js +25 -0
- package/src/charting/datamodel/naming/UpperSnakeStyle.js +13 -0
- package/src/charting/datamodel/naming/index.js +8 -0
- package/src/charting/datamodel/scorecard/CompletenessCategory.js +30 -0
- package/src/charting/datamodel/scorecard/ConsistencyCategory.js +33 -0
- package/src/charting/datamodel/scorecard/DefinitionsCategory.js +33 -0
- package/src/charting/datamodel/scorecard/ModelScorecard.js +113 -0
- package/src/charting/datamodel/scorecard/ReadabilityCategory.js +37 -0
- package/src/charting/datamodel/scorecard/ScorecardCategory.js +46 -0
- package/src/charting/datamodel/scorecard/StandardsCategory.js +29 -0
- package/src/charting/datamodel/scorecard/StructureCategory.js +32 -0
- package/src/charting/datamodel/scorecard/index.js +8 -0
- package/src/charting/diagrams/DataModelDiagram.js +4 -11
- package/src/charting/diagrams/DataModelRelationshipStrategy.js +7 -1
- package/src/charting/diagrams/NodeGeometry.js +6 -2
- package/src/charting/toolbar/ToolbarSelect.js +6 -0
- package/src/datamodel.js +402 -4
- package/src/index.js +2666 -2666
- package/src/workspace/parts/DataModelPart.js +197 -6
|
@@ -1,385 +1,398 @@
|
|
|
1
|
-
import { el } from '../workspace/dom.js';
|
|
2
|
-
import { ColorField } from './ColorField.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* -
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* -
|
|
23
|
-
*
|
|
24
|
-
* -
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* -
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
* @param {
|
|
48
|
-
* @param {
|
|
49
|
-
* @param {
|
|
50
|
-
* @param {
|
|
51
|
-
* @param {
|
|
52
|
-
* @param {string} [options.
|
|
53
|
-
* @param {
|
|
54
|
-
* @param {
|
|
55
|
-
*
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
this.
|
|
62
|
-
this.
|
|
63
|
-
this.
|
|
64
|
-
this.
|
|
65
|
-
this.
|
|
66
|
-
this.
|
|
67
|
-
this.
|
|
68
|
-
this.
|
|
69
|
-
this.
|
|
70
|
-
|
|
71
|
-
this.
|
|
72
|
-
this.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
this.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
{
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
this.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
else
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
if (f.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
const
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
1
|
+
import { el } from '../workspace/dom.js';
|
|
2
|
+
import { ColorField } from './ColorField.js';
|
|
3
|
+
import { RowsField } from './RowsField.js';
|
|
4
|
+
|
|
5
|
+
const INHERIT = '__inherit__';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* FormDialog — the one modal form, built from field descriptions, so every
|
|
9
|
+
* editor dialog shares one layout, keyboard handling and value reading.
|
|
10
|
+
*
|
|
11
|
+
* Field: `{ key, label, type, value?, choices?, min?, max?, step?, rows?, placeholder?,
|
|
12
|
+
* suggestions?, accept?, hint?, readOnly?, allowEmpty?, inherit?, element?, read? }`
|
|
13
|
+
* - type: 'text' | 'textarea' | 'number' | 'color' | 'select' | 'checkbox' | 'checklist' | 'file' | 'custom'
|
|
14
|
+
* | 'rows' | 'heading' (a section title; no value)
|
|
15
|
+
* - rows: a list edited as a table (RowsField): `columns` `[{ key, label, type?, choices?, suggestions? }]`,
|
|
16
|
+
* `value` the items, `addLabel`; reads as the items in order, each keeping what no column shows
|
|
17
|
+
* - checklist: a box per choice, `choices` `[[value, text, group?], ...]` (grouped under their group's
|
|
18
|
+
* name), `value` the chosen values; reads as the chosen values, in choice order
|
|
19
|
+
* - choices (select): `[[value, text], ...]`. A current value that is not a
|
|
20
|
+
* choice is kept as its own option (never silently blanked); `allowEmpty`
|
|
21
|
+
* adds a "(none)" option for an empty value.
|
|
22
|
+
* - suggestions (text): values offered as you type (a datalist). The box empties
|
|
23
|
+
* on focus so every suggestion shows, and restores its value if left empty.
|
|
24
|
+
* - readOnly: shown but not editable, and not read back
|
|
25
|
+
* - accept (file): file types; reads as the chosen File or null
|
|
26
|
+
* - custom: `element` (a Node to place) and `read()` (its value)
|
|
27
|
+
* - color: a ColorField (the value as text, `#rrggbb` or `none`, a picker and preset
|
|
28
|
+
* swatches); `presets` (`[{value, name}]`) replaces ColorField.PRESETS, and
|
|
29
|
+
* `allowNone: false` leaves out "No colour"
|
|
30
|
+
* - hint: small explanatory text under the field
|
|
31
|
+
* - inherit: `{ text, short?, value }` — the field may be left unset. Selects get
|
|
32
|
+
* a first option labelled `text`; other types (checkboxes too) get an "Inherit"
|
|
33
|
+
* checkbox (`short` renames it) that greys the control out showing `value`. An
|
|
34
|
+
* inherited field reads as `undefined`. `FormDialog.many` uses it for a form
|
|
35
|
+
* that edits several items at once.
|
|
36
|
+
*
|
|
37
|
+
* The dialog fits the window: its fields scroll between the title and the
|
|
38
|
+
* buttons, which stay in view, and it moves by dragging its title. A textarea
|
|
39
|
+
* scrolls inside its own box and can be resized taller.
|
|
40
|
+
*
|
|
41
|
+
* `validate(values)` may return a message to show inline instead of closing.
|
|
42
|
+
* After open(), `controls[key]` is each field's control, for wiring fields that
|
|
43
|
+
* depend on one another.
|
|
44
|
+
*/
|
|
45
|
+
export class FormDialog {
|
|
46
|
+
/**
|
|
47
|
+
* @param {Object} options
|
|
48
|
+
* @param {string} options.title
|
|
49
|
+
* @param {string} [options.description] - Text under the title
|
|
50
|
+
* @param {Array<Object>} options.fields
|
|
51
|
+
* @param {(values: Object) => void} options.onApply
|
|
52
|
+
* @param {(values: Object) => (string|null|undefined)} [options.validate]
|
|
53
|
+
* @param {string} [options.submitLabel]
|
|
54
|
+
* @param {string} [options.cancelLabel]
|
|
55
|
+
* @param {string} [options.idPrefix] - Field element ids are `${idPrefix}${key}`
|
|
56
|
+
* @param {Document} [options.document]
|
|
57
|
+
* @param {(applied: boolean) => void} [options.onClose] - Once, when the dialog closes: whether it was
|
|
58
|
+
* applied (false for Cancel, Escape or a click outside)
|
|
59
|
+
*/
|
|
60
|
+
constructor({ title, description, fields, onApply, validate, submitLabel = 'Apply', cancelLabel = 'Cancel', idPrefix = 'form-dialog-', document: doc = document, onClose = null }) {
|
|
61
|
+
this.title = title;
|
|
62
|
+
this.description = description;
|
|
63
|
+
this.fields = fields;
|
|
64
|
+
this.onApply = onApply;
|
|
65
|
+
this.validate = validate;
|
|
66
|
+
this.submitLabel = submitLabel;
|
|
67
|
+
this.cancelLabel = cancelLabel;
|
|
68
|
+
this.idPrefix = idPrefix;
|
|
69
|
+
this.doc = doc;
|
|
70
|
+
this.onClose = onClose;
|
|
71
|
+
this._applied = false;
|
|
72
|
+
this.overlay = null;
|
|
73
|
+
/** @type {Object<string, HTMLElement>} */
|
|
74
|
+
this.controls = {};
|
|
75
|
+
this._readers = [];
|
|
76
|
+
this._error = null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
open() {
|
|
80
|
+
this._readers = [];
|
|
81
|
+
this.controls = {};
|
|
82
|
+
const rows = this.fields.map(f => this._row(f));
|
|
83
|
+
this.overlay = el({
|
|
84
|
+
style: {
|
|
85
|
+
position: 'fixed', inset: '0', background: 'rgba(0,0,0,0.25)', zIndex: '10001',
|
|
86
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'sans-serif',
|
|
87
|
+
},
|
|
88
|
+
on: { mousedown: (e) => { if (e.target === this.overlay) this.close(); } },
|
|
89
|
+
children: [{
|
|
90
|
+
tag: 'form',
|
|
91
|
+
attrs: { role: 'dialog', 'aria-label': this.title },
|
|
92
|
+
style: {
|
|
93
|
+
background: '#fff', color: '#222', border: '1px solid #bbb', borderRadius: '6px', padding: '16px 18px',
|
|
94
|
+
minWidth: '360px', maxWidth: '90vw', maxHeight: '90vh', boxSizing: 'border-box',
|
|
95
|
+
display: 'flex', flexDirection: 'column',
|
|
96
|
+
boxShadow: '0 6px 24px rgba(0,0,0,0.2)', fontSize: '12px',
|
|
97
|
+
},
|
|
98
|
+
ref: (n) => { this.form = n; },
|
|
99
|
+
on: {
|
|
100
|
+
submit: (e) => { e.preventDefault(); this._apply(); },
|
|
101
|
+
keydown: (e) => { if (e.key === 'Escape') this.close(); },
|
|
102
|
+
},
|
|
103
|
+
children: [
|
|
104
|
+
{
|
|
105
|
+
// The title is the handle that moves the dialog.
|
|
106
|
+
tag: 'h3', text: this.title, attrs: { title: 'Drag to move' },
|
|
107
|
+
style: { margin: '0 0 8px', fontSize: '14px', cursor: 'move', userSelect: 'none', flex: '0 0 auto' },
|
|
108
|
+
on: { mousedown: (e) => this._startMove(e) },
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
// The fields scroll; the title and the buttons stay in view.
|
|
112
|
+
style: { overflowY: 'auto', flex: '1 1 auto', minHeight: '0', paddingRight: '4px' },
|
|
113
|
+
ref: (n) => { this.body = n; },
|
|
114
|
+
children: [
|
|
115
|
+
this.description && { text: this.description, style: { fontSize: '11px', color: '#666', margin: '0 0 10px', whiteSpace: 'pre-line' } },
|
|
116
|
+
{ style: { display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '8px 12px', alignItems: 'center' }, children: rows.flat() },
|
|
117
|
+
{ attrs: { role: 'alert' }, style: { color: '#c0392b', fontSize: '12px', marginTop: '8px', display: 'none' }, ref: (n) => { this._error = n; } },
|
|
118
|
+
].filter(Boolean),
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
style: { display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '16px', flex: '0 0 auto' },
|
|
122
|
+
children: [
|
|
123
|
+
{ tag: 'button', attrs: { type: 'button' }, text: this.cancelLabel, on: { click: () => this.close() } },
|
|
124
|
+
{
|
|
125
|
+
tag: 'button', attrs: { type: 'submit' }, text: this.submitLabel,
|
|
126
|
+
style: { background: '#0066cc', color: '#fff', border: '1px solid #0066cc', borderRadius: '3px', padding: '4px 14px' },
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
].filter(Boolean),
|
|
131
|
+
}],
|
|
132
|
+
}, this.doc);
|
|
133
|
+
this.doc.body.appendChild(this.overlay);
|
|
134
|
+
this.overlay.querySelector('input:not([type=checkbox]), select, textarea')?.focus();
|
|
135
|
+
return this;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Move the dialog by its title: from then on it sits where it was dropped
|
|
140
|
+
* (fixed position), kept inside the window.
|
|
141
|
+
* @protected
|
|
142
|
+
*/
|
|
143
|
+
_startMove(e) {
|
|
144
|
+
if (e.button !== 0 || !this.form) return;
|
|
145
|
+
e.preventDefault();
|
|
146
|
+
const view = this.doc.defaultView;
|
|
147
|
+
const box = this.form.getBoundingClientRect();
|
|
148
|
+
const grab = { x: e.clientX - box.left, y: e.clientY - box.top };
|
|
149
|
+
Object.assign(this.form.style, { position: 'fixed', margin: '0', left: `${box.left}px`, top: `${box.top}px` });
|
|
150
|
+
const move = (ev) => {
|
|
151
|
+
const width = view?.innerWidth ?? Infinity, height = view?.innerHeight ?? Infinity;
|
|
152
|
+
const left = Math.min(Math.max(0, ev.clientX - grab.x), Math.max(0, width - box.width));
|
|
153
|
+
const top = Math.min(Math.max(0, ev.clientY - grab.y), Math.max(0, height - 40));
|
|
154
|
+
this.form.style.left = `${left}px`;
|
|
155
|
+
this.form.style.top = `${top}px`;
|
|
156
|
+
};
|
|
157
|
+
const end = () => {
|
|
158
|
+
this.doc.removeEventListener('mousemove', move);
|
|
159
|
+
this.doc.removeEventListener('mouseup', end);
|
|
160
|
+
};
|
|
161
|
+
this.doc.addEventListener('mousemove', move);
|
|
162
|
+
this.doc.addEventListener('mouseup', end);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
close() {
|
|
166
|
+
if (!this.overlay) return;
|
|
167
|
+
this.overlay.remove();
|
|
168
|
+
this.overlay = null;
|
|
169
|
+
this.onClose?.(this._applied);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
values() {
|
|
173
|
+
return Object.fromEntries(this._readers.map(read => read()));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
_apply() {
|
|
177
|
+
const values = this.values();
|
|
178
|
+
const problem = this.validate?.(values);
|
|
179
|
+
if (problem) {
|
|
180
|
+
this._error.textContent = problem;
|
|
181
|
+
this._error.style.display = 'block';
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
this._applied = true;
|
|
185
|
+
this.close();
|
|
186
|
+
this.onApply(values);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
_id(f) {
|
|
190
|
+
return `${this.idPrefix}${f.key}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
_keep(f) {
|
|
194
|
+
return (n) => { this.controls[f.key] = n; };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
_row(f) {
|
|
198
|
+
if (f.type === 'heading') {
|
|
199
|
+
return [{
|
|
200
|
+
text: f.label, style: {
|
|
201
|
+
gridColumn: '1 / -1', fontSize: '11px', fontWeight: 'bold', textTransform: 'uppercase',
|
|
202
|
+
color: '#999', letterSpacing: '0.04em', marginTop: '6px',
|
|
203
|
+
},
|
|
204
|
+
}];
|
|
205
|
+
}
|
|
206
|
+
const label = { tag: 'label', text: f.label, attrs: { for: this._id(f) }, style: { color: '#333' } };
|
|
207
|
+
let control;
|
|
208
|
+
if (f.type === 'select') control = this._select(f);
|
|
209
|
+
else if (f.type === 'checkbox') control = this._checkbox(f);
|
|
210
|
+
else if (f.type === 'checklist') control = this._checklist(f);
|
|
211
|
+
else if (f.type === 'custom') control = this._custom(f);
|
|
212
|
+
else if (f.type === 'rows') control = this._rowsField(f);
|
|
213
|
+
else control = f.inherit ? this._inheritableInput(f) : this._input(f);
|
|
214
|
+
// No label, or a table of rows: the control takes the whole row, its label above it.
|
|
215
|
+
const row = f.label && f.type !== 'rows' ? [label, control]
|
|
216
|
+
: [{ style: { gridColumn: '1 / -1' }, children: [...(f.label ? [{ ...label, style: { color: '#333', display: 'block', marginBottom: '2px' } }] : []), control] }];
|
|
217
|
+
if (f.hint) row.push({}, { text: f.hint, style: { fontSize: '11px', color: '#888', marginTop: '-4px' } });
|
|
218
|
+
return row;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
_select(f) {
|
|
222
|
+
let select;
|
|
223
|
+
if (!f.readOnly) this._readers.push(() => [f.key, select.value === INHERIT ? undefined : select.value]);
|
|
224
|
+
const unset = f.inherit && f.value === undefined;
|
|
225
|
+
const current = f.value == null ? '' : String(f.value);
|
|
226
|
+
const known = f.choices.some(([v]) => v === current);
|
|
227
|
+
const extra = unset ? []
|
|
228
|
+
: current && !known ? [[current, current]]
|
|
229
|
+
: !current && f.allowEmpty ? [['', '(none)']]
|
|
230
|
+
: [];
|
|
231
|
+
return {
|
|
232
|
+
tag: 'select', attrs: { id: this._id(f), disabled: !!f.readOnly }, ref: (n) => { select = n; this._keep(f)(n); },
|
|
233
|
+
children: [
|
|
234
|
+
...(f.inherit ? [{ tag: 'option', attrs: { value: INHERIT, selected: unset }, text: f.inherit.text }] : []),
|
|
235
|
+
...[...extra, ...f.choices].map(([v, text]) => ({ tag: 'option', attrs: { value: v, selected: !unset && v === current }, text })),
|
|
236
|
+
],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
_checkbox(f) {
|
|
241
|
+
if (f.inherit) return this._inheritableInput(f);
|
|
242
|
+
let box;
|
|
243
|
+
if (!f.readOnly) this._readers.push(() => [f.key, box.checked]);
|
|
244
|
+
return {
|
|
245
|
+
tag: 'input', attrs: { id: this._id(f), type: 'checkbox', checked: !!f.value, disabled: !!f.readOnly }, style: { justifySelf: 'start' },
|
|
246
|
+
ref: (n) => { box = n; this._keep(f)(n); },
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
_checklist(f) {
|
|
251
|
+
const boxes = [];
|
|
252
|
+
const chosen = new Set(f.value ?? []);
|
|
253
|
+
if (!f.readOnly) this._readers.push(() => [f.key, boxes.filter(b => b.checked).map(b => b.value)]);
|
|
254
|
+
const groups = new Map();
|
|
255
|
+
for (const [value, text, group = ''] of f.choices) {
|
|
256
|
+
if (!groups.has(group)) groups.set(group, []);
|
|
257
|
+
groups.get(group).push([value, text]);
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
attrs: { id: this._id(f) }, ref: this._keep(f),
|
|
261
|
+
style: { maxHeight: '220px', overflowY: 'auto', border: '1px solid #ccc', borderRadius: '3px', padding: '4px 8px', background: '#fff' },
|
|
262
|
+
children: [...groups].map(([group, items]) => ({
|
|
263
|
+
children: [
|
|
264
|
+
...(group ? [{ text: group, style: { fontSize: '11px', fontWeight: 'bold', color: '#777', marginTop: '4px' } }] : []),
|
|
265
|
+
...items.map(([value, text]) => ({
|
|
266
|
+
tag: 'label', style: { display: 'block', whiteSpace: 'nowrap' },
|
|
267
|
+
children: [
|
|
268
|
+
{ tag: 'input', attrs: { type: 'checkbox', value, checked: chosen.has(value), disabled: !!f.readOnly }, ref: (n) => boxes.push(n) },
|
|
269
|
+
` ${text}`,
|
|
270
|
+
],
|
|
271
|
+
})),
|
|
272
|
+
],
|
|
273
|
+
})),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
_custom(f) {
|
|
278
|
+
this._readers.push(() => [f.key, f.read()]);
|
|
279
|
+
if (f.element && !f.element.id) f.element.id = this._id(f);
|
|
280
|
+
this.controls[f.key] = f.element;
|
|
281
|
+
return f.element;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
_rowsField(f) {
|
|
285
|
+
const field = new RowsField(f, this.doc, this._id(f));
|
|
286
|
+
const node = field.element();
|
|
287
|
+
if (!f.readOnly) this._readers.push(() => [f.key, field.read()]);
|
|
288
|
+
this.controls[f.key] = node;
|
|
289
|
+
return node;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
_controlSpec(f, ref) {
|
|
293
|
+
const keep = (n) => { ref(n); this._keep(f)(n); };
|
|
294
|
+
const readOnlyStyle = f.readOnly ? { background: '#f2f2f2', color: '#888' } : {};
|
|
295
|
+
if (f.type === 'textarea') {
|
|
296
|
+
return {
|
|
297
|
+
tag: 'textarea', attrs: { id: this._id(f), rows: f.rows ?? 4, placeholder: f.placeholder, readonly: !!f.readOnly },
|
|
298
|
+
text: f.value ?? '',
|
|
299
|
+
// Long text scrolls inside the box, which can be dragged taller.
|
|
300
|
+
style: { width: '100%', minWidth: '260px', boxSizing: 'border-box', maxHeight: '40vh', overflowY: 'auto', resize: 'vertical', ...readOnlyStyle },
|
|
301
|
+
ref: keep,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
// A colour: its value as text (#rrggbb or none), a picker and preset swatches.
|
|
305
|
+
if (f.type === 'color') return ColorField.spec(f, { id: this._id(f), ref: keep, style: readOnlyStyle });
|
|
306
|
+
const value = f.type === 'file' ? null : (f.value == null ? '' : (Array.isArray(f.value) ? JSON.stringify(f.value) : String(f.value)));
|
|
307
|
+
const listId = f.suggestions ? `${this._id(f)}-suggestions` : null;
|
|
308
|
+
const input = {
|
|
309
|
+
tag: 'input',
|
|
310
|
+
attrs: {
|
|
311
|
+
id: this._id(f), type: f.type ?? 'text', value,
|
|
312
|
+
min: f.min, max: f.max, step: f.step, placeholder: f.placeholder, accept: f.accept, list: listId,
|
|
313
|
+
readonly: !!f.readOnly,
|
|
314
|
+
},
|
|
315
|
+
style: { ...(f.type === 'number' ? { width: '70px' } : { width: '100%', boxSizing: 'border-box' }), ...readOnlyStyle },
|
|
316
|
+
on: f.suggestions ? {
|
|
317
|
+
// A datalist filters against the text already typed, so an existing
|
|
318
|
+
// value can look like the only option. Emptying on focus shows them all.
|
|
319
|
+
focus: (e) => { e.target.dataset.prevValue = e.target.value; e.target.value = ''; },
|
|
320
|
+
blur: (e) => { if (e.target.value === '' && e.target.dataset.prevValue) e.target.value = e.target.dataset.prevValue; },
|
|
321
|
+
} : undefined,
|
|
322
|
+
ref: keep,
|
|
323
|
+
};
|
|
324
|
+
if (!listId) return input;
|
|
325
|
+
return {
|
|
326
|
+
children: [input, { tag: 'datalist', attrs: { id: listId }, children: f.suggestions.map(v => ({ tag: 'option', attrs: { value: v } })) }],
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
_read(f, input) {
|
|
331
|
+
if (f.type === 'file') return input.files?.[0] ?? null;
|
|
332
|
+
if (f.type === 'checkbox') return input.checked;
|
|
333
|
+
return f.type === 'number' ? Number(input.value) : input.value;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
_input(f) {
|
|
337
|
+
let input;
|
|
338
|
+
if (!f.readOnly) this._readers.push(() => [f.key, this._read(f, input)]);
|
|
339
|
+
return this._controlSpec(f, (n) => { input = n; });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** The control plus an "Inherit" checkbox that greys it out showing the inherited value. */
|
|
343
|
+
_inheritableInput(f) {
|
|
344
|
+
let input, inherit;
|
|
345
|
+
const sync = () => {
|
|
346
|
+
input.disabled = inherit.checked;
|
|
347
|
+
if (inherit.checked && f.type === 'checkbox') input.checked = !!f.inherit.value;
|
|
348
|
+
else if (inherit.checked) input.value = f.inherit.value;
|
|
349
|
+
};
|
|
350
|
+
this._readers.push(() => [f.key, inherit.checked ? undefined : this._read(f, input)]);
|
|
351
|
+
return {
|
|
352
|
+
style: { display: 'flex', alignItems: 'center', gap: '10px' },
|
|
353
|
+
children: [
|
|
354
|
+
f.type === 'checkbox'
|
|
355
|
+
? { tag: 'input', attrs: { id: this._id(f), type: 'checkbox', checked: !!(f.value ?? f.inherit.value) }, ref: (n) => { input = n; this._keep(f)(n); } }
|
|
356
|
+
: this._controlSpec({ ...f, value: f.value ?? f.inherit.value }, (n) => { input = n; }),
|
|
357
|
+
{
|
|
358
|
+
tag: 'label', style: { display: 'flex', alignItems: 'center', gap: '4px', color: '#555' },
|
|
359
|
+
attrs: { title: f.inherit.text },
|
|
360
|
+
children: [
|
|
361
|
+
{ tag: 'input', attrs: { type: 'checkbox', checked: f.value === undefined }, on: { change: sync }, ref: (n) => { inherit = n; } },
|
|
362
|
+
f.inherit.short ?? 'Inherit',
|
|
363
|
+
],
|
|
364
|
+
},
|
|
365
|
+
],
|
|
366
|
+
// el() runs a parent's ref after its children's, so input/inherit are bound here.
|
|
367
|
+
ref: () => sync(),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* One form for several items: each item's fields (the same keys, in the
|
|
373
|
+
* same order) merged into one list in which every field starts as "keep
|
|
374
|
+
* each item's own" (`inherit`, reading as undefined) showing the value
|
|
375
|
+
* they share, or empty with a hint when they differ. Set a field and it
|
|
376
|
+
* applies to them all; read-only fields are left out.
|
|
377
|
+
* @param {Array<Array<Object>>} fieldLists - One list of fields per item
|
|
378
|
+
* @param {Object} [options]
|
|
379
|
+
* @param {string} [options.short] - The keep checkbox's label
|
|
380
|
+
* @param {string} [options.text] - Its tooltip (and a select's first option)
|
|
381
|
+
* @returns {Array<Object>} fields
|
|
382
|
+
*/
|
|
383
|
+
static many(fieldLists, { short = 'Keep', text = 'As each one is' } = {}) {
|
|
384
|
+
const [first = [], ...rest] = fieldLists;
|
|
385
|
+
const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
386
|
+
return first.filter(f => !f.readOnly).map((f, i) => {
|
|
387
|
+
if (f.type === 'heading' || !f.key) return f;
|
|
388
|
+
const values = [f.value, ...rest.map(list => list.find(g => g.key === f.key)?.value)];
|
|
389
|
+
const shared = values.every(v => same(v, values[0]));
|
|
390
|
+
const blank = f.type === 'checkbox' ? false : '';
|
|
391
|
+
return {
|
|
392
|
+
...f, value: undefined,
|
|
393
|
+
inherit: { short, text, value: shared ? (values[0] ?? blank) : blank },
|
|
394
|
+
...(!shared && { hint: [f.hint, 'Differs between them'].filter(Boolean).join('. ') }),
|
|
395
|
+
};
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|