599 lines
23 KiB
TypeScript
599 lines
23 KiB
TypeScript
import { IComponent, effect, EffectRef, signal, WritableSignal } from "../index";
|
|
import { WebComponent } from "./web-component";
|
|
|
|
interface NgContainerMarker {
|
|
startMarker: Comment;
|
|
endMarker: Comment;
|
|
condition?: string;
|
|
originalTemplate: string;
|
|
ngForExpression?: string;
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElement {
|
|
templateFragment?: TemplateFragment;
|
|
component?: IComponent;
|
|
template?: string;
|
|
originalContent?: DocumentFragment;
|
|
__inputs?: Record<string, WritableSignal<any>>;
|
|
__quarcContext?: Record<string, any>;
|
|
__effects?: EffectRef[];
|
|
}
|
|
}
|
|
|
|
export class TemplateFragment {
|
|
public container: HTMLElement;
|
|
public component: IComponent;
|
|
public template: string;
|
|
public originalContent: DocumentFragment;
|
|
private ngContainerMarkers: NgContainerMarker[] = [];
|
|
private currentContext: any = null;
|
|
|
|
constructor(
|
|
container: HTMLElement,
|
|
component: IComponent,
|
|
template?: string,
|
|
) {
|
|
this.container = container;
|
|
this.component = component;
|
|
this.template = template ?? '';
|
|
this.originalContent = document.createDocumentFragment();
|
|
|
|
while (container.firstChild) {
|
|
this.originalContent.appendChild(container.firstChild);
|
|
}
|
|
|
|
container.templateFragment = this;
|
|
container.component = component;
|
|
container.template = this.template;
|
|
container.originalContent = this.originalContent;
|
|
}
|
|
|
|
render(): void {
|
|
if (!this.template) return;
|
|
|
|
const templateElement = document.createElement('template');
|
|
templateElement.innerHTML = this.template;
|
|
|
|
const renderedContent = templateElement.content.cloneNode(true) as DocumentFragment;
|
|
|
|
// Process structural directives before appending
|
|
this.processStructuralDirectives(renderedContent);
|
|
|
|
// Process property bindings BEFORE adding elements to DOM
|
|
// This ensures __inputs is set before child component's connectedCallback runs
|
|
const tempContainer = document.createElement('div');
|
|
while (renderedContent.firstChild) {
|
|
tempContainer.appendChild(renderedContent.firstChild);
|
|
}
|
|
this.processPropertyBindings(tempContainer);
|
|
|
|
while (tempContainer.firstChild) {
|
|
this.container.appendChild(tempContainer.firstChild);
|
|
}
|
|
}
|
|
|
|
private processStructuralDirectives(fragment: DocumentFragment): void {
|
|
this.processSelectFor(fragment);
|
|
|
|
const ngContainers = Array.from(fragment.querySelectorAll('ng-container'));
|
|
for (const c of ngContainers) {
|
|
this.processNgContainer(c as HTMLElement);
|
|
}
|
|
}
|
|
|
|
private processSelectFor(fragment: DocumentFragment): void {
|
|
for (const s of Array.from(fragment.querySelectorAll('select,optgroup'))) {
|
|
const w = document.createTreeWalker(s, NodeFilter.SHOW_COMMENT);
|
|
const m: Comment[] = [];
|
|
let n;
|
|
while ((n = w.nextNode())) {
|
|
if ((n.textContent || '').startsWith('F:')) m.push(n as Comment);
|
|
}
|
|
for (const c of m) this.expandFor(s as HTMLElement, c);
|
|
}
|
|
}
|
|
|
|
private expandFor(p: HTMLElement, m: Comment): void {
|
|
const [, v, e] = (m.textContent || '').split(':');
|
|
const t: HTMLElement[] = [];
|
|
let c: Node | null = m.nextSibling;
|
|
while (c && !(c.nodeType === 8 && c.textContent === '/F')) {
|
|
if (c.nodeType === 1) t.push(c as HTMLElement);
|
|
c = c.nextSibling;
|
|
}
|
|
if (!t.length) return;
|
|
try {
|
|
const items = this.evaluateExpression(e);
|
|
if (!items) return;
|
|
for (const i of Array.isArray(items) ? items : Object.values(items)) {
|
|
for (const el of t) {
|
|
const cl = el.cloneNode(true) as HTMLElement;
|
|
cl.__quarcContext = { [v]: i };
|
|
p.insertBefore(cl, m);
|
|
}
|
|
}
|
|
t.forEach(x => x.remove());
|
|
m.remove();
|
|
c?.parentNode?.removeChild(c);
|
|
} catch {}
|
|
}
|
|
|
|
private processNgContainer(ngContainer: HTMLElement): void {
|
|
const ngIfAttr = ngContainer.getAttribute('*ngIf');
|
|
const ngForAttr = ngContainer.getAttribute('*ngFor');
|
|
const parent = ngContainer.parentNode;
|
|
|
|
if (!parent) return;
|
|
|
|
// Create marker comments to track ng-container position
|
|
let markerComment = 'ng-container-start';
|
|
if (ngIfAttr) markerComment += ` *ngIf="${ngIfAttr}"`;
|
|
if (ngForAttr) markerComment += ` *ngFor="${ngForAttr}"`;
|
|
|
|
const startMarker = document.createComment(markerComment);
|
|
const endMarker = document.createComment('ng-container-end');
|
|
|
|
// Store marker information for later re-rendering
|
|
const originalTemplate = ngContainer.innerHTML;
|
|
this.ngContainerMarkers.push({
|
|
startMarker,
|
|
endMarker,
|
|
condition: ngIfAttr || undefined,
|
|
originalTemplate,
|
|
ngForExpression: ngForAttr || undefined
|
|
});
|
|
|
|
parent.insertBefore(startMarker, ngContainer);
|
|
|
|
if (ngForAttr) {
|
|
// Handle *ngFor directive
|
|
this.processNgForDirective(ngContainer, ngForAttr, parent, endMarker);
|
|
} else if (ngIfAttr && !this.evaluateConditionWithContext(ngIfAttr, ngContainer.__quarcContext)) {
|
|
// Condition is false - don't render content, just add end marker
|
|
parent.insertBefore(endMarker, ngContainer);
|
|
ngContainer.remove();
|
|
} else {
|
|
// Condition is true or no condition - render content between markers
|
|
while (ngContainer.firstChild) {
|
|
parent.insertBefore(ngContainer.firstChild, ngContainer);
|
|
}
|
|
parent.insertBefore(endMarker, ngContainer);
|
|
ngContainer.remove();
|
|
}
|
|
}
|
|
|
|
|
|
private processNgForDirective(ngContainer: HTMLElement, ngForExpression: string, parent: Node, endMarker: Comment): void {
|
|
const parts = ngForExpression.split(';').map(part => part.trim());
|
|
const forPart = parts[0];
|
|
|
|
const forOfMatch = forPart.match(/^let\s+(\w+)\s+of\s+(.+)$/);
|
|
const forInMatch = forPart.match(/^let\s+(\w+)\s+in\s+(.+)$/);
|
|
|
|
const match = forOfMatch || forInMatch;
|
|
const isForIn = !!forInMatch;
|
|
|
|
if (!match) {
|
|
console.warn('Invalid ngFor expression:', ngForExpression);
|
|
parent.insertBefore(endMarker, ngContainer);
|
|
ngContainer.remove();
|
|
return;
|
|
}
|
|
|
|
const variableName = match[1];
|
|
const iterableExpression = match[2];
|
|
const loopTemplate = ngContainer.innerHTML;
|
|
const startMarker = document.createComment(`ngFor-start: ${ngForExpression}`);
|
|
const parentContext = ngContainer.__quarcContext;
|
|
|
|
parent.insertBefore(startMarker, ngContainer);
|
|
parent.insertBefore(endMarker, ngContainer);
|
|
ngContainer.remove();
|
|
|
|
const renderLoop = () => {
|
|
let current = startMarker.nextSibling;
|
|
while (current && current !== endMarker) {
|
|
const next = current.nextSibling;
|
|
if (current.nodeType === 1) {
|
|
TemplateFragment.destroyEffects(current as HTMLElement);
|
|
}
|
|
current.parentNode?.removeChild(current);
|
|
current = next;
|
|
}
|
|
|
|
try {
|
|
const iterable = this.evaluateExpressionWithContext(iterableExpression, parentContext);
|
|
if (iterable == null) return;
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
if (isForIn) {
|
|
for (const key in iterable) {
|
|
if (Object.prototype.hasOwnProperty.call(iterable, key)) {
|
|
this.renderForItem(fragment, loopTemplate, variableName, key, parentContext);
|
|
}
|
|
}
|
|
} else {
|
|
const items = Array.isArray(iterable) ? iterable : Object.values(iterable);
|
|
for (const item of items) {
|
|
this.renderForItem(fragment, loopTemplate, variableName, item, parentContext);
|
|
}
|
|
}
|
|
|
|
parent.insertBefore(fragment, endMarker);
|
|
this.reapplyDirectives();
|
|
} catch {}
|
|
};
|
|
|
|
this.registerEffect(this.container, effect(renderLoop));
|
|
}
|
|
|
|
private getWebComponent(): WebComponent | null {
|
|
let el: HTMLElement | null = this.container;
|
|
while (el) {
|
|
if (el instanceof WebComponent) {
|
|
return el;
|
|
}
|
|
el = el.parentElement;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private reapplyDirectives(): void {
|
|
const webComponent = this.getWebComponent();
|
|
if (webComponent) {
|
|
queueMicrotask(() => webComponent.applyDirectives());
|
|
}
|
|
}
|
|
|
|
private renderForItem(fragment: DocumentFragment, template: string, variableName: string, value: any, parentContext?: any): void {
|
|
const ctx = { ...parentContext, [variableName]: value };
|
|
const t = document.createElement('template');
|
|
t.innerHTML = template;
|
|
const content = t.content;
|
|
for (const el of Array.from(content.querySelectorAll('*'))) {
|
|
(el as HTMLElement).__quarcContext = ctx;
|
|
}
|
|
this.processStructuralDirectivesWithContext(content, ctx);
|
|
const tempDiv = document.createElement('div');
|
|
while (content.firstChild) {
|
|
tempDiv.appendChild(content.firstChild);
|
|
}
|
|
this.processPropertyBindings(tempDiv);
|
|
this.applyScopeAttributes(tempDiv);
|
|
while (tempDiv.firstChild) fragment.appendChild(tempDiv.firstChild);
|
|
}
|
|
|
|
private getScopeId(): string | null {
|
|
let el: HTMLElement | null = this.container;
|
|
while (el) {
|
|
for (const attr of Array.from(el.attributes)) {
|
|
if (attr.name.startsWith('_nghost-')) {
|
|
return attr.name.substring(8);
|
|
}
|
|
}
|
|
el = el.parentElement;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private applyScopeAttributes(container: HTMLElement): void {
|
|
const scopeId = this.getScopeId();
|
|
if (!scopeId) return;
|
|
const attr = `_ngcontent-${scopeId}`;
|
|
container.querySelectorAll('*').forEach(e => e.setAttribute(attr, ''));
|
|
Array.from(container.children).forEach(e => e.setAttribute(attr, ''));
|
|
}
|
|
|
|
private processStructuralDirectivesWithContext(fragment: DocumentFragment, ctx: any): void {
|
|
const ngContainers = Array.from(fragment.querySelectorAll('ng-container'));
|
|
for (const c of ngContainers) {
|
|
(c as HTMLElement).__quarcContext = ctx;
|
|
this.processNgContainer(c as HTMLElement);
|
|
}
|
|
}
|
|
|
|
private evaluateCondition(condition: string): boolean {
|
|
try {
|
|
return new Function('component', `with(component) { return ${condition}; }`)(this.component);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private evaluateConditionWithContext(condition: string, ctx?: any): boolean {
|
|
try {
|
|
const mergedContext = { ...this.component, ...(ctx || {}) };
|
|
return new Function('c', `with(c) { return ${condition}; }`)(mergedContext);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Re-renders a specific ng-container fragment based on marker position
|
|
*/
|
|
rerenderFragment(markerIndex: number): void {
|
|
if (markerIndex < 0 || markerIndex >= this.ngContainerMarkers.length) {
|
|
console.warn('Invalid marker index:', markerIndex);
|
|
return;
|
|
}
|
|
|
|
const marker = this.ngContainerMarkers[markerIndex];
|
|
const { startMarker, endMarker, condition, originalTemplate } = marker;
|
|
|
|
// Remove all nodes between markers
|
|
let currentNode = startMarker.nextSibling;
|
|
while (currentNode && currentNode !== endMarker) {
|
|
const nextNode = currentNode.nextSibling;
|
|
currentNode.remove();
|
|
currentNode = nextNode;
|
|
}
|
|
|
|
// Re-evaluate condition and render if true
|
|
if (!condition || this.evaluateCondition(condition)) {
|
|
const tempContainer = document.createElement('div');
|
|
tempContainer.innerHTML = originalTemplate;
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
while (tempContainer.firstChild) {
|
|
fragment.appendChild(tempContainer.firstChild);
|
|
}
|
|
|
|
// Process property bindings on the fragment
|
|
const tempWrapper = document.createElement('div');
|
|
tempWrapper.appendChild(fragment);
|
|
this.processPropertyBindings(tempWrapper);
|
|
|
|
// Insert processed nodes between markers
|
|
const parent = startMarker.parentNode;
|
|
if (parent) {
|
|
while (tempWrapper.firstChild) {
|
|
parent.insertBefore(tempWrapper.firstChild, endMarker);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Re-renders all ng-container fragments
|
|
*/
|
|
rerenderAllFragments(): void {
|
|
for (let i = 0; i < this.ngContainerMarkers.length; i++) {
|
|
this.rerenderFragment(i);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets all ng-container markers for inspection
|
|
*/
|
|
getFragmentMarkers(): NgContainerMarker[] {
|
|
return this.ngContainerMarkers;
|
|
}
|
|
|
|
private processPropertyBindings(container: HTMLElement | DocumentFragment): void {
|
|
const allElements = Array.from(container.querySelectorAll('*'));
|
|
|
|
for (const element of allElements) {
|
|
this.currentContext = this.buildContextForElement(element as HTMLElement);
|
|
this.processElementBindings(element as HTMLElement);
|
|
this.currentContext = null;
|
|
}
|
|
}
|
|
|
|
private buildContextForElement(el: HTMLElement): any {
|
|
const chain: Record<string, any>[] = [];
|
|
let c: HTMLElement | null = el;
|
|
while (c) {
|
|
if (c.__quarcContext) chain.unshift(c.__quarcContext);
|
|
if (c.component) break;
|
|
c = c.parentElement;
|
|
}
|
|
const ctx = Object.create(this.component);
|
|
for (const x of chain) Object.assign(ctx, x);
|
|
return ctx;
|
|
}
|
|
|
|
private processElementBindings(element: HTMLElement): void {
|
|
const attributesToRemove: string[] = [];
|
|
const attributes = Array.from(element.attributes);
|
|
|
|
for (const attr of attributes) {
|
|
if (attr.name.startsWith('(') && attr.name.endsWith(')')) {
|
|
this.processOutputBinding(element, attr.name, attr.value);
|
|
attributesToRemove.push(attr.name);
|
|
} else if (attr.name.startsWith('[') && attr.name.endsWith(']')) {
|
|
const propertyName = attr.name.slice(1, -1);
|
|
const expression = attr.value;
|
|
|
|
if (propertyName.startsWith('attr.')) {
|
|
this.processAttrBinding(element, propertyName.slice(5), expression);
|
|
} else if (propertyName.startsWith('style.')) {
|
|
this.processStyleBinding(element, propertyName.slice(6), expression);
|
|
} else if (propertyName.startsWith('class.')) {
|
|
this.processClassBinding(element, propertyName.slice(6), expression);
|
|
} else if (this.isCustomElement(element)) {
|
|
this.processInputBinding(element, propertyName, expression);
|
|
} else {
|
|
const camelCaseName = this.kebabToCamel(propertyName);
|
|
this.processDomPropertyBinding(element, camelCaseName, expression);
|
|
this.processInputBinding(element, camelCaseName, expression);
|
|
this.setInputAttribute(element, propertyName, expression);
|
|
}
|
|
|
|
attributesToRemove.push(attr.name);
|
|
} else if (attr.name === 'data-bind') {
|
|
this.processDataBind(element, attr.value);
|
|
attributesToRemove.push(attr.name);
|
|
} else if (attr.name.startsWith('data-input-')) {
|
|
const propertyName = attr.name.slice(11);
|
|
this.processInputBinding(element, propertyName, attr.value);
|
|
attributesToRemove.push(attr.name);
|
|
} else if (attr.name.startsWith('data-on-')) {
|
|
const eventName = attr.name.slice(8);
|
|
this.processDataOutputBinding(element, eventName, attr.value);
|
|
attributesToRemove.push(attr.name);
|
|
} else if (attr.name === 'data-quarc-attr-bindings') {
|
|
this.processQuarcAttrBindings(element, attr.value);
|
|
attributesToRemove.push(attr.name);
|
|
}
|
|
}
|
|
|
|
for (const attrName of attributesToRemove) {
|
|
element.removeAttribute(attrName);
|
|
}
|
|
}
|
|
|
|
private processQuarcAttrBindings(el: HTMLElement, json: string): void {
|
|
try {
|
|
const b: { attr: string; expr: string }[] = JSON.parse(json.replace(/'/g, "'").replace(/'/g, '"'));
|
|
for (const { attr, expr } of b) this.setAttr(el, attr, this.eval(expr));
|
|
} catch {}
|
|
}
|
|
|
|
private isCustomElement(element: HTMLElement): boolean {
|
|
return element.tagName.includes('-');
|
|
}
|
|
|
|
private processOutputBinding(element: HTMLElement, attrName: string, expression: string): void {
|
|
const eventName = this.camelToKebab(attrName.slice(1, -1));
|
|
this.processDataOutputBinding(element, eventName, expression);
|
|
}
|
|
|
|
private processDataOutputBinding(el: HTMLElement, ev: string, expr: string): void {
|
|
const ctx = this.currentContext ?? this.component;
|
|
el.addEventListener(ev, (e: Event) => {
|
|
try { new Function('c', '$event', `with(c){return ${expr}}`)(ctx, (e as CustomEvent).detail ?? e); } catch {}
|
|
});
|
|
}
|
|
|
|
private processDataBind(el: HTMLElement, expr: string): void {
|
|
const ctx = this.currentContext ?? this.component;
|
|
this.registerEffect(el, effect(() => {
|
|
try { el.innerHTML = String(this.evalWithContext(expr, ctx) ?? ''); } catch {}
|
|
}));
|
|
}
|
|
|
|
private processInputBinding(el: HTMLElement, prop: string, expr: string): void {
|
|
if (!el.__inputs) el.__inputs = {};
|
|
const ctx = this.currentContext ?? this.component;
|
|
const initialValue = this.evalWithContext(expr, ctx);
|
|
const s = signal<any>(initialValue);
|
|
el.__inputs[prop] = s;
|
|
this.registerEffect(el, effect(() => {
|
|
try { s.set(this.evalWithContext(expr, ctx)); } catch {}
|
|
}));
|
|
}
|
|
|
|
private processAttrBinding(el: HTMLElement, attr: string, expr: string): void {
|
|
const ctx = this.currentContext ?? this.component;
|
|
this.registerEffect(el, effect(() => {
|
|
try { this.setAttr(el, attr, this.evalWithContext(expr, ctx)); } catch {}
|
|
}));
|
|
}
|
|
|
|
private setAttr(el: HTMLElement, attr: string, v: any): void {
|
|
if (v == null || v === false) el.removeAttribute(attr);
|
|
else el.setAttribute(attr, v === true ? '' : String(v));
|
|
}
|
|
|
|
private eval(expr: string): any {
|
|
return new Function('c', `with(c){return ${expr}}`)(this.currentContext ?? this.component);
|
|
}
|
|
|
|
private evalWithContext(expr: string, ctx: any): any {
|
|
return new Function('c', `with(c){return ${expr}}`)(ctx);
|
|
}
|
|
|
|
private registerEffect(el: HTMLElement, effectRef: EffectRef): void {
|
|
if (!el.__effects) el.__effects = [];
|
|
el.__effects.push(effectRef);
|
|
}
|
|
|
|
private processStyleBinding(el: HTMLElement, prop: string, expr: string): void {
|
|
const ctx = this.currentContext ?? this.component;
|
|
const p = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
this.registerEffect(el, effect(() => {
|
|
try {
|
|
const v = this.evalWithContext(expr, ctx);
|
|
v == null || v === false ? el.style.removeProperty(p) : el.style.setProperty(p, String(v));
|
|
} catch {}
|
|
}));
|
|
}
|
|
|
|
private processClassBinding(el: HTMLElement, cls: string, expr: string): void {
|
|
const ctx = this.currentContext ?? this.component;
|
|
this.registerEffect(el, effect(() => {
|
|
try { this.evalWithContext(expr, ctx) ? el.classList.add(cls) : el.classList.remove(cls); } catch {}
|
|
}));
|
|
}
|
|
|
|
private processDomPropertyBinding(el: HTMLElement, prop: string, expr: string): void {
|
|
const m: Record<string, string> = { innerhtml: 'innerHTML', textcontent: 'textContent', innertext: 'innerText', classname: 'className' };
|
|
const ctx = this.currentContext ?? this.component;
|
|
const resolvedProp = m[prop.toLowerCase()] ?? prop;
|
|
this.registerEffect(el, effect(() => {
|
|
try { (el as any)[resolvedProp] = this.evalWithContext(expr, ctx); } catch {}
|
|
}));
|
|
}
|
|
|
|
private evaluateExpression(expr: string): any {
|
|
try { return this.eval(expr); } catch { return undefined; }
|
|
}
|
|
|
|
private evaluateExpressionWithContext(expr: string, ctx?: any): any {
|
|
try {
|
|
const mergedContext = { ...this.component, ...(ctx || {}) };
|
|
return new Function('c', `with(c){return ${expr}}`)(mergedContext);
|
|
} catch { return undefined; }
|
|
}
|
|
|
|
static getOrCreate(container: HTMLElement, component: IComponent, template?: string): TemplateFragment {
|
|
if (container.templateFragment) {
|
|
return container.templateFragment;
|
|
}
|
|
return new TemplateFragment(container, component, template);
|
|
}
|
|
|
|
static destroyEffects(container: HTMLElement): void {
|
|
const allElements = container.querySelectorAll('*');
|
|
for (const el of Array.from(allElements)) {
|
|
const htmlEl = el as HTMLElement;
|
|
if (htmlEl.__effects) {
|
|
for (const e of htmlEl.__effects) e.destroy();
|
|
htmlEl.__effects = [];
|
|
}
|
|
}
|
|
if (container.__effects) {
|
|
for (const e of container.__effects) e.destroy();
|
|
container.__effects = [];
|
|
}
|
|
}
|
|
|
|
private camelToKebab(str: string): string {
|
|
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
|
}
|
|
|
|
private kebabToCamel(str: string): string {
|
|
return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
}
|
|
|
|
private setInputAttribute(el: HTMLElement, attrName: string, expr: string): void {
|
|
const ctx = this.currentContext ?? this.component;
|
|
this.registerEffect(el, effect(() => {
|
|
try {
|
|
const value = this.evalWithContext(expr, ctx);
|
|
if (value == null || value === false) {
|
|
el.removeAttribute(attrName);
|
|
} else if (value === true) {
|
|
el.setAttribute(attrName, '');
|
|
} else if (typeof value === 'object') {
|
|
el.setAttribute(attrName, JSON.stringify(value));
|
|
} else {
|
|
el.setAttribute(attrName, String(value));
|
|
}
|
|
} catch {}
|
|
}));
|
|
}
|
|
} |