Skip to content

Commit

Permalink
feat(common): introduce ng-observe component
Browse files Browse the repository at this point in the history
  • Loading branch information
trotyl committed Sep 30, 2019
1 parent c3f0fd1 commit b068da4
Show file tree
Hide file tree
Showing 6 changed files with 369 additions and 0 deletions.
38 changes: 38 additions & 0 deletions projects/common/src/ng-observe/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# NgObserve

Helper component to retrieve the projected content.

## Type

**Component**

## Provenance

+ https://github.com/angular/angular/issues/29395

## NgModule

`@angular-contrib/core#ContribNgObserveModule`

## Usage

```typescript
@Component({
template: `
<!-- As Host -->
<ng-observe (contentChange)="onContentChange($event)">
<ng-content></ng-content>
</ng-observe>
<!-- As Marker -->
<ng-observe start (contentChange)="onContentChange($event)"></ng-observe>
<ng-content></ng-content>
<ng-observe end></ng-observe>
`
})
class MyComponent {
onContentChange(content: Node[]) {
console.log(content);
}
}
```
2 changes: 2 additions & 0 deletions projects/common/src/ng-observe/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './ng-observe';
export * from './ng-observe.module';
16 changes: 16 additions & 0 deletions projects/common/src/ng-observe/ng-observe.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core';
import { NgObserve, NgObserveStart, NgObserveEnd } from './ng-observe';
import { ContribRenderExtensionModule } from '@angular-contrib/core';

@NgModule({
declarations: [
NgObserve, NgObserveStart, NgObserveEnd,
],
imports: [
ContribRenderExtensionModule,
],
exports: [
NgObserve, NgObserveStart, NgObserveEnd,
],
})
export class ContribNgObserveModule {}
155 changes: 155 additions & 0 deletions projects/common/src/ng-observe/ng-observe.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { CommonModule } from '@angular/common';
import { Component, ViewChild } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ContribNgObserveModule } from './ng-observe.module';
import { NgObserve } from './ng-observe';
import { By } from '@angular/platform-browser';

describe('ng-observe', () => {
let fixture: ComponentFixture<TestComponent>;
let component: TestComponent;

beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [
ObserveChildrenComponent,
ObserveMultiChildrenComponent,
ObserveRangeComponent,
ObserveMultiRangeComponent,
TestComponent,
],
imports: [CommonModule, ContribNgObserveModule],
}).compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
});

it('should get observed content', () => {
fixture.detectChanges();

const c1 = fixture.debugElement.query(By.directive(ObserveChildrenComponent)).injector.get(ObserveChildrenComponent);
expect(c1.observe.content.length).toBe(1);
expect(c1.observe.content[0].textContent!.trim()).toBe(`Content`);
expect(c1.events.length).toBe(1);

const c2 = fixture.debugElement.query(By.directive(ObserveMultiChildrenComponent)).injector.get(ObserveMultiChildrenComponent);
expect(c2.first.content.length).toBe(1);
expect(c2.first.content[0].textContent!.trim()).toBe(`First`);
expect(c2.second.content.length).toBe(1);
expect(c2.second.content[0].textContent!.trim()).toBe(`Second`);
expect(c2.firstEvents.length).toBe(1);
expect(c2.secondEvents.length).toBe(1);

const c3 = fixture.debugElement.query(By.directive(ObserveRangeComponent)).injector.get(ObserveRangeComponent);
expect(c3.observe.content.length).toBe(1);
expect(c3.observe.content[0].textContent!.trim()).toBe(`Content`);
expect(c3.events.length).toBe(1);

const c4 = fixture.debugElement.query(By.directive(ObserveMultiRangeComponent)).injector.get(ObserveMultiRangeComponent);
expect(c4.first.content.length).toBe(1);
expect(c4.first.content[0].textContent!.trim()).toBe(`First`);
expect(c4.second.content.length).toBe(1);
expect(c4.second.content[0].textContent!.trim()).toBe(`Second`);
expect(c4.firstEvents.length).toBe(1);
expect(c4.secondEvents.length).toBe(1);
});
});

@Component({
selector: 'test-observe-children',
template: `
Start
<ng-observe (contentChange)="events.push($event)">
<ng-content></ng-content>
</ng-observe>
End
`,
})
class ObserveChildrenComponent {
@ViewChild(NgObserve, { static: true }) observe!: NgObserve;

events: Node[] = [];
}

@Component({
selector: 'test-observe-multi-children',
template: `
Start
<ng-observe #first (contentChange)="firstEvents.push($event)">
<ng-content select=".first"></ng-content>
</ng-observe>
Middle
<ng-observe #second (contentChange)="secondEvents.push($event)">
<ng-content select=".second"></ng-content>
</ng-observe>
End
`,
})
class ObserveMultiChildrenComponent {
@ViewChild('first', { static: true }) first!: NgObserve;
@ViewChild('second', { static: true }) second!: NgObserve;

firstEvents: Node[] = [];
secondEvents: Node[] = [];
}

@Component({
selector: 'test-observe-range',
template: `
Start
<ng-observe start (contentChange)="events.push($event)"></ng-observe>
<ng-content></ng-content>
<ng-observe end></ng-observe>
End
`,
})
class ObserveRangeComponent {
@ViewChild(NgObserve, { static: true }) observe!: NgObserve;

events: Node[] = [];
}

@Component({
selector: 'test-observe-multi-range',
template: `
Start
<ng-observe start #first (contentChange)="firstEvents.push($event)"></ng-observe>
<ng-content select=".first"></ng-content>
<ng-observe end></ng-observe>
Middle
<ng-observe start #second (contentChange)="secondEvents.push($event)"></ng-observe>
<ng-content select=".second"></ng-content>
<ng-observe end></ng-observe>
End
`,
})
class ObserveMultiRangeComponent {
@ViewChild('first', { static: true }) first!: NgObserve;
@ViewChild('second', { static: true }) second!: NgObserve;

firstEvents: Node[] = [];
secondEvents: Node[] = [];
}

@Component({
template: `
<test-observe-children>
Content
</test-observe-children>
<test-observe-multi-children>
<span class="first">First</span>
<span class="second">Second</span>
</test-observe-multi-children>
<test-observe-range>
Content
</test-observe-range>
<test-observe-multi-range>
<span class="first">First</span>
<span class="second">Second</span>
</test-observe-multi-range>
`,
})
class TestComponent {}
157 changes: 157 additions & 0 deletions projects/common/src/ng-observe/ng-observe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { Directive, forwardRef, ElementRef, Injectable, OnInit, OnDestroy, Renderer2, Component, Output, EventEmitter } from '@angular/core';
import { RenderInterceptor } from '@angular-contrib/core';

const observers = new WeakMap<Node, () => void>();
const observeStarts = new WeakMap<Node, Node>();
const observeEnds = new WeakMap<Node, Node>();

function differentInArray<T>(arr1: T[], arr2: T[]): boolean {
if (arr1.length !== arr2.length) {
return true;
}

for (let i = 0; i < arr1.length; i++) {
if (arr1[i] !== arr2[i]) {
return true;
}
}

return false;
}

@Injectable()
export class ObserveContentInterceptor implements RenderInterceptor {
appendChild(parent: Node, newChild: Node, renderer: Renderer2): void {
renderer.appendChild(parent, newChild);

const observer = observers.get(parent);
if (observer != null) {
observer();
}
}

insertBefore(parent: Node, newChild: Node, refChild: Node, renderer: Renderer2): void {
renderer.insertBefore(parent, newChild, refChild);

const observer = observers.get(parent);
if (observer != null) {
observer();
}
}

removeChild(parent: Node, oldChild: Node, isHostElement: boolean | undefined, renderer: Renderer2): void {
renderer.removeChild(parent, oldChild, isHostElement);

const observer = observers.get(parent);
if (observer != null) {
observer();
}
}
}

@Component({
selector: 'ng-observe:not([start]):not([end])',
template: `<ng-content></ng-content>`,
})
export class NgObserve implements OnInit, OnDestroy {
@Output() contentChange = new EventEmitter();

content: Node[] = [];

constructor(
private host: ElementRef,
private renderer: Renderer2,
) {}

ngOnInit(): void {
this.updateContent();
observers.set(this.host.nativeElement, () => this.updateContent());
}

ngOnDestroy(): void {
observers.delete(this.host.nativeElement);
}

updateContent(): void {
const el = this.host.nativeElement as HTMLElement;
this.content = Array.from(this.renderer.childNodes(el));
this.contentChange.emit(this.content);
}
}

@Component({
selector: 'ng-observe[start]',
template: `<ng-content></ng-content>`,
providers: [
{ provide: NgObserve, useExisting: forwardRef(() => NgObserveStart) }
],
})
export class NgObserveStart implements OnInit, OnDestroy {
@Output() contentChange = new EventEmitter();

content: Node[] = [];
parent: Node;

constructor(
private host: ElementRef,
private renderer: Renderer2,
) {
const parent = renderer.parentNode(host.nativeElement);
this.parent = parent;
observeStarts.set(parent, this.host.nativeElement);
}

ngOnInit(): void {
this.updateContent();
observers.set(this.parent, () => this.updateContent());
}

ngOnDestroy(): void {
observers.delete(this.host.nativeElement);
}

updateContent(): void {
const childNodes = this.renderer.childNodes(this.parent);
const start = this.host.nativeElement;
const end = observeEnds.get(start);
const content: Node[] = [];
let postStart = false;

for (let i = 0; i < childNodes.length; i++) {
const childNode = childNodes[i];
if (childNode === start) {
postStart = true;
continue;
}
if (childNode === end) {
break;
}
if (postStart) {
content.push(childNode);
}
}

if (differentInArray(content, this.content)) {
this.content = content;
this.contentChange.emit(this.content);
}
}
}

@Directive({
selector: 'ng-observe[end]'
})
export class NgObserveEnd {
constructor(
host: ElementRef,
renderer: Renderer2,
) {
const parent = renderer.parentNode(host.nativeElement) as Node;
const start = observeStarts.get(parent);

if (start != null) {
observeStarts.delete(parent);
observeEnds.set(start, host.nativeElement);
}
}
}
1 change: 1 addition & 0 deletions projects/common/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export * from './ng-host/index';
export * from './ng-init/index';
export * from './ng-let/index';
export * from './ng-no-check/index';
export * from './ng-observe/index';
export * from './ng-switch-continue/index';

0 comments on commit b068da4

Please sign in to comment.