@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.
- package/README.md +0 -2
- package/dist/fore-dev.js +9505 -6057
- package/dist/fore.js +9218 -5996
- package/index.js +1 -0
- package/package.json +4 -2
- package/src/DependentXPathQueries.js +37 -2
- package/src/ForeElementMixin.js +64 -38
- package/src/actions/fx-delete.js +424 -73
- package/src/actions/fx-insert.js +472 -73
- package/src/actions/fx-setattribute.js +5 -5
- package/src/actions/fx-setvalue.js +11 -9
- package/src/authoring-check.js +231 -0
- package/src/fore.js +32 -77
- package/src/functions/registerFunction.js +65 -20
- package/src/fx-bind.js +128 -73
- package/src/fx-fore.js +653 -219
- package/src/fx-instance.js +145 -143
- package/src/fx-model.js +292 -102
- package/src/fx-speech.js +268 -0
- package/src/fx-submission.js +246 -135
- package/src/fx-var.js +28 -4
- package/src/json/JSONDomFacade.js +84 -0
- package/src/json/JSONLens.js +67 -0
- package/src/json/JSONNode.js +248 -0
- package/src/json/lensFromNode.js +5 -0
- package/src/modelitem.js +16 -2
- package/src/tools/fx-action-log.js +1 -1
- package/src/ui/UIElement.js +16 -2
- package/src/ui/abstract-control.js +14 -7
- package/src/ui/fx-case.js +3 -1
- package/src/ui/fx-control.js +4 -2
- package/src/ui/fx-group.js +1 -1
- package/src/ui/fx-items.js +26 -32
- package/src/ui/fx-output.js +8 -38
- package/src/ui/fx-repeat-attributes.js +1 -1
- package/src/ui/fx-repeat.js +683 -247
- package/src/ui/fx-repeatitem.js +16 -1
- package/src/ui/repeat-base.js +9 -5
- package/src/withDraggability.js +0 -1
- package/src/xpath-evaluation.js +1763 -740
- package/src/xpath-path.js +274 -24
- package/src/xpath-util.js +92 -46
package/src/fx-fore.js
CHANGED
|
@@ -2,7 +2,11 @@ import { Fore } from './fore.js';
|
|
|
2
2
|
import './fx-instance.js';
|
|
3
3
|
import { FxModel } from './fx-model.js';
|
|
4
4
|
import '@jinntec/jinn-toast';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
evaluateXPathToNodes,
|
|
7
|
+
evaluateXPathToString,
|
|
8
|
+
createNamespaceResolver,
|
|
9
|
+
} from './xpath-evaluation.js';
|
|
6
10
|
import getInScopeContext from './getInScopeContext.js';
|
|
7
11
|
import { XPathUtil } from './xpath-util.js';
|
|
8
12
|
import { FxRepeatAttributes } from './ui/fx-repeat-attributes.js';
|
|
@@ -22,9 +26,7 @@ const dirtyStates = {
|
|
|
22
26
|
};
|
|
23
27
|
async function waitForFunctionLibs(rootEl) {
|
|
24
28
|
const libs = Array.from(rootEl.querySelectorAll('fx-functionlib'));
|
|
25
|
-
await Promise.all(
|
|
26
|
-
libs.map(l => (l.readyPromise ? l.readyPromise : Promise.resolve()))
|
|
27
|
-
);
|
|
29
|
+
await Promise.all(libs.map(l => (l.readyPromise ? l.readyPromise : Promise.resolve())));
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
/**
|
|
@@ -222,8 +224,6 @@ export class FxFore extends HTMLElement {
|
|
|
222
224
|
right:0;
|
|
223
225
|
left:0;
|
|
224
226
|
height:40px;
|
|
225
|
-
border-radius: 5px;
|
|
226
|
-
|
|
227
227
|
}
|
|
228
228
|
.popup .close {
|
|
229
229
|
position: absolute;
|
|
@@ -248,6 +248,53 @@ export class FxFore extends HTMLElement {
|
|
|
248
248
|
.warning{
|
|
249
249
|
background:orange;
|
|
250
250
|
}
|
|
251
|
+
#authoringErrors {
|
|
252
|
+
z-index: 20;
|
|
253
|
+
}
|
|
254
|
+
#authoringErrors .popup {
|
|
255
|
+
width: 70%;
|
|
256
|
+
max-height: 80vh;
|
|
257
|
+
overflow:hidden;
|
|
258
|
+
}
|
|
259
|
+
#authoringErrors h2 {
|
|
260
|
+
background: #c62828;
|
|
261
|
+
color: white;
|
|
262
|
+
padding-left: 12px;
|
|
263
|
+
line-height: 40px;
|
|
264
|
+
font-size: 1rem;
|
|
265
|
+
}
|
|
266
|
+
#authoringErrors table {
|
|
267
|
+
width: 100%;
|
|
268
|
+
border-collapse: collapse;
|
|
269
|
+
font-size: 0.85rem;
|
|
270
|
+
}
|
|
271
|
+
#authoringErrors th {
|
|
272
|
+
text-align: left;
|
|
273
|
+
border-bottom: 2px solid #c62828;
|
|
274
|
+
padding: 4px 8px;
|
|
275
|
+
}
|
|
276
|
+
#authoringErrors td {
|
|
277
|
+
padding: 4px 8px;
|
|
278
|
+
border-bottom: 1px solid #ddd;
|
|
279
|
+
vertical-align: top;
|
|
280
|
+
}
|
|
281
|
+
#authoringErrors td:first-child {
|
|
282
|
+
color: #555;
|
|
283
|
+
font-family: monospace;
|
|
284
|
+
white-space: nowrap;
|
|
285
|
+
}
|
|
286
|
+
#authoringErrors .ae-actions {
|
|
287
|
+
text-align: center;
|
|
288
|
+
margin-top: 12px;
|
|
289
|
+
}
|
|
290
|
+
#authoringErrors .ae-actions button {
|
|
291
|
+
padding: 6px 20px;
|
|
292
|
+
background: #c62828;
|
|
293
|
+
color: white;
|
|
294
|
+
border: none;
|
|
295
|
+
border-radius: 3px;
|
|
296
|
+
cursor: pointer;
|
|
297
|
+
}
|
|
251
298
|
`;
|
|
252
299
|
|
|
253
300
|
const html = `
|
|
@@ -265,6 +312,14 @@ export class FxFore extends HTMLElement {
|
|
|
265
312
|
<div id="messageContent"></div>
|
|
266
313
|
</div>
|
|
267
314
|
</div>
|
|
315
|
+
<div id="authoringErrors" class="overlay">
|
|
316
|
+
<div class="popup">
|
|
317
|
+
<h2>Authoring Errors</h2>
|
|
318
|
+
<a class="close" href="#" onclick="event.preventDefault();event.target.closest('.overlay').classList.remove('show')">×</a>
|
|
319
|
+
<div id="authoringErrorsContent" style="margin-top:48px;"></div>
|
|
320
|
+
<div class="ae-actions"><button onclick="this.closest('.overlay').classList.remove('show')">Dismiss</button></div>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
268
323
|
<slot name="event"></slot>
|
|
269
324
|
`;
|
|
270
325
|
|
|
@@ -281,13 +336,14 @@ export class FxFore extends HTMLElement {
|
|
|
281
336
|
this._scanForNewTemplateExpressionsNextRefresh = false;
|
|
282
337
|
this.repeatsFromAttributesCreated = false;
|
|
283
338
|
this.validateOn = this.hasAttribute('validate-on')
|
|
284
|
-
|
|
285
|
-
|
|
339
|
+
? this.getAttribute('validate-on')
|
|
340
|
+
: 'update';
|
|
286
341
|
// this.mergePartial = this.hasAttribute('merge-partial')? true:false;
|
|
287
342
|
this.mergePartial = false;
|
|
288
343
|
this.createNodes = this.hasAttribute('create-nodes') ? true : false;
|
|
289
344
|
this._localNamesWithChanges = new Set();
|
|
290
345
|
this.setAttribute('role', 'form'); // set aria role
|
|
346
|
+
this._pendingRefresh = false;
|
|
291
347
|
}
|
|
292
348
|
|
|
293
349
|
/**
|
|
@@ -304,9 +360,9 @@ export class FxFore extends HTMLElement {
|
|
|
304
360
|
_parseTargetList(raw) {
|
|
305
361
|
if (!raw) return [];
|
|
306
362
|
return raw
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
363
|
+
.split(/[\s,]+/)
|
|
364
|
+
.map(s => s.trim())
|
|
365
|
+
.filter(Boolean);
|
|
310
366
|
}
|
|
311
367
|
|
|
312
368
|
_findBySelector(sel) {
|
|
@@ -322,10 +378,10 @@ export class FxFore extends HTMLElement {
|
|
|
322
378
|
|
|
323
379
|
_isReadyTarget(el) {
|
|
324
380
|
return !!(
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
381
|
+
el &&
|
|
382
|
+
(el.ready === true ||
|
|
383
|
+
(el.classList && el.classList.contains('fx-ready')) ||
|
|
384
|
+
(typeof el.hasAttribute === 'function' && el.hasAttribute('ready')))
|
|
329
385
|
);
|
|
330
386
|
}
|
|
331
387
|
|
|
@@ -342,7 +398,7 @@ export class FxFore extends HTMLElement {
|
|
|
342
398
|
if (waitForRaw) {
|
|
343
399
|
if (!this._warnedWaitForDeprecation) {
|
|
344
400
|
console.warn(
|
|
345
|
-
|
|
401
|
+
'[fx-fore] The "wait-for" attribute is deprecated. Use init-on="ready" init-on-target="..." instead.',
|
|
346
402
|
);
|
|
347
403
|
this._warnedWaitForDeprecation = true;
|
|
348
404
|
}
|
|
@@ -441,7 +497,7 @@ export class FxFore extends HTMLElement {
|
|
|
441
497
|
// Special: closest fx-fore
|
|
442
498
|
if (targetSpec === 'closest') {
|
|
443
499
|
const recheckFn =
|
|
444
|
-
|
|
500
|
+
event === 'ready' ? () => this._isReadyTarget(this.closest('fx-fore')) : null;
|
|
445
501
|
|
|
446
502
|
const matchesFn = ev => {
|
|
447
503
|
const t = ev.target;
|
|
@@ -455,7 +511,7 @@ export class FxFore extends HTMLElement {
|
|
|
455
511
|
const selector = targetSpec;
|
|
456
512
|
|
|
457
513
|
const recheckFn =
|
|
458
|
-
|
|
514
|
+
event === 'ready' ? () => this._isReadyTarget(this._findBySelector(selector)) : null;
|
|
459
515
|
|
|
460
516
|
if (typeof recheckFn === 'function' && recheckFn()) {
|
|
461
517
|
return Promise.resolve();
|
|
@@ -488,7 +544,7 @@ export class FxFore extends HTMLElement {
|
|
|
488
544
|
}
|
|
489
545
|
|
|
490
546
|
this._initGatesPromise = Promise.all(gates.map(g => this._waitForInitGate(g))).then(
|
|
491
|
-
|
|
547
|
+
() => undefined,
|
|
492
548
|
);
|
|
493
549
|
return this._initGatesPromise;
|
|
494
550
|
}
|
|
@@ -523,7 +579,7 @@ export class FxFore extends HTMLElement {
|
|
|
523
579
|
}
|
|
524
580
|
// Fallback for odd engines/polyfills
|
|
525
581
|
return (slotEl.assignedNodes({ flatten: true }) || []).filter(
|
|
526
|
-
|
|
582
|
+
n => n.nodeType === Node.ELEMENT_NODE,
|
|
527
583
|
);
|
|
528
584
|
};
|
|
529
585
|
|
|
@@ -541,8 +597,8 @@ export class FxFore extends HTMLElement {
|
|
|
541
597
|
}
|
|
542
598
|
if (!modelElement.inited) {
|
|
543
599
|
console.info(
|
|
544
|
-
|
|
545
|
-
|
|
600
|
+
`%cFore running ... ${this.id ? '#' + this.id : ''}`,
|
|
601
|
+
'background:#64b5f6; color:white; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
|
|
546
602
|
);
|
|
547
603
|
|
|
548
604
|
const variables = new Map();
|
|
@@ -561,6 +617,7 @@ export class FxFore extends HTMLElement {
|
|
|
561
617
|
await Promise.all(libs.map(l => l.readyPromise || Promise.resolve()));
|
|
562
618
|
|
|
563
619
|
await modelElement.modelConstruct();
|
|
620
|
+
console.log('varbindings ', this._instanceVarBindings);
|
|
564
621
|
this._handleModelConstructDone();
|
|
565
622
|
}
|
|
566
623
|
|
|
@@ -568,7 +625,6 @@ export class FxFore extends HTMLElement {
|
|
|
568
625
|
this.inited = true;
|
|
569
626
|
};
|
|
570
627
|
|
|
571
|
-
|
|
572
628
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
573
629
|
if (oldValue === newValue) return;
|
|
574
630
|
|
|
@@ -609,7 +665,7 @@ export class FxFore extends HTMLElement {
|
|
|
609
665
|
|
|
610
666
|
connectedCallback() {
|
|
611
667
|
const modelElement = Array.from(this.children).find(
|
|
612
|
-
|
|
668
|
+
modelElem => modelElem.nodeName.toUpperCase() === 'FX-MODEL',
|
|
613
669
|
);
|
|
614
670
|
|
|
615
671
|
this.model = modelElement;
|
|
@@ -636,8 +692,8 @@ export class FxFore extends HTMLElement {
|
|
|
636
692
|
},true);
|
|
637
693
|
*/
|
|
638
694
|
this.ignoreExpressions = this.hasAttribute('ignore-expressions')
|
|
639
|
-
|
|
640
|
-
|
|
695
|
+
? this.getAttribute('ignore-expressions')
|
|
696
|
+
: null;
|
|
641
697
|
|
|
642
698
|
this.lazyRefresh = this.hasAttribute('refresh-on-view');
|
|
643
699
|
if (this.lazyRefresh) {
|
|
@@ -674,6 +730,79 @@ export class FxFore extends HTMLElement {
|
|
|
674
730
|
}
|
|
675
731
|
}
|
|
676
732
|
|
|
733
|
+
/**
|
|
734
|
+
* Ensure there is an fx-var for each fx-instance in this fx-fore's fx-model scope.
|
|
735
|
+
*
|
|
736
|
+
* - For instances with an @id, create `$id` with value `instance('id')`.
|
|
737
|
+
* - For the first instance WITHOUT an @id, create `$default` with value `instance()`.
|
|
738
|
+
* - IMPORTANT: if an instance has id="default", we STILL bind `$default` to `instance()`
|
|
739
|
+
* (avoids recursion / stack overflow during fx-var refresh in some cycles).
|
|
740
|
+
*
|
|
741
|
+
* Vars are inserted as direct children of `<fx-fore>` immediately before `<fx-model>`.
|
|
742
|
+
* The method is idempotent.
|
|
743
|
+
*/
|
|
744
|
+
_ensureInstanceVars() {
|
|
745
|
+
if (this.__instanceVarsEnsured) return;
|
|
746
|
+
this.__instanceVarsEnsured = true;
|
|
747
|
+
|
|
748
|
+
// Resolve this fx-fore's own fx-model (not nested ones)
|
|
749
|
+
const model = this.querySelector(':scope > fx-model');
|
|
750
|
+
if (!model) return;
|
|
751
|
+
|
|
752
|
+
// Collect instances that are direct children of this model (doc order)
|
|
753
|
+
const instances = Array.from(model.querySelectorAll(':scope > fx-instance'));
|
|
754
|
+
|
|
755
|
+
// Collect existing fx-var names at fx-fore scope (author-defined and previously generated)
|
|
756
|
+
const existingVars = new Set(
|
|
757
|
+
Array.from(this.querySelectorAll(':scope > fx-var'))
|
|
758
|
+
.map(v => (v.getAttribute('name') || '').trim())
|
|
759
|
+
.filter(Boolean),
|
|
760
|
+
);
|
|
761
|
+
|
|
762
|
+
let defaultAssigned = false;
|
|
763
|
+
|
|
764
|
+
for (const inst of instances) {
|
|
765
|
+
const rawId = (inst.getAttribute('id') || '').trim();
|
|
766
|
+
|
|
767
|
+
// First id-less instance => $default = instance()
|
|
768
|
+
if (!rawId) {
|
|
769
|
+
if (defaultAssigned) continue;
|
|
770
|
+
defaultAssigned = true;
|
|
771
|
+
|
|
772
|
+
const name = 'default';
|
|
773
|
+
if (existingVars.has(name)) continue;
|
|
774
|
+
|
|
775
|
+
const fxVar = document.createElement('fx-var');
|
|
776
|
+
fxVar.setAttribute('name', name);
|
|
777
|
+
fxVar.setAttribute('value', 'instance()');
|
|
778
|
+
fxVar.setAttribute('data-generated', 'instance-var');
|
|
779
|
+
|
|
780
|
+
this.insertBefore(fxVar, model);
|
|
781
|
+
existingVars.add(name);
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Normal id-based instance var
|
|
786
|
+
const name = rawId;
|
|
787
|
+
if (existingVars.has(name)) continue;
|
|
788
|
+
|
|
789
|
+
const fxVar = document.createElement('fx-var');
|
|
790
|
+
fxVar.setAttribute('name', name);
|
|
791
|
+
|
|
792
|
+
// IMPORTANT: avoid `instance('default')` recursion in fx-var refresh
|
|
793
|
+
if (name === 'default') {
|
|
794
|
+
fxVar.setAttribute('value', 'instance()');
|
|
795
|
+
} else {
|
|
796
|
+
fxVar.setAttribute('value', `instance('${name}')`);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
fxVar.setAttribute('data-generated', 'instance-var');
|
|
800
|
+
|
|
801
|
+
this.insertBefore(fxVar, model);
|
|
802
|
+
existingVars.add(name);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
677
806
|
_injectDevtools() {
|
|
678
807
|
if (this.ownerDocument.querySelector('fx-devtools')) {
|
|
679
808
|
// There's already a devtools, so we can ignore this one.
|
|
@@ -713,16 +842,19 @@ export class FxFore extends HTMLElement {
|
|
|
713
842
|
}
|
|
714
843
|
|
|
715
844
|
markAsClean() {
|
|
845
|
+
console.log('marking as clean', this);
|
|
716
846
|
this.addEventListener(
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
847
|
+
'value-changed',
|
|
848
|
+
() => {
|
|
849
|
+
console.log('MARK as modified', this);
|
|
850
|
+
this.dirtyState = dirtyStates.DIRTY;
|
|
851
|
+
this.classList.toggle('fx-modified');
|
|
852
|
+
},
|
|
853
|
+
{ once: true },
|
|
723
854
|
);
|
|
724
855
|
this.dirtyState = dirtyStates.CLEAN;
|
|
725
856
|
this.classList.remove('fx-modified');
|
|
857
|
+
this.querySelectorAll('.visited').forEach(el => el.classList.remove('visited'));
|
|
726
858
|
}
|
|
727
859
|
|
|
728
860
|
/**
|
|
@@ -793,89 +925,76 @@ export class FxFore extends HTMLElement {
|
|
|
793
925
|
/**
|
|
794
926
|
* @param {(boolean|{reason:'index-function'})} [force]fx-fore
|
|
795
927
|
*/
|
|
928
|
+
/**
|
|
929
|
+
* @param {(boolean|{reason:'index-function'})} [force]
|
|
930
|
+
*/
|
|
931
|
+
/**
|
|
932
|
+
* @param {(boolean|{reason:'index-function'})} [force]
|
|
933
|
+
*/
|
|
796
934
|
async refresh(force) {
|
|
935
|
+
// If we're already refreshing, do NOT drop the request.
|
|
936
|
+
// Queue a hard refresh and return a promise that resolves when the next refresh finishes.
|
|
797
937
|
if (this.isRefreshing) {
|
|
798
|
-
|
|
799
|
-
|
|
938
|
+
// keep "strongest" request: any true means hard refresh
|
|
939
|
+
this._pendingRefresh = this._pendingRefresh || force === true;
|
|
800
940
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
...(force || { reason: undefined }),
|
|
805
|
-
elementLocalnamesWithChanges: Array.from(this._localNamesWithChanges),
|
|
806
|
-
};
|
|
807
|
-
this._localNamesWithChanges.clear();
|
|
941
|
+
return new Promise(resolve => {
|
|
942
|
+
this.addEventListener('refresh-done', () => resolve(), { once: true });
|
|
943
|
+
});
|
|
808
944
|
}
|
|
809
|
-
*/
|
|
810
945
|
|
|
811
946
|
this.isRefreshing = true;
|
|
812
947
|
this.isRefreshPhase = true;
|
|
813
948
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
console.log('🔄 🎯 ### processing batched notifications');
|
|
825
|
-
await this._processBatchedNotifications();
|
|
826
|
-
}
|
|
949
|
+
try {
|
|
950
|
+
if (force === true || this.initialRun) {
|
|
951
|
+
performance.mark('force-refresh-start');
|
|
952
|
+
console.log('🔄 🔴🔴🔴 ### full refresh() on ', this);
|
|
953
|
+
await Fore.refreshChildren(this, force);
|
|
954
|
+
performance.mark('force-refresh-end');
|
|
955
|
+
performance.measure('force-refresh', 'force-refresh-start', 'force-refresh-end');
|
|
956
|
+
} else {
|
|
957
|
+
await this._processBatchedNotifications();
|
|
958
|
+
}
|
|
827
959
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
}
|
|
960
|
+
if (force === true || this.initialRun || this._scanForNewTemplateExpressionsNextRefresh) {
|
|
961
|
+
this._updateTemplateExpressions();
|
|
962
|
+
this._scanForNewTemplateExpressionsNextRefresh = false;
|
|
963
|
+
}
|
|
833
964
|
|
|
834
|
-
|
|
965
|
+
this._processTemplateExpressions();
|
|
835
966
|
|
|
836
|
-
|
|
967
|
+
this.isRefreshPhase = false;
|
|
968
|
+
this.initialRun = false;
|
|
969
|
+
this.style.visibility = 'visible';
|
|
837
970
|
|
|
838
|
-
|
|
839
|
-
// this.dispatchEvent(new CustomEvent('refresh-done'));
|
|
840
|
-
this.initialRun = false;
|
|
841
|
-
this.style.visibility = 'visible';
|
|
842
|
-
console.info(
|
|
971
|
+
console.info(
|
|
843
972
|
`%c ✅ refresh-done on #${this.id}`,
|
|
844
973
|
'background:darkorange; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
|
|
845
974
|
this.getModel().modelItems,
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
Fore.dispatch(this, 'refresh-done', {});
|
|
849
|
-
|
|
850
|
-
const subFores = Array.from(this.querySelectorAll('fx-fore'));
|
|
851
|
-
/*
|
|
852
|
-
calling the parent to refresh causes errors and inconsistent state. Also it is questionable
|
|
853
|
-
if a child should actually interact with its parent in this way.
|
|
854
|
-
|
|
855
|
-
This only affects the refreshing NOT the data mutation itself which is happening as expected.
|
|
856
|
-
|
|
857
|
-
Current solution is that a child that wants the parent to refresh must do so by adding an additional
|
|
858
|
-
event handler that dispatches an event upwards and having a handler in the parent to refresh itself.
|
|
975
|
+
);
|
|
859
976
|
|
|
860
|
-
|
|
977
|
+
Fore.dispatch(this, 'refresh-done', {});
|
|
861
978
|
|
|
862
|
-
|
|
863
|
-
|
|
979
|
+
const subFores = Array.from(this.querySelectorAll('fx-fore'));
|
|
980
|
+
for (const subFore of subFores) {
|
|
981
|
+
if (subFore.ready) {
|
|
982
|
+
await subFore.refresh(true);
|
|
864
983
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
984
|
+
}
|
|
985
|
+
} finally {
|
|
986
|
+
this.isRefreshing = false;
|
|
987
|
+
|
|
988
|
+
// If anything requested a refresh while we were refreshing, run exactly one more.
|
|
989
|
+
// This prevents "dropped" refresh requests (your timeout).
|
|
990
|
+
if (this._pendingRefresh) {
|
|
991
|
+
const pendingHard = this._pendingRefresh === true;
|
|
992
|
+
this._pendingRefresh = false;
|
|
993
|
+
// Important: do NOT await in finally without clearing flags first.
|
|
994
|
+
await this.refresh(pendingHard);
|
|
872
995
|
}
|
|
873
996
|
}
|
|
874
|
-
this.isRefreshing = false;
|
|
875
|
-
// Clear the batch
|
|
876
|
-
// this.batchedNotifications.clear();
|
|
877
997
|
}
|
|
878
|
-
|
|
879
998
|
/**
|
|
880
999
|
* Add a ModelItem to the batch of notifications to be processed at the end of the refresh phase
|
|
881
1000
|
* @param {ModelItem | import('./ui/UIElement.js').UIElement} item - The ModelItem or UI Element to add to the batch
|
|
@@ -892,6 +1011,9 @@ export class FxFore extends HTMLElement {
|
|
|
892
1011
|
*/
|
|
893
1012
|
_processBatchedNotifications() {
|
|
894
1013
|
if (this.batchedNotifications.size > 0) {
|
|
1014
|
+
console.log(`🔄 🎯 ### processing ${this.batchedNotifications.size} batched notifications`);
|
|
1015
|
+
console.log('🔄 🎯 ### processing ', Array.from(this.batchedNotifications));
|
|
1016
|
+
|
|
895
1017
|
// console.log(`🔍 Processing ${this.batchedNotifications.size} batched notifications`);
|
|
896
1018
|
|
|
897
1019
|
// Process all batched notifications
|
|
@@ -932,6 +1054,10 @@ export class FxFore extends HTMLElement {
|
|
|
932
1054
|
}
|
|
933
1055
|
});
|
|
934
1056
|
|
|
1057
|
+
// Update template expressions after processing batched notifications
|
|
1058
|
+
// This ensures template expressions are re-evaluated when data changes
|
|
1059
|
+
this._processTemplateExpressions();
|
|
1060
|
+
|
|
935
1061
|
// Clear the batch
|
|
936
1062
|
this.batchedNotifications.clear();
|
|
937
1063
|
}
|
|
@@ -947,7 +1073,7 @@ export class FxFore extends HTMLElement {
|
|
|
947
1073
|
*/
|
|
948
1074
|
_updateTemplateExpressions() {
|
|
949
1075
|
const search =
|
|
950
|
-
|
|
1076
|
+
"(descendant-or-self::*!(text(), @*))[contains(., '{')][substring-after(., '{') => contains('}')][not(ancestor-or-self::*[self::fx-model or self::fx-function])]";
|
|
951
1077
|
|
|
952
1078
|
const tmplExpressions = evaluateXPathToNodes(search, this, this);
|
|
953
1079
|
// console.log('template expressions found ', tmplExpressions);
|
|
@@ -958,6 +1084,7 @@ export class FxFore extends HTMLElement {
|
|
|
958
1084
|
|
|
959
1085
|
// console.log('######### storedTemplateExpressions', this.storedTemplateExpressions.length);
|
|
960
1086
|
|
|
1087
|
+
if (!tmplExpressions) return;
|
|
961
1088
|
/*
|
|
962
1089
|
storing expressions and their nodes for re-evaluation
|
|
963
1090
|
*/
|
|
@@ -1021,45 +1148,64 @@ export class FxFore extends HTMLElement {
|
|
|
1021
1148
|
* @param {Node} node the node which will get updated with evaluation result
|
|
1022
1149
|
*/
|
|
1023
1150
|
evaluateTemplateExpression(expr, node) {
|
|
1024
|
-
// ### do not evaluate template expressions
|
|
1151
|
+
// ### do not evaluate template expressions within nonrelevant sections
|
|
1025
1152
|
if (node.nodeType === Node.ATTRIBUTE_NODE && node.ownerElement.closest('[nonrelevant]')) return;
|
|
1026
1153
|
if (node.nodeType === Node.TEXT_NODE && node.parentNode.closest('[nonrelevant]')) return;
|
|
1027
1154
|
if (node.nodeType === Node.ELEMENT_NODE && node.closest('[nonrelevant]')) return;
|
|
1028
1155
|
|
|
1029
|
-
//
|
|
1030
|
-
|
|
1156
|
+
// ---- IMPORTANT GUARD ----
|
|
1157
|
+
// Prevent JSON object/array literals in fx-insert@origin from being treated as
|
|
1158
|
+
// template expressions (they contain {...} but are not XPath templates).
|
|
1159
|
+
if (node.nodeType === Node.ATTRIBUTE_NODE) {
|
|
1160
|
+
const el = node.ownerElement;
|
|
1161
|
+
if (el && el.localName === 'fx-insert' && node.name === 'origin') {
|
|
1162
|
+
const v = String(node.value ?? '').trim();
|
|
1163
|
+
const isJsonLiteral =
|
|
1164
|
+
(v.startsWith('{') && v.endsWith('}')) || (v.startsWith('[') && v.endsWith(']'));
|
|
1165
|
+
if (isJsonLiteral) return;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
// -------------------------
|
|
1169
|
+
|
|
1170
|
+
// The element that "defines" the template expression is the correct basis for:
|
|
1171
|
+
// - namespace resolution (xmlns lookup)
|
|
1172
|
+
// - fx-var scoping (in-scope variables)
|
|
1173
|
+
// - context() in repeats (repeat item detection)
|
|
1174
|
+
const definitionElement =
|
|
1175
|
+
node.nodeType === Node.ATTRIBUTE_NODE
|
|
1176
|
+
? node.ownerElement
|
|
1177
|
+
: node.nodeType === Node.TEXT_NODE
|
|
1178
|
+
? node.parentElement || node.parentNode
|
|
1179
|
+
: node;
|
|
1180
|
+
|
|
1181
|
+
const formElement =
|
|
1182
|
+
definitionElement && definitionElement.nodeType === Node.ELEMENT_NODE
|
|
1183
|
+
? definitionElement
|
|
1184
|
+
: this;
|
|
1185
|
+
|
|
1186
|
+
const replaced = String(expr ?? '').replace(/{[^}]*}/g, match => {
|
|
1031
1187
|
if (match === '{}') return match;
|
|
1188
|
+
|
|
1032
1189
|
const naked = match.substring(1, match.length - 1);
|
|
1033
1190
|
const inscope = getInScopeContext(node, naked);
|
|
1191
|
+
|
|
1034
1192
|
if (!inscope) {
|
|
1035
|
-
console.warn('no inscope context for expr', naked);
|
|
1036
|
-
const errNode =
|
|
1037
|
-
node.nodeType === Node.TEXT_NODE || node.nodeType === Node.ATTRIBUTE_NODE
|
|
1038
|
-
? node.parentNode
|
|
1039
|
-
: node;
|
|
1040
1193
|
return match;
|
|
1041
1194
|
}
|
|
1042
|
-
// Templates are special: they use the namespace configuration from the place where they are
|
|
1043
|
-
// being defined
|
|
1044
|
-
const instanceId = XPathUtil.getInstanceId(naked,node);
|
|
1045
|
-
|
|
1046
|
-
// If there is an instance referred
|
|
1047
|
-
const inst = instanceId
|
|
1048
|
-
? this.getModel().getInstance(instanceId)
|
|
1049
|
-
: this.getModel().getDefaultInstance();
|
|
1050
1195
|
|
|
1051
1196
|
try {
|
|
1052
|
-
|
|
1053
|
-
//
|
|
1054
|
-
|
|
1197
|
+
// IMPORTANT:
|
|
1198
|
+
// Do NOT pass `null` as the 4th argument here.
|
|
1199
|
+
// Passing `null` suppresses variable collection, which hides implicit vars
|
|
1200
|
+
// like `$default`.
|
|
1201
|
+
return evaluateXPathToString(naked, inscope, formElement);
|
|
1055
1202
|
} catch (error) {
|
|
1056
1203
|
console.warn('ignoring unparseable expr', error);
|
|
1057
1204
|
return match;
|
|
1058
1205
|
}
|
|
1059
1206
|
});
|
|
1060
1207
|
|
|
1061
|
-
// Update to the new value
|
|
1062
|
-
// images from reloading for example
|
|
1208
|
+
// Update to the new value only if it changed (avoid iframe/image reload etc.)
|
|
1063
1209
|
if (node.nodeType === Node.ATTRIBUTE_NODE) {
|
|
1064
1210
|
const parent = node.ownerElement;
|
|
1065
1211
|
if (parent.getAttribute(node.nodeName) !== replaced) {
|
|
@@ -1070,9 +1216,7 @@ export class FxFore extends HTMLElement {
|
|
|
1070
1216
|
node.textContent = replaced;
|
|
1071
1217
|
}
|
|
1072
1218
|
}
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
// eslint-disable-next-line class-methods-use-this
|
|
1219
|
+
} // eslint-disable-next-line class-methods-use-this
|
|
1076
1220
|
_getTemplateExpression(node) {
|
|
1077
1221
|
if (this.ignoredNodes) {
|
|
1078
1222
|
if (node.nodeType === Node.ATTRIBUTE_NODE) {
|
|
@@ -1121,17 +1265,17 @@ export class FxFore extends HTMLElement {
|
|
|
1121
1265
|
|
|
1122
1266
|
// ##### lazy creation should NOT take place if there's a parent Fore using shared instances
|
|
1123
1267
|
const parentFore =
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1268
|
+
this.parentNode.nodeType !== Node.DOCUMENT_FRAGMENT_NODE
|
|
1269
|
+
? this.parentNode.closest('fx-fore')
|
|
1270
|
+
: null;
|
|
1127
1271
|
if (this.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
|
|
1128
1272
|
console.log('fragment', this.parentNode);
|
|
1129
1273
|
}
|
|
1130
1274
|
|
|
1131
1275
|
if (parentFore) {
|
|
1132
1276
|
const shared = parentFore
|
|
1133
|
-
|
|
1134
|
-
|
|
1277
|
+
.getModel()
|
|
1278
|
+
.instances.filter(shared => shared.hasAttribute('shared'));
|
|
1135
1279
|
if (shared.length !== 0) return;
|
|
1136
1280
|
}
|
|
1137
1281
|
|
|
@@ -1152,8 +1296,8 @@ export class FxFore extends HTMLElement {
|
|
|
1152
1296
|
}
|
|
1153
1297
|
} catch (e) {
|
|
1154
1298
|
console.warn(
|
|
1155
|
-
|
|
1156
|
-
|
|
1299
|
+
'lazyCreateInstance created an error attempting to create a document',
|
|
1300
|
+
e.message,
|
|
1157
1301
|
);
|
|
1158
1302
|
}
|
|
1159
1303
|
}
|
|
@@ -1210,8 +1354,8 @@ export class FxFore extends HTMLElement {
|
|
|
1210
1354
|
*/
|
|
1211
1355
|
async _initUI() {
|
|
1212
1356
|
console.info(
|
|
1213
|
-
|
|
1214
|
-
|
|
1357
|
+
`%cinitUI #${this.id}`,
|
|
1358
|
+
'background:lightblue; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
|
|
1215
1359
|
);
|
|
1216
1360
|
|
|
1217
1361
|
const parentFore = this.closest('fx-fore');
|
|
@@ -1233,7 +1377,12 @@ export class FxFore extends HTMLElement {
|
|
|
1233
1377
|
|
|
1234
1378
|
// First refresh should be forced
|
|
1235
1379
|
if (this.createNodes) {
|
|
1380
|
+
performance.mark('initData-start');
|
|
1236
1381
|
this.initData();
|
|
1382
|
+
performance.mark('initData-end');
|
|
1383
|
+
|
|
1384
|
+
performance.measure('initData', 'initData-start', 'initData-end');
|
|
1385
|
+
|
|
1237
1386
|
const binds = this.getModel().querySelector('fx-bind');
|
|
1238
1387
|
if (binds) {
|
|
1239
1388
|
this.getModel().updateModel();
|
|
@@ -1251,13 +1400,12 @@ export class FxFore extends HTMLElement {
|
|
|
1251
1400
|
this.initialRun = false;
|
|
1252
1401
|
// console.log('### >>>>> dispatching ready >>>>>', this);
|
|
1253
1402
|
console.info(
|
|
1254
|
-
|
|
1255
|
-
|
|
1403
|
+
`%c ✅ ${this.id ? '#' + this.id : 'Fore'} is ready`,
|
|
1404
|
+
'background:lightgreen; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
|
|
1256
1405
|
);
|
|
1257
1406
|
|
|
1258
1407
|
// console.log(`### <<<<< ${this.id} ready >>>>>`);
|
|
1259
1408
|
|
|
1260
|
-
// console.log('### modelItems: ', this.getModel().modelItems);
|
|
1261
1409
|
Fore.dispatch(this, 'ready', {});
|
|
1262
1410
|
// console.log('dataChanged', FxModel.dataChanged);
|
|
1263
1411
|
this.markAsClean();
|
|
@@ -1271,6 +1419,50 @@ export class FxFore extends HTMLElement {
|
|
|
1271
1419
|
e.stopPropagation();
|
|
1272
1420
|
e.dataTransfer.dropEffect = 'move';
|
|
1273
1421
|
});
|
|
1422
|
+
|
|
1423
|
+
// Run authoring checks after ready — they're diagnostic only and must not delay
|
|
1424
|
+
// the ready event or drag-listener registration (both of which tests depend on).
|
|
1425
|
+
try {
|
|
1426
|
+
await this._runAuthoringChecks();
|
|
1427
|
+
} catch (e) {
|
|
1428
|
+
console.warn('[fore] authoring check failed:', e.message);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
async _runAuthoringChecks() {
|
|
1433
|
+
if (this.hasAttribute('no-check')) return;
|
|
1434
|
+
if (new URLSearchParams(window.location.search).has('no-check')) return;
|
|
1435
|
+
|
|
1436
|
+
const { checkAuthoring } = await import('./authoring-check.js');
|
|
1437
|
+
const errors = checkAuthoring(this);
|
|
1438
|
+
if (errors.length) {
|
|
1439
|
+
this._showAuthoringErrors(errors);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
_showAuthoringErrors(errors) {
|
|
1444
|
+
const overlay = this.shadowRoot.getElementById('authoringErrors');
|
|
1445
|
+
const content = this.shadowRoot.getElementById('authoringErrorsContent');
|
|
1446
|
+
if (!overlay || !content) return;
|
|
1447
|
+
|
|
1448
|
+
const rows = errors
|
|
1449
|
+
.map(({ element, message }) => {
|
|
1450
|
+
const path = element
|
|
1451
|
+
? element.tagName.toLowerCase() + (element.id ? `#${element.id}` : '')
|
|
1452
|
+
: '?';
|
|
1453
|
+
const safeMsg = message.replace(/</g, '<').replace(/>/g, '>');
|
|
1454
|
+
const safePath = path.replace(/</g, '<').replace(/>/g, '>');
|
|
1455
|
+
return `<tr><td>${safePath}</td><td>${safeMsg}</td></tr>`;
|
|
1456
|
+
})
|
|
1457
|
+
.join('');
|
|
1458
|
+
|
|
1459
|
+
content.innerHTML = `
|
|
1460
|
+
<table>
|
|
1461
|
+
<thead><tr><th>Element</th><th>Problem</th></tr></thead>
|
|
1462
|
+
<tbody>${rows}</tbody>
|
|
1463
|
+
</table>`;
|
|
1464
|
+
|
|
1465
|
+
overlay.classList.add('show');
|
|
1274
1466
|
}
|
|
1275
1467
|
|
|
1276
1468
|
/**
|
|
@@ -1376,28 +1568,189 @@ export class FxFore extends HTMLElement {
|
|
|
1376
1568
|
// Insert after the previous control
|
|
1377
1569
|
return referenceNode;
|
|
1378
1570
|
}
|
|
1379
|
-
|
|
1380
1571
|
/**
|
|
1381
1572
|
* @param {HTMLElement} root The root of the data initialization. fx-repeat overrides this when it makes new repeat items
|
|
1382
|
-
*
|
|
1383
1573
|
*/
|
|
1384
1574
|
initData(root = this) {
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1575
|
+
/**
|
|
1576
|
+
* @param {*} value
|
|
1577
|
+
* @returns {boolean}
|
|
1578
|
+
*/
|
|
1579
|
+
const isObjectLike = value =>
|
|
1580
|
+
value !== null && (typeof value === 'object' || typeof value === 'function');
|
|
1581
|
+
|
|
1582
|
+
/**
|
|
1583
|
+
* @param {*} nodeset
|
|
1584
|
+
* @returns {boolean}
|
|
1585
|
+
*/
|
|
1586
|
+
const hasResolvedNodeset = nodeset => {
|
|
1587
|
+
if (nodeset == null) return false;
|
|
1588
|
+
|
|
1589
|
+
// Atomic values like strings/numbers/booleans are valid resolved XPath results
|
|
1590
|
+
if (!isObjectLike(nodeset)) return true;
|
|
1591
|
+
|
|
1592
|
+
if (Array.isArray(nodeset)) return nodeset.length > 0;
|
|
1593
|
+
|
|
1594
|
+
if (typeof nodeset.length === 'number' && !('nodeType' in nodeset)) {
|
|
1595
|
+
return nodeset.length > 0;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
if (typeof nodeset[Symbol.iterator] === 'function' && !('nodeType' in nodeset)) {
|
|
1599
|
+
for (const item of nodeset) {
|
|
1600
|
+
return item !== undefined;
|
|
1601
|
+
}
|
|
1602
|
+
return false;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
return !!nodeset.nodeType;
|
|
1606
|
+
};
|
|
1607
|
+
|
|
1608
|
+
/**
|
|
1609
|
+
* Only try create-nodes for path-like refs, not general expressions like sequences.
|
|
1610
|
+
* @param {string} ref
|
|
1611
|
+
* @returns {boolean}
|
|
1612
|
+
*/
|
|
1613
|
+
const isCreateNodesCandidate = ref => {
|
|
1614
|
+
const expr = String(ref || '').trim();
|
|
1615
|
+
if (!expr || expr === '.') return false;
|
|
1616
|
+
if (expr.startsWith('"') || expr.startsWith("'")) return false;
|
|
1617
|
+
|
|
1618
|
+
// Ignore only simple literal sequences like ('a', 'b', 'c') or (1, 2, 3).
|
|
1619
|
+
// Keep fx-repeat refs that are real path expressions or more complex XPath.
|
|
1620
|
+
const simpleSequencePattern =
|
|
1621
|
+
/^\(\s*(?:(?:'[^']*'|"[^"]*"|\d+(?:\.\d+)?|\.)(?:\s*,\s*(?:'[^']*'|"[^"]*"|\d+(?:\.\d+)?|\.))*)?\s*\)$/;
|
|
1622
|
+
if (simpleSequencePattern.test(expr)) return false;
|
|
1623
|
+
|
|
1624
|
+
return true;
|
|
1625
|
+
};
|
|
1626
|
+
|
|
1627
|
+
/**
|
|
1628
|
+
* Detect whether a ref ends in an attribute step.
|
|
1629
|
+
* @param {string} ref
|
|
1630
|
+
* @returns {boolean}
|
|
1631
|
+
*/
|
|
1632
|
+
const isAttributeRef = ref => /(^|\/)\s*@/.test(String(ref || '').trim());
|
|
1633
|
+
|
|
1634
|
+
/**
|
|
1635
|
+
* Normalize a possibly sequence-like nodeset/context to a single DOM node.
|
|
1636
|
+
* @param {*} candidate
|
|
1637
|
+
* @returns {*}
|
|
1638
|
+
*/
|
|
1639
|
+
const firstNode = candidate => {
|
|
1640
|
+
if (!candidate) return null;
|
|
1641
|
+
if (!isObjectLike(candidate)) return null;
|
|
1642
|
+
if (candidate.nodeType) return candidate;
|
|
1643
|
+
|
|
1644
|
+
if (Array.isArray(candidate)) {
|
|
1645
|
+
return candidate.find(item => item && isObjectLike(item) && item.nodeType) || null;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
if (typeof candidate.length === 'number' && typeof candidate.item === 'function') {
|
|
1649
|
+
for (let i = 0; i < candidate.length; i += 1) {
|
|
1650
|
+
const item = candidate.item(i);
|
|
1651
|
+
if (item && isObjectLike(item) && item.nodeType) return item;
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
return null;
|
|
1656
|
+
};
|
|
1657
|
+
|
|
1658
|
+
/**
|
|
1659
|
+
* Detect XPath results that are sequences of atomic values rather than DOM nodes.
|
|
1660
|
+
* These are valid resolved results for repeats, but they must never trigger create-nodes.
|
|
1661
|
+
* @param {*} candidate
|
|
1662
|
+
* @returns {boolean}
|
|
1663
|
+
*/
|
|
1664
|
+
const isAtomicSequence = candidate => {
|
|
1665
|
+
if (!candidate) return false;
|
|
1666
|
+
|
|
1667
|
+
// A single primitive value is also atomic from our perspective
|
|
1668
|
+
if (!isObjectLike(candidate)) return true;
|
|
1669
|
+
|
|
1670
|
+
if (candidate.nodeType) return false;
|
|
1671
|
+
|
|
1672
|
+
if (Array.isArray(candidate)) {
|
|
1673
|
+
return (
|
|
1674
|
+
candidate.length > 0 &&
|
|
1675
|
+
!candidate.some(item => item && isObjectLike(item) && item.nodeType)
|
|
1676
|
+
);
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
if (typeof candidate.length === 'number' && typeof candidate.item === 'function') {
|
|
1680
|
+
return false;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
if (typeof candidate[Symbol.iterator] === 'function') {
|
|
1684
|
+
for (const item of candidate) {
|
|
1685
|
+
return !(item && isObjectLike(item) && item.nodeType);
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
return false;
|
|
1690
|
+
};
|
|
1691
|
+
|
|
1692
|
+
/**
|
|
1693
|
+
* Check whether a bound element is resolved after evalInContext.
|
|
1694
|
+
* Attribute refs often expose an empty string as nodeset, so use the model item node in that case.
|
|
1695
|
+
* @param {import('./ForeElementMixin.js').default} bound
|
|
1696
|
+
* @returns {boolean}
|
|
1697
|
+
*/
|
|
1698
|
+
const isResolvedBound = bound => {
|
|
1699
|
+
if (hasResolvedNodeset(bound.nodeset)) return true;
|
|
1700
|
+
if (bound.nodeName === 'FX-REPEAT' && isAtomicSequence(bound.nodeset)) return true;
|
|
1701
|
+
if (isAttributeRef(bound.ref)) {
|
|
1702
|
+
const modelItem = typeof bound.getModelItem === 'function' ? bound.getModelItem() : null;
|
|
1703
|
+
return !!modelItem?.node;
|
|
1704
|
+
}
|
|
1705
|
+
return false;
|
|
1706
|
+
};
|
|
1707
|
+
|
|
1708
|
+
/**
|
|
1709
|
+
* Determine the best context node for lazy node creation.
|
|
1710
|
+
* Prefer the bound element's in-scope context, but fall back to a structural parent.
|
|
1711
|
+
* @param {import('./ForeElementMixin.js').default} bound
|
|
1712
|
+
* @param {*} fallback
|
|
1713
|
+
* @returns {*}
|
|
1714
|
+
*/
|
|
1715
|
+
const getCreationContext = (bound, fallback) => {
|
|
1716
|
+
const direct =
|
|
1717
|
+
typeof bound.getInScopeContext === 'function' ? firstNode(bound.getInScopeContext()) : null;
|
|
1718
|
+
if (direct) return direct;
|
|
1719
|
+
|
|
1720
|
+
const dotCtx = firstNode(getInScopeContext(bound, '.'));
|
|
1721
|
+
if (dotCtx) return dotCtx;
|
|
1722
|
+
|
|
1723
|
+
const refCtx = firstNode(getInScopeContext(bound, bound.ref));
|
|
1724
|
+
if (refCtx) return refCtx;
|
|
1725
|
+
|
|
1726
|
+
return firstNode(fallback);
|
|
1727
|
+
};
|
|
1388
1728
|
|
|
1389
1729
|
/**
|
|
1390
1730
|
* @type {import('./ForeElementMixin.js').default[]}
|
|
1391
1731
|
*/
|
|
1392
1732
|
const boundControls = Array.from(
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
)
|
|
1397
|
-
|
|
1733
|
+
root.querySelectorAll(
|
|
1734
|
+
'fx-control[ref],fx-upload[ref],fx-group[ref],fx-repeat[ref], fx-switch[ref]',
|
|
1735
|
+
),
|
|
1736
|
+
).filter(boundEl => {
|
|
1737
|
+
if (boundEl.nodeName !== 'FX-REPEAT') return true;
|
|
1738
|
+
|
|
1739
|
+
const repeatRef = String(boundEl.getAttribute('ref') || '').trim();
|
|
1740
|
+
|
|
1741
|
+
// Any repeat whose ref is a pure parenthesized expression is not a create-nodes candidate.
|
|
1742
|
+
// Example: ('a', 'b', 'c')
|
|
1743
|
+
if (repeatRef.startsWith('(')) return false;
|
|
1744
|
+
|
|
1745
|
+
return isCreateNodesCandidate(repeatRef);
|
|
1746
|
+
});
|
|
1747
|
+
|
|
1748
|
+
if (root.matches && root.matches('fx-repeatitem') && firstNode(root.nodeset)) {
|
|
1398
1749
|
boundControls.unshift(root);
|
|
1399
1750
|
}
|
|
1751
|
+
|
|
1400
1752
|
console.log('_initData', boundControls);
|
|
1753
|
+
|
|
1401
1754
|
for (let i = 0; i < boundControls.length; i++) {
|
|
1402
1755
|
const bound = boundControls[i];
|
|
1403
1756
|
|
|
@@ -1408,11 +1761,21 @@ export class FxFore extends HTMLElement {
|
|
|
1408
1761
|
// Repeat items are dumb. They do not respond to evalInContext
|
|
1409
1762
|
bound.evalInContext();
|
|
1410
1763
|
}
|
|
1411
|
-
|
|
1412
|
-
|
|
1764
|
+
|
|
1765
|
+
if (bound.nodeName === 'FX-REPEAT' && isAtomicSequence(bound.nodeset)) {
|
|
1766
|
+
continue;
|
|
1767
|
+
}
|
|
1768
|
+
if (isResolvedBound(bound)) {
|
|
1769
|
+
continue;
|
|
1770
|
+
}
|
|
1771
|
+
if (!isCreateNodesCandidate(bound.ref)) {
|
|
1772
|
+
continue;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
// Ignore bound elements in a different form. They will be taken care of in the other form.
|
|
1776
|
+
if (bound.closest('fx-fore') !== this) {
|
|
1413
1777
|
continue;
|
|
1414
1778
|
}
|
|
1415
|
-
// console.log('Node does not exists', bound.ref);
|
|
1416
1779
|
|
|
1417
1780
|
// We need to create that node!
|
|
1418
1781
|
const previousControl = boundControls[i - 1];
|
|
@@ -1420,26 +1783,16 @@ export class FxFore extends HTMLElement {
|
|
|
1420
1783
|
// Previous control can either be an ancestor of us, or a previous node, which can be a sibling, or a child of a sibling.
|
|
1421
1784
|
// First: parent
|
|
1422
1785
|
if (previousControl && previousControl.contains(bound)) {
|
|
1423
|
-
// Parent is here.
|
|
1424
|
-
// console.log('insert into', bound, previousControl);
|
|
1425
|
-
// console.log('insert into nodeset', bound.nodeset);
|
|
1426
1786
|
/**
|
|
1427
1787
|
* @type {ParentNode}
|
|
1428
1788
|
*/
|
|
1429
|
-
const parentNodeset =
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
// const parentModelItemNode = parentModelItem.node;
|
|
1789
|
+
const parentNodeset =
|
|
1790
|
+
firstNode(previousControl.nodeset) || firstNode(root.getModel().getDefaultContext());
|
|
1791
|
+
const creationContext = getCreationContext(bound, parentNodeset);
|
|
1433
1792
|
const ref = bound.ref;
|
|
1434
|
-
// const newElement = parentModelItemNode.ownerDocument.createElement(ref);
|
|
1435
|
-
// if (parentNodeset.querySelector(`[ref="${ref}"]`)) {
|
|
1436
|
-
// console.log(`Node with ref "${ref}" already exists.`);
|
|
1437
|
-
// continue;
|
|
1438
|
-
// }
|
|
1439
1793
|
|
|
1440
|
-
const newNode = this._createNodes(ref, parentNodeset);
|
|
1441
|
-
if (!newNode) {
|
|
1442
|
-
// We could not make the node for some reason. Maybe it's something like `instance('XXX')`?
|
|
1794
|
+
const newNode = this._createNodes(ref, creationContext || parentNodeset);
|
|
1795
|
+
if (!newNode || !parentNodeset) {
|
|
1443
1796
|
continue;
|
|
1444
1797
|
}
|
|
1445
1798
|
if (newNode.nodeType === Node.ATTRIBUTE_NODE) {
|
|
@@ -1457,17 +1810,11 @@ export class FxFore extends HTMLElement {
|
|
|
1457
1810
|
// Do not try to get a bind for a nodeSET of a repeat. there are multiple.
|
|
1458
1811
|
bound.getModelItem().bind?.evalInContext();
|
|
1459
1812
|
}
|
|
1460
|
-
|
|
1461
|
-
// console.log('CREATED child', newElement);
|
|
1462
|
-
// console.log('new control evaluated to ', control.nodeset);
|
|
1463
|
-
// Done!
|
|
1464
1813
|
continue;
|
|
1465
1814
|
}
|
|
1466
|
-
|
|
1467
|
-
// console.log('control', control);
|
|
1815
|
+
|
|
1468
1816
|
// Is previousControl a sibling or a descendant of a logical sibling? Keep looking backwards until we share parents!
|
|
1469
|
-
|
|
1470
|
-
// console.log('ourParent', ourParent);
|
|
1817
|
+
const ourParent = XPathUtil.getParentBindingElement(bound);
|
|
1471
1818
|
let siblingControl = null;
|
|
1472
1819
|
|
|
1473
1820
|
for (let j = i - 1; j > 0; --j) {
|
|
@@ -1480,11 +1827,7 @@ export class FxFore extends HTMLElement {
|
|
|
1480
1827
|
break;
|
|
1481
1828
|
}
|
|
1482
1829
|
}
|
|
1483
|
-
|
|
1484
|
-
// console.log('No sibling found for', bound);
|
|
1485
|
-
}
|
|
1486
|
-
// console.log('sibling', siblingControl);
|
|
1487
|
-
// todo: review: should this not just be inscopeContext?
|
|
1830
|
+
|
|
1488
1831
|
let parentNodeset;
|
|
1489
1832
|
if (!ourParent || !ourParent.nodeset) {
|
|
1490
1833
|
/*
|
|
@@ -1493,13 +1836,13 @@ export class FxFore extends HTMLElement {
|
|
|
1493
1836
|
*/
|
|
1494
1837
|
parentNodeset = root.getModel().getDefaultContext();
|
|
1495
1838
|
} else {
|
|
1496
|
-
parentNodeset = ourParent.nodeset;
|
|
1839
|
+
parentNodeset = firstNode(ourParent.nodeset) || root.getModel().getDefaultContext();
|
|
1497
1840
|
}
|
|
1498
1841
|
const ref = bound.ref;
|
|
1842
|
+
const creationContext = getCreationContext(bound, parentNodeset);
|
|
1499
1843
|
|
|
1500
|
-
const newNode = this._createNodes(ref, parentNodeset);
|
|
1844
|
+
const newNode = this._createNodes(ref, creationContext || parentNodeset);
|
|
1501
1845
|
if (!newNode) {
|
|
1502
|
-
// We could not make the node for some reason. Maybe it's something like `instance('XXX')`?
|
|
1503
1846
|
continue;
|
|
1504
1847
|
}
|
|
1505
1848
|
|
|
@@ -1507,13 +1850,12 @@ export class FxFore extends HTMLElement {
|
|
|
1507
1850
|
parentNodeset.setAttributeNode(newNode);
|
|
1508
1851
|
} else {
|
|
1509
1852
|
let referenceNode = this._findReferenceNodeForNewElement(
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1853
|
+
newNode,
|
|
1854
|
+
parentNodeset,
|
|
1855
|
+
siblingControl,
|
|
1513
1856
|
);
|
|
1514
1857
|
|
|
1515
1858
|
if (referenceNode) {
|
|
1516
|
-
// console.log('insert after', referenceNode,newNode);
|
|
1517
1859
|
if (referenceNode.nodeType === Node.DOCUMENT_NODE) {
|
|
1518
1860
|
referenceNode.firstElementChild.append(newNode);
|
|
1519
1861
|
} else {
|
|
@@ -1524,50 +1866,135 @@ export class FxFore extends HTMLElement {
|
|
|
1524
1866
|
}
|
|
1525
1867
|
}
|
|
1526
1868
|
|
|
1527
|
-
/*
|
|
1528
|
-
console.log('control inscope', control.getInScopeContext());
|
|
1529
|
-
console.log('control ref', control.ref);
|
|
1530
|
-
console.log('control new element parent', newElement.parentNode.nodeName);
|
|
1531
|
-
*/
|
|
1532
|
-
|
|
1533
1869
|
bound.evalInContext();
|
|
1534
1870
|
bound.getModelItem().bind?.evalInContext();
|
|
1535
1871
|
|
|
1536
|
-
if (!bound
|
|
1537
|
-
|
|
1872
|
+
if (!isResolvedBound(bound)) {
|
|
1873
|
+
console.warn('create-nodes: could not resolve bound after node creation, skipping', bound);
|
|
1874
|
+
continue;
|
|
1538
1875
|
}
|
|
1539
|
-
// console.log('new control evaluated to ', control.nodeset);
|
|
1540
|
-
// console.log('CREATED sibling', newElement);
|
|
1541
1876
|
}
|
|
1542
|
-
// console.log('DATA', this.getModel().getDefaultContext());
|
|
1543
1877
|
}
|
|
1544
|
-
|
|
1545
1878
|
_createNodes(ref, referenceNode) {
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
}
|
|
1554
|
-
console.log(`creating new node for ref: ${ref}`);
|
|
1555
|
-
*/
|
|
1556
|
-
if (/instance\([^\)]*\)/.test(ref)) {
|
|
1557
|
-
// This is an absolute path for some instance. Not supporteed for now
|
|
1879
|
+
if (!ref || !referenceNode) return null;
|
|
1880
|
+
|
|
1881
|
+
const xpath = String(ref).trim();
|
|
1882
|
+
if (!xpath || xpath === '.') return null;
|
|
1883
|
+
|
|
1884
|
+
if (/^instance\([^\)]*\)/.test(xpath)) {
|
|
1885
|
+
// This is an absolute path for some instance. Not supported for create-nodes here.
|
|
1558
1886
|
return null;
|
|
1559
1887
|
}
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1888
|
+
|
|
1889
|
+
const ownerDoc =
|
|
1890
|
+
referenceNode.nodeType === Node.DOCUMENT_NODE ? referenceNode : referenceNode.ownerDocument;
|
|
1891
|
+
|
|
1892
|
+
const baseElement =
|
|
1893
|
+
referenceNode.nodeType === Node.DOCUMENT_NODE
|
|
1894
|
+
? referenceNode.documentElement
|
|
1895
|
+
: referenceNode.nodeType === Node.ATTRIBUTE_NODE
|
|
1896
|
+
? referenceNode.ownerElement
|
|
1897
|
+
: referenceNode;
|
|
1898
|
+
|
|
1899
|
+
if (!ownerDoc) return null;
|
|
1900
|
+
|
|
1901
|
+
const baseNamespace = baseElement?.namespaceURI || null;
|
|
1902
|
+
const namespaceResolver = createNamespaceResolver(xpath, this);
|
|
1903
|
+
|
|
1904
|
+
const parseName = token => {
|
|
1905
|
+
const raw = token.trim();
|
|
1906
|
+
|
|
1907
|
+
if (raw.startsWith('@')) {
|
|
1908
|
+
const attrToken = raw.slice(1);
|
|
1909
|
+
if (attrToken.startsWith('*:')) {
|
|
1910
|
+
return { isAttribute: true, namespaceURI: null, localName: attrToken.substring(2) };
|
|
1911
|
+
}
|
|
1912
|
+
if (attrToken.includes(':')) {
|
|
1913
|
+
const [prefix, localName] = attrToken.split(':');
|
|
1914
|
+
return {
|
|
1915
|
+
isAttribute: true,
|
|
1916
|
+
namespaceURI: prefix === '*' ? null : namespaceResolver(prefix) || null,
|
|
1917
|
+
localName,
|
|
1918
|
+
};
|
|
1919
|
+
}
|
|
1920
|
+
return { isAttribute: true, namespaceURI: null, localName: attrToken };
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
if (raw.startsWith('*:')) {
|
|
1924
|
+
return { isAttribute: false, namespaceURI: baseNamespace, localName: raw.substring(2) };
|
|
1925
|
+
}
|
|
1926
|
+
if (raw.includes(':')) {
|
|
1927
|
+
const [prefix, localName] = raw.split(':');
|
|
1928
|
+
return {
|
|
1929
|
+
isAttribute: false,
|
|
1930
|
+
namespaceURI: prefix === '*' ? baseNamespace : namespaceResolver(prefix) || baseNamespace,
|
|
1931
|
+
localName,
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
return { isAttribute: false, namespaceURI: baseNamespace, localName: raw };
|
|
1935
|
+
};
|
|
1936
|
+
|
|
1937
|
+
const parseStep = step => {
|
|
1938
|
+
const trimmed = step.trim();
|
|
1939
|
+
const nameMatch = trimmed.match(/^([^\[]+)/);
|
|
1940
|
+
const token = nameMatch ? nameMatch[1].trim() : trimmed;
|
|
1941
|
+
const predicates = [];
|
|
1942
|
+
|
|
1943
|
+
const predicateRegex = /\[\s*@([^\]\s=]+)\s*=\s*(['"])(.*?)\2\s*\]/g;
|
|
1944
|
+
let match;
|
|
1945
|
+
while ((match = predicateRegex.exec(trimmed)) !== null) {
|
|
1946
|
+
predicates.push({ name: match[1], value: match[3] });
|
|
1947
|
+
}
|
|
1948
|
+
return { token, predicates };
|
|
1949
|
+
};
|
|
1950
|
+
|
|
1951
|
+
const steps = xpath
|
|
1952
|
+
.split('/')
|
|
1953
|
+
.map(step => step.trim())
|
|
1954
|
+
.filter(step => step && step !== '.');
|
|
1955
|
+
|
|
1956
|
+
if (!steps.length) return null;
|
|
1957
|
+
|
|
1958
|
+
let subtreeRoot = null;
|
|
1959
|
+
let current = null;
|
|
1960
|
+
|
|
1961
|
+
for (const rawStep of steps) {
|
|
1962
|
+
const { token, predicates } = parseStep(rawStep);
|
|
1963
|
+
if (!token || token === '.') {
|
|
1964
|
+
continue;
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
const parsed = parseName(token);
|
|
1968
|
+
|
|
1969
|
+
if (parsed.isAttribute) {
|
|
1970
|
+
if (!current) {
|
|
1971
|
+
const attr = ownerDoc.createAttribute(parsed.localName);
|
|
1972
|
+
return attr;
|
|
1973
|
+
}
|
|
1974
|
+
current.setAttribute(parsed.localName, '');
|
|
1975
|
+
continue;
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
const element = parsed.namespaceURI
|
|
1979
|
+
? ownerDoc.createElementNS(parsed.namespaceURI, parsed.localName)
|
|
1980
|
+
: ownerDoc.createElement(parsed.localName);
|
|
1981
|
+
|
|
1982
|
+
for (const predicate of predicates) {
|
|
1983
|
+
const attrName = predicate.name.includes(':')
|
|
1984
|
+
? predicate.name.split(':')[1]
|
|
1985
|
+
: predicate.name;
|
|
1986
|
+
element.setAttribute(attrName, predicate.value);
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
if (!subtreeRoot) {
|
|
1990
|
+
subtreeRoot = element;
|
|
1991
|
+
} else {
|
|
1992
|
+
current.appendChild(element);
|
|
1993
|
+
}
|
|
1994
|
+
current = element;
|
|
1570
1995
|
}
|
|
1996
|
+
|
|
1997
|
+
return subtreeRoot;
|
|
1571
1998
|
}
|
|
1572
1999
|
|
|
1573
2000
|
_handleDragStart(event) {
|
|
@@ -1649,16 +2076,23 @@ export class FxFore extends HTMLElement {
|
|
|
1649
2076
|
}
|
|
1650
2077
|
|
|
1651
2078
|
_logError(e) {
|
|
2079
|
+
// Prevent the error event from bubbling up and potentially triggering
|
|
2080
|
+
// parent error handlers that might call refresh() again
|
|
1652
2081
|
e.stopPropagation();
|
|
2082
|
+
e.stopImmediatePropagation(); // Added to stop other listeners on this element
|
|
1653
2083
|
e.preventDefault();
|
|
1654
2084
|
|
|
1655
2085
|
console.error('ERROR', e.detail.message);
|
|
1656
|
-
|
|
1657
|
-
if
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
2086
|
+
|
|
2087
|
+
// Guard the display logic: if showing the error causes another error,
|
|
2088
|
+
// we must break the cycle.
|
|
2089
|
+
if (this.strict && !this._isLogging) {
|
|
2090
|
+
this._isLogging = true;
|
|
2091
|
+
try {
|
|
2092
|
+
this._displayError(e);
|
|
2093
|
+
} finally {
|
|
2094
|
+
this._isLogging = false;
|
|
2095
|
+
}
|
|
1662
2096
|
}
|
|
1663
2097
|
}
|
|
1664
2098
|
|