@jinntec/fore 2.8.0 → 3.0.1

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