@jinntec/fore 2.9.0 → 3.1.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.
Files changed (42) hide show
  1. package/README.md +0 -2
  2. package/dist/fore-dev.js +9505 -6057
  3. package/dist/fore.js +9218 -5996
  4. package/index.js +1 -0
  5. package/package.json +4 -2
  6. package/src/DependentXPathQueries.js +37 -2
  7. package/src/ForeElementMixin.js +64 -38
  8. package/src/actions/fx-delete.js +424 -73
  9. package/src/actions/fx-insert.js +472 -73
  10. package/src/actions/fx-setattribute.js +5 -5
  11. package/src/actions/fx-setvalue.js +11 -9
  12. package/src/authoring-check.js +231 -0
  13. package/src/fore.js +32 -77
  14. package/src/functions/registerFunction.js +65 -20
  15. package/src/fx-bind.js +128 -73
  16. package/src/fx-fore.js +653 -219
  17. package/src/fx-instance.js +145 -143
  18. package/src/fx-model.js +292 -102
  19. package/src/fx-speech.js +268 -0
  20. package/src/fx-submission.js +246 -135
  21. package/src/fx-var.js +28 -4
  22. package/src/json/JSONDomFacade.js +84 -0
  23. package/src/json/JSONLens.js +67 -0
  24. package/src/json/JSONNode.js +248 -0
  25. package/src/json/lensFromNode.js +5 -0
  26. package/src/modelitem.js +16 -2
  27. package/src/tools/fx-action-log.js +1 -1
  28. package/src/ui/UIElement.js +16 -2
  29. package/src/ui/abstract-control.js +14 -7
  30. package/src/ui/fx-case.js +3 -1
  31. package/src/ui/fx-control.js +4 -2
  32. package/src/ui/fx-group.js +1 -1
  33. package/src/ui/fx-items.js +26 -32
  34. package/src/ui/fx-output.js +8 -38
  35. package/src/ui/fx-repeat-attributes.js +1 -1
  36. package/src/ui/fx-repeat.js +683 -247
  37. package/src/ui/fx-repeatitem.js +16 -1
  38. package/src/ui/repeat-base.js +9 -5
  39. package/src/withDraggability.js +0 -1
  40. package/src/xpath-evaluation.js +1763 -740
  41. package/src/xpath-path.js +274 -24
  42. package/src/xpath-util.js +92 -46
@@ -5,10 +5,13 @@ import {
5
5
  evaluateXPathToFirstNode,
6
6
  evaluateXPathToNumber,
7
7
  } from '../xpath-evaluation.js';
8
- import { XPathUtil } from '../xpath-util';
8
+ import { XPathUtil } from '../xpath-util.js';
9
9
  import { Fore } from '../fore.js';
10
10
  import { getPath } from '../xpath-path.js';
11
11
 
12
+ // JSON support
13
+ import { JSONLens } from '../json/JSONLens.js';
14
+
12
15
  /**
13
16
  * `fx-insert`
14
17
  * inserts nodes into data instances
@@ -23,7 +26,7 @@ export class FxInsert extends AbstractAction {
23
26
  type: Number,
24
27
  },
25
28
  position: {
26
- type: Number,
29
+ type: String,
27
30
  },
28
31
  origin: {
29
32
  type: Object,
@@ -61,21 +64,380 @@ export class FxInsert extends AbstractAction {
61
64
  this.keepValues = !!this.hasAttribute('keep-values');
62
65
  }
63
66
 
67
+ // -------------------------
68
+ // JSON helpers
69
+ // -------------------------
70
+ _getValueAtLensSteps(rootValue, steps) {
71
+ let cur = rootValue;
72
+ for (const step of steps || []) {
73
+ if (cur === null || cur === undefined) return undefined;
74
+
75
+ // keyOrIndex can be string (object key) or number (array index)
76
+ if (typeof step === 'number') {
77
+ if (!Array.isArray(cur)) return undefined;
78
+ cur = cur[step];
79
+ } else {
80
+ cur = cur[step];
81
+ }
82
+ }
83
+ return cur;
84
+ }
85
+ _getInlineTemplateElement() {
86
+ // Prefer a direct child <template>, otherwise any descendant <template> within fx-insert
87
+ const direct = Array.from(this.children).find(c => c?.localName === 'template');
88
+ if (direct) return direct;
89
+ return this.querySelector('template');
90
+ }
91
+
92
+ _getTemplateElementById(templateId) {
93
+ if (!templateId) return null;
94
+
95
+ // Try within the same fore first (shadow + light)
96
+ const fore = this.getOwnerForm?.() || this.closest('fx-fore');
97
+ const sel = `template#${CSS.escape(templateId)}`;
98
+
99
+ if (fore) {
100
+ const inLight = fore.querySelector(sel);
101
+ if (inLight) return inLight;
102
+ const inShadow = fore.shadowRoot?.querySelector?.(sel);
103
+ if (inShadow) return inShadow;
104
+ }
105
+
106
+ // Global fallback
107
+ const el = document.getElementById(templateId);
108
+ return el && el.localName === 'template' ? el : null;
109
+ }
110
+
111
+ _getJsonTemplateTextFromTemplateEl(tplEl) {
112
+ if (!tplEl) return null;
113
+
114
+ // <template>.textContent is "" because content lives in template.content (DocumentFragment).
115
+ // innerHTML reads from the content fragment correctly.
116
+ const raw = String(tplEl.innerHTML || tplEl.textContent || '').trim();
117
+ if (!raw) return null;
118
+ return raw;
119
+ }
120
+
121
+ _tryParseJsonFromTemplateEl(tplEl, errorLabel) {
122
+ const txt = this._getJsonTemplateTextFromTemplateEl(tplEl);
123
+ if (!txt) return null;
124
+ try {
125
+ return JSON.parse(txt);
126
+ } catch (_e) {
127
+ throw new Error(`fx-insert: ${errorLabel} does not contain valid JSON`);
128
+ }
129
+ }
130
+ _isJsonLiteral(value) {
131
+ if (value === null || value === undefined) return false;
132
+ const t = String(value).trim();
133
+ return (t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'));
134
+ }
135
+
136
+ _parseJsonLiteral(value) {
137
+ const t = String(value ?? '').trim();
138
+ return JSON.parse(t);
139
+ }
140
+
141
+ _matchIndexRepeatId(expr) {
142
+ const t = String(expr ?? '').trim();
143
+ const m = t.match(/^index\s*\(\s*(['"])(.*?)\1\s*\)\s*$/);
144
+ return m ? m[2] : null;
145
+ }
146
+
147
+ _resolveRepeatById(repeatId, fore) {
148
+ if (!repeatId || !fore) return null;
149
+ try {
150
+ return fore.querySelector(`#${CSS.escape(repeatId)}`);
151
+ } catch (_e) {
152
+ // CSS.escape not available or invalid selector; fall back
153
+ return fore.querySelector(`#${repeatId}`);
154
+ }
155
+ }
156
+ _isJsonLensRef(ref) {
157
+ if (!ref) return false;
158
+ const t = String(ref).trim();
159
+ return t.startsWith('?') || /^instance\s*\(/.test(t);
160
+ }
161
+
162
+ _deepClone(value) {
163
+ // Prefer structuredClone when available
164
+ if (typeof structuredClone === 'function') return structuredClone(value);
165
+ return JSON.parse(JSON.stringify(value));
166
+ }
167
+
168
+ _clearJsonValues(value) {
169
+ // Produce an “empty” structure with same keys/shape.
170
+ if (Array.isArray(value)) return value.map(v => this._clearJsonValues(v));
171
+ if (value && typeof value === 'object') {
172
+ const out = {};
173
+ for (const [k, v] of Object.entries(value)) {
174
+ out[k] = this._clearJsonValues(v);
175
+ }
176
+ return out;
177
+ }
178
+ // primitives: clear to empty string (inputs render blank)
179
+ return '';
180
+ }
181
+
182
+ _jsonNodeToLensSteps(node) {
183
+ // Build JSONLens path array from a JSONNode by walking parents.
184
+ const steps = [];
185
+ let cur = node;
186
+ while (cur && cur.parent !== null && cur.keyOrIndex !== null && cur.keyOrIndex !== undefined) {
187
+ steps.unshift(cur.keyOrIndex);
188
+ cur = cur.parent;
189
+ }
190
+ return steps;
191
+ }
192
+
193
+ _resolveRepeatElement() {
194
+ // Don’t use XPathUtil.getClosest here: it can receive non-Elements and then `.matches()` explodes.
195
+ return this && this.nodeType === Node.ELEMENT_NODE && typeof this.closest === 'function'
196
+ ? this.closest('fx-repeat')
197
+ : null;
198
+ }
199
+
200
+ _performJsonInsert(inscope, fore) {
201
+ // We need the ARRAY CONTAINER node, not the array children.
202
+ // IMPORTANT: do not rely on xpath-evaluation here because action in-scope context
203
+ // can be a DOM node (trigger/button), not a JSON lens node.
204
+ const target = this._resolveJsonRefToNode(this.ref);
205
+ if (!target || !target.__jsonlens__) {
206
+ throw new Error('fx-insert JSON mode: ref did not resolve to a JSON lens node');
207
+ }
208
+
209
+ // Determine array container + insertion index
210
+ let arrayNode = target;
211
+ let insertIndex = 0;
212
+
213
+ // If ref points to an array item, insert relative to its parent array
214
+ if (
215
+ !Array.isArray(arrayNode.value) &&
216
+ arrayNode.parent &&
217
+ Array.isArray(arrayNode.parent.value)
218
+ ) {
219
+ const itemNode = arrayNode;
220
+ arrayNode = itemNode.parent;
221
+
222
+ const base =
223
+ typeof itemNode.keyOrIndex === 'number' ? itemNode.keyOrIndex : arrayNode.value.length;
224
+
225
+ if (this.position === 'before') insertIndex = base;
226
+ else insertIndex = base + 1; // after (default)
227
+ } else {
228
+ // ref points to the array itself
229
+ if (!Array.isArray(arrayNode.value)) {
230
+ throw new Error('fx-insert JSON mode: target is not an array');
231
+ }
232
+
233
+ const len = arrayNode.value.length;
234
+
235
+ if (this.hasAttribute('at')) {
236
+ // `at` is 1-based like XForms/XPath.
237
+ // When combined with position="after", we insert *after* the item at `at`.
238
+ const atExpr = this.getAttribute('at');
239
+
240
+ let at1;
241
+ if (/^\s*-?\d+(?:\.\d+)?\s*$/.test(atExpr)) {
242
+ at1 = Number(atExpr);
243
+ } else {
244
+ at1 = Number(evaluateXPathToNumber(atExpr, inscope, this));
245
+ }
246
+ if (Number.isNaN(at1) || at1 < 1) at1 = 1;
247
+
248
+ const base0 = Math.min(len, Math.max(0, at1 - 1));
249
+
250
+ if (this.position === 'after') {
251
+ insertIndex = Math.min(len, base0 + 1);
252
+ } else {
253
+ // before (and any other value): insert at the computed base index
254
+ insertIndex = base0;
255
+ }
256
+ } else if (this.position === 'first') {
257
+ insertIndex = 0;
258
+ } else if (this.position === 'last') {
259
+ insertIndex = len; // append
260
+ } else {
261
+ // default behavior: append
262
+ insertIndex = len;
263
+ }
264
+ }
265
+
266
+ // ------------------------------------------------------------
267
+ // IMPORTANT: update repeat index even if action is outside repeat
268
+ // ------------------------------------------------------------
269
+ // If at="index('movies')" (like in your demo), ensure index('movies')
270
+ // points at the new row BEFORE subsequent actions run.
271
+ const atExpr = this.getAttribute('at');
272
+ const repeatId = this._matchIndexRepeatId(atExpr);
273
+ const repeatFromAt = repeatId ? this._resolveRepeatById(repeatId, fore) : null;
274
+
275
+ // If action *is* inside a repeat, keep existing behavior as fallback
276
+ const repeatLocal = this._resolveRepeatElement();
277
+ const repeat = repeatFromAt || repeatLocal;
278
+
279
+ if (repeat) {
280
+ const newIndex1 = insertIndex + 1;
281
+ repeat.setAttribute('index', String(newIndex1));
282
+ if (typeof repeat.setIndex === 'function') {
283
+ try {
284
+ repeat.setIndex(newIndex1);
285
+ } catch (_e) {
286
+ // ignore
287
+ }
288
+ }
289
+ }
290
+
291
+ // ----------------------
292
+ // Compute insert value
293
+ // ----------------------
294
+ // Goal:
295
+ // - origin attribute OR <template> (inline or referenced) are AUTHOR-DEFINED defaults -> keep as-is
296
+ // - only the implicit fallback (clone last item) is cleared unless keep-values is set
297
+
298
+ let templateValue = null;
299
+ let hasExplicitOriginOrTemplate = false;
300
+
301
+ if (this.origin) {
302
+ // origin attribute is always explicit
303
+ hasExplicitOriginOrTemplate = true;
304
+
305
+ // 1) JSON literal origin (existing behavior): origin="{ ... }"
306
+ if (this._isJsonLiteral(this.origin)) {
307
+ templateValue = this._parseJsonLiteral(this.origin);
308
+ } else {
309
+ // 2) lens origin: origin="?foo?bar" OR 3) XPath origin
310
+ const originNode = this._isJsonLensRef(this.origin)
311
+ ? this._resolveJsonRefToNode(this.origin)
312
+ : evaluateXPathToFirstNode(this.origin, inscope, this);
313
+
314
+ if (originNode && originNode.__jsonlens__) {
315
+ templateValue = this._deepClone(originNode.value);
316
+ } else {
317
+ // origin was present but did not resolve -> keep old behavior: fall back
318
+ templateValue = null;
319
+ hasExplicitOriginOrTemplate = false;
320
+ }
321
+ }
322
+ } else {
323
+ // No origin attribute: allow JSON via template="id" or inline <template>...</template>
324
+ const templateId = this.getAttribute('template');
325
+ if (templateId) {
326
+ const tplEl = this._getTemplateElementById(templateId);
327
+ const parsed = this._tryParseJsonFromTemplateEl(tplEl, `template=\"${templateId}\"`);
328
+ if (parsed !== null) {
329
+ templateValue = parsed;
330
+ hasExplicitOriginOrTemplate = true;
331
+ }
332
+ }
333
+
334
+ if (templateValue === null) {
335
+ const inlineTpl = this._getInlineTemplateElement();
336
+ const parsed = this._tryParseJsonFromTemplateEl(inlineTpl, 'inline <template>');
337
+ if (parsed !== null) {
338
+ templateValue = parsed;
339
+ hasExplicitOriginOrTemplate = true;
340
+ }
341
+ }
342
+ }
343
+
344
+ if (templateValue === null) {
345
+ // Fallback: clone last item if it exists, else insert empty object
346
+ const len = arrayNode.value.length;
347
+ if (len > 0) {
348
+ templateValue = this._deepClone(arrayNode.value[len - 1]);
349
+ } else {
350
+ templateValue = {};
351
+ }
352
+ hasExplicitOriginOrTemplate = false;
353
+ }
354
+
355
+ // Explicit origin/template values must be preserved as-is.
356
+ // Only the implicit fallback clone gets cleared (unless keep-values is set).
357
+ const newValue =
358
+ this.keepValues || hasExplicitOriginOrTemplate
359
+ ? this._deepClone(templateValue)
360
+ : this._clearJsonValues(templateValue);
361
+
362
+ // Mutate raw JSON via JSONLens
363
+ // Mutate JSON in a way that keeps JSONNode.children in sync
364
+ const instanceId = XPathUtil.resolveInstance(this, this.ref);
365
+ const model = this.getModel();
366
+ const instance = model.getInstance(instanceId);
367
+
368
+ // 1) BEST: use the JSONNode API if available (it should update children)
369
+ if (arrayNode && typeof arrayNode.insert === 'function') {
370
+ arrayNode.insert(newValue, insertIndex);
371
+ } else {
372
+ // 2) Fallback: mutate raw data via JSONLens
373
+ const steps = this._jsonNodeToLensSteps(arrayNode);
374
+ const lens = new JSONLens(instance.instanceData, steps);
375
+ lens.insert(newValue, insertIndex);
376
+
377
+ // Force the array node to notice the change and rebuild children:
378
+ // IMPORTANT: change reference so set() can't short-circuit on sameRef=true
379
+ const nextArr = Array.isArray(arrayNode.value) ? arrayNode.value.slice() : [];
380
+ if (typeof arrayNode.set === 'function') {
381
+ arrayNode.set(nextArr);
382
+ } else {
383
+ // last resort
384
+ arrayNode.value = nextArr;
385
+ if (typeof arrayNode._buildChildren === 'function') arrayNode._buildChildren();
386
+ }
387
+ }
388
+
389
+ // At this point, children MUST match value length
390
+ if (Array.isArray(arrayNode.value) && Array.isArray(arrayNode.children)) {
391
+ if (arrayNode.children.length !== arrayNode.value.length) {
392
+ // One more forced rebuild to be safe
393
+ const nextArr = arrayNode.value.slice();
394
+ if (typeof arrayNode.set === 'function') arrayNode.set(nextArr);
395
+ else if (typeof arrayNode._buildChildren === 'function') arrayNode._buildChildren();
396
+ }
397
+ }
398
+
399
+ const insertedNode = arrayNode.children?.[insertIndex] || null;
400
+
401
+ // Dispatch Fore insert event similarly to XML branch
402
+ const xpath = insertedNode?.getPath ? insertedNode.getPath() : '';
403
+
404
+ Fore.dispatch(instance, 'insert', {
405
+ insertedNodes: insertedNode,
406
+ insertedParent: arrayNode,
407
+ ref: this.ref,
408
+ location: insertedNode,
409
+ position: this.position,
410
+ instanceId,
411
+ foreId: fore.id,
412
+ index: insertIndex + 1,
413
+ xpath,
414
+ });
415
+
416
+ document.dispatchEvent(
417
+ new CustomEvent('index-changed', {
418
+ composed: true,
419
+ bubbles: true,
420
+ detail: {
421
+ insertedNodes: insertedNode,
422
+ index: insertIndex + 1,
423
+ },
424
+ }),
425
+ );
426
+
427
+ // Ensure UI updates
428
+ this.needsUpdate = true;
429
+ return [xpath];
430
+ }
431
+ // -------------------------
432
+ // Existing XML clone helpers
433
+ // -------------------------
434
+
64
435
  _cloneOriginSequence(inscope, targetSequence) {
65
436
  let originSequenceClone;
66
437
  if (this.origin) {
67
438
  // ### if there's an origin attribute use it
68
439
  let originTarget;
69
440
  try {
70
- /*
71
- todo: discuss where to pass vars from event.detail into function context
72
- */
73
- // this.setInScopeVariables(this.detail);
74
-
75
- /*
76
- if in 'create-nodes' mode and origin targets a repeat, the repeat
77
- we use the already during initData() created nodeset as a template for insertion
78
- */
79
441
  if (this.origin.startsWith('#') && this.getOwnerForm().createNodes) {
80
442
  const repeat = this.getOwnerForm().querySelector(this.origin);
81
443
  originSequenceClone = repeat.createdNodeset.cloneNode(true);
@@ -83,7 +445,6 @@ export class FxInsert extends AbstractAction {
83
445
  console.error(`createdNodeset for repeat ${this.origin} does not exist`);
84
446
  }
85
447
  } else {
86
- // originTarget = evaluateXPathToFirstNode(this.origin, inscope, this);
87
448
  originTarget = evaluateXPathToFirstNode(this.origin, inscope, this);
88
449
  if (Array.isArray(originTarget) && originTarget.length === 0) {
89
450
  console.warn('invalid origin for this insert action - ignoring...', this);
@@ -114,19 +475,98 @@ export class FxInsert extends AbstractAction {
114
475
  return targetSequence.length;
115
476
  }
116
477
 
478
+ _parseJsonLensRef(ref, defaultInstanceId = 'default') {
479
+ if (!ref) return null;
480
+ const s = String(ref).trim();
481
+
482
+ // instance('id')?a?b
483
+ const instMatch = s.match(/^instance\s*\(\s*(['"])(.*?)\1\s*\)\s*(\?.*)?$/);
484
+ let instanceId;
485
+ let lensPart;
486
+
487
+ if (instMatch) {
488
+ instanceId = instMatch[2];
489
+ lensPart = instMatch[3] || '';
490
+ } else {
491
+ if (!s.startsWith('?')) return null;
492
+ instanceId = defaultInstanceId;
493
+ lensPart = s;
494
+ }
495
+
496
+ const steps = lensPart
497
+ .split('?')
498
+ .filter(Boolean)
499
+ .map(part => {
500
+ if (part === '*') return '*';
501
+ if (/^\d+$/.test(part)) return Number(part) - 1; // 1-based -> 0-based
502
+ return part;
503
+ });
504
+
505
+ return { instanceId, steps };
506
+ }
507
+
508
+ _resolveJsonRefToNode(ref) {
509
+ const parsed = this._parseJsonLensRef(ref, 'default');
510
+ if (!parsed) return null;
511
+
512
+ const model = this.getModel();
513
+ const instance = model?.getInstance?.(parsed.instanceId) || model?.getInstance?.('default');
514
+ const root = instance?.nodeset;
515
+ if (!root || !root.__jsonlens__) return null;
516
+
517
+ let node = root;
518
+ for (const step of parsed.steps) {
519
+ if (step === '*') {
520
+ // For insert we require a concrete container; callers should not use wildcard here.
521
+ return null;
522
+ }
523
+ node = node?.get?.(step);
524
+ if (!node) return null;
525
+ }
526
+ return node;
527
+ }
528
+
117
529
  async perform() {
118
- // We have a few terms here: `inScope` is the 'current item' we have. It is the item we're
119
- // copying and inserting elsewhere. If we have a `ref`, one of the nodes returned will
120
- // become the sibling of this copy. The `context` is the new parent of the copied
121
- // element. It's usually better to add a `context` because that deals with empty elements.
122
530
  let inscope;
123
531
  let context;
124
532
  let targetSequence = [];
125
- const inscopeContext = getInScopeContext(this);
126
533
 
127
534
  const fore = this.getOwnerForm();
535
+ const inscopeContext = getInScopeContext(this);
128
536
 
129
- // ### 'context' attribute takes precedence over 'ref'
537
+ // -----------------------------------------
538
+ // Decide mode ONLY by instance type (NOT ref)
539
+ // -----------------------------------------
540
+ const exprForInstanceResolution =
541
+ (this.hasAttribute('ref') && this.ref) ||
542
+ (this.hasAttribute('context') && this.getAttribute('context')) ||
543
+ "instance('default')";
544
+
545
+ const instanceId = XPathUtil.resolveInstance(this, exprForInstanceResolution);
546
+ const inst = this.getModel()?.getInstance?.(instanceId);
547
+
548
+ const isJsonInstance =
549
+ !!inst &&
550
+ (inst.type === 'json' ||
551
+ (typeof inst.getAttribute === 'function' && inst.getAttribute('type') === 'json'));
552
+
553
+ if (isJsonInstance) {
554
+ // In JSON mode we only support lens refs that start with '?'
555
+ if (this.hasAttribute('ref') && !this._isJsonLensRef(this.ref)) {
556
+ throw new Error(
557
+ `fx-insert JSON mode: ref must be a JSON lens path starting with '?' (got: ${this.ref})`,
558
+ );
559
+ }
560
+ // For JSON inserts your implementation expects to work from the in-scope lens context
561
+ inscope = inscopeContext;
562
+ return this._performJsonInsert(inscope, fore);
563
+ }
564
+
565
+ // -------------------------
566
+ // XML branch (normal XPath)
567
+ // -------------------------
568
+
569
+ // context takes precedence over ref
130
570
  if (this.hasAttribute('context')) {
131
571
  [context] = evaluateXPathToNodes(this.getAttribute('context'), inscopeContext, this);
132
572
  inscope = inscopeContext;
@@ -140,21 +580,13 @@ export class FxInsert extends AbstractAction {
140
580
  targetSequence = evaluateXPathToNodes(this.ref, inscope, this);
141
581
  }
142
582
  }
143
- // const originSequenceClone = this._cloneOriginSequence(inscope, targetSequence);
144
583
 
145
584
  const originSequenceClone = this._cloneOriginSequence(inscope, targetSequence);
146
- if (!originSequenceClone) return; // if no origin back out without effect
585
+ if (!originSequenceClone) return;
147
586
 
148
- /**
149
- * @type {Node}
150
- */
151
587
  let insertLocationNode;
152
- /**
153
- * @type {number}
154
- */
155
588
  let index;
156
589
 
157
- // if the targetSequence is empty but we got an originSequence use inscope as context and ignore 'at' and 'position'
158
590
  if (targetSequence.length === 0) {
159
591
  if (context) {
160
592
  insertLocationNode = context;
@@ -162,63 +594,46 @@ export class FxInsert extends AbstractAction {
162
594
  fore.signalChangeToElement(insertLocationNode.localName);
163
595
  fore.signalChangeToElement(originSequenceClone.localName);
164
596
  index = 1;
597
+ } else if (!inscope && this.getOwnerForm().createNodes) {
598
+ const repeat = this.getOwnerForm().querySelector(this.origin);
599
+ inscope = getInScopeContext(repeat, repeat.ref);
600
+ insertLocationNode = inscope;
601
+ inscope.appendChild(originSequenceClone);
602
+ index = inscope.length - 1;
165
603
  } else {
166
- // No context but creating nodes from UI
167
- if (!inscope && this.getOwnerForm().createNodes) {
168
- const repeat = this.getOwnerForm().querySelector(this.origin);
169
- inscope = getInScopeContext(repeat, repeat.ref);
170
- insertLocationNode = inscope;
171
- inscope.appendChild(originSequenceClone);
172
- index = inscope.length - 1;
173
- } else {
174
- insertLocationNode = inscope;
175
- inscope.appendChild(originSequenceClone);
176
- index = 1;
177
- }
604
+ insertLocationNode = inscope;
605
+ inscope.appendChild(originSequenceClone);
606
+ index = 1;
178
607
  }
179
608
  } else {
180
- /* ### insert at position given by 'at' or use the last item in the targetSequence ### */
181
609
  if (this.hasAttribute('at')) {
182
- // todo: eval 'at'
183
- // index = this.at;
184
- // insertLocationNode = targetSequence[this.at - 1];
185
-
186
610
  index = evaluateXPathToNumber(this.getAttribute('at'), inscope, this);
187
- insertLocationNode = targetSequence[index - 1];
611
+ insertLocationNode = targetSequence[index - 1]; // 1-based
188
612
  } else {
189
- // this.at = targetSequence.length;
190
613
  index = targetSequence.length;
191
614
  insertLocationNode = targetSequence[targetSequence.length - 1];
192
615
  }
193
616
 
194
- // ### if the insertLocationNode is undefined use the targetSequence - usually the case when the targetSequence just contains a single node
195
617
  if (!insertLocationNode) {
196
618
  index = 1;
197
-
198
619
  insertLocationNode = targetSequence;
199
- const context = evaluateXPathToNumber(
620
+ const ctxIndex = evaluateXPathToNumber(
200
621
  'count(preceding::*)',
201
622
  targetSequence,
202
623
  this.getOwnerForm(),
203
624
  );
204
- // console.log('context', context);
205
- index = context + 1;
206
- // index = targetSequence.findIndex(insertLocationNode);
625
+ index = ctxIndex + 1;
207
626
  }
208
627
 
209
628
  if (this.position && this.position === 'before') {
210
- // this.at -= 1;
211
629
  insertLocationNode.parentNode.insertBefore(originSequenceClone, insertLocationNode);
212
630
  fore.signalChangeToElement(insertLocationNode.parentNode);
213
631
  fore.signalChangeToElement(originSequenceClone.localName);
214
632
  }
215
633
 
216
634
  if (this.position && this.position === 'after') {
217
- // insertLocationNode.parentNode.append(originSequence);
218
- // const nextSibl = insertLocationNode.nextSibling;
219
635
  index += 1;
220
636
  if (this.hasAttribute('context') && this.hasAttribute('ref')) {
221
- // index=1;
222
637
  inscope.append(originSequenceClone);
223
638
  fore.signalChangeToElement(insertLocationNode);
224
639
  fore.signalChangeToElement(originSequenceClone.localName);
@@ -234,19 +649,7 @@ export class FxInsert extends AbstractAction {
234
649
  }
235
650
  }
236
651
  }
237
- // instance('default')/items/item[index()]
238
-
239
- // console.log('insert context item ', insertLocationNode);
240
- // console.log('parent ', insertLocationNode.parentNode);
241
- // console.log('instance ', this.getModel().getDefaultContext());
242
- // Fore.dispatch()
243
652
 
244
- // const instanceId = XPathUtil.resolveInstance(this, this.getAttribute('context'));
245
- const instanceId = XPathUtil.resolveInstance(this, this.ref);
246
- const inst = this.getModel().getInstance(instanceId);
247
- // console.log('<<<<<<< resolved instance', inst);
248
- // Note: the parent to insert under is always the parent of the inserted node. The 'context' is not always the parent if the sequence is empty, or the position is different
249
- // const xpath = XPathUtil.getPath(originSequenceClone.parentNode, instanceId);
250
653
  const xpath = getPath(insertLocationNode, instanceId);
251
654
 
252
655
  const path = Fore.getDomNodeIndexString(originSequenceClone);
@@ -258,6 +661,7 @@ export class FxInsert extends AbstractAction {
258
661
  detail: { action: this, event: this.event, path },
259
662
  }),
260
663
  );
664
+
261
665
  Fore.dispatch(inst, 'insert', {
262
666
  insertedNodes: originSequenceClone,
263
667
  insertedParent: insertLocationNode.parentNode,
@@ -270,9 +674,7 @@ export class FxInsert extends AbstractAction {
270
674
  xpath,
271
675
  });
272
676
 
273
- // todo: this actually should dispatch to respective instance
274
677
  document.dispatchEvent(
275
- // new CustomEvent('insert', {
276
678
  new CustomEvent('index-changed', {
277
679
  composed: true,
278
680
  bubbles: true,
@@ -299,8 +701,6 @@ export class FxInsert extends AbstractAction {
299
701
  }
300
702
 
301
703
  actionPerformed(changedPaths) {
302
- // ### make sure the necessary modelItems will get created
303
- // this.getModel().rebuild();
304
704
  super.actionPerformed();
305
705
  }
306
706
 
@@ -314,7 +714,6 @@ export class FxInsert extends AbstractAction {
314
714
 
315
715
  // clear attrs
316
716
  for (let i = 0; i < attrs.length; i += 1) {
317
- // n.setAttribute(attrs[i].name,'');
318
717
  attrs[i].value = '';
319
718
  }
320
719
  // clear text content
@@ -65,12 +65,12 @@ export default class FxSetattribute extends AbstractAction {
65
65
  this,
66
66
  null,
67
67
  );
68
- this.getOwnerForm()
69
- .getModel()
70
- .registerModelItem(newModelItem);
71
- this.getOwnerForm().addToBatchedNotifications(newModelItem);
68
+ // IMPORTANT: registerModelItem may return an existing canonical ModelItem for the same path.
69
+ // Always use the returned instance for notifications.
70
+ const canonical = this.getOwnerForm().getModel().registerModelItem(newModelItem);
71
+ this.getOwnerForm().addToBatchedNotifications(canonical);
72
72
  this.needsUpdate = true;
73
- newModelItem.notify();
73
+ canonical.notify();
74
74
  }
75
75
  }
76
76