fix(aio): fix embedded ToC and improve ToC, destroying components and scroll timing (#18428)

- Fix embedded ToC:
  Previously, the element was added too late and was never instantiated.

- Improve ToC update timing:
  Previously, the ToC was updated after the entering animation was over, which
  resulted in the ToC being outdated for the duration of the animation.

- Improve destroying components timing:
  Previously, the old embedded components were destroyed as soon as a
  new document was requested. Even if the transition ended up never
  happening (e.g. due to error while preparing the new document), the
  embedded components would have been destroyed and the displayed
  document would not work as expected.
  Now the old embedded components are destroyed only after the new
  document has been fully prepared.

- Improve scroll-to-top timing:
  Previously, the page was scrolled to top after the entering animation was
  over, which resulted in "jumpi-ness". Now the scrolling happens after the
  leaving document has been removed and before the entering document has been
  inserted.

PR Close #18428
This commit is contained in:
George Kalpakas 2017-11-27 23:06:09 +02:00 committed by Jason Aden
parent 1539cd8819
commit 94e2ea7361
6 changed files with 483 additions and 286 deletions

View File

@ -28,7 +28,7 @@
<section class="sidenav-content" [id]="pageId" role="content"> <section class="sidenav-content" [id]="pageId" role="content">
<aio-mode-banner [mode]="deployment.mode" [version]="versionInfo"></aio-mode-banner> <aio-mode-banner [mode]="deployment.mode" [version]="versionInfo"></aio-mode-banner>
<aio-doc-viewer [doc]="currentDocument" (docRendered)="onDocRendered()"></aio-doc-viewer> <aio-doc-viewer [doc]="currentDocument" (docReady)="onDocReady()" (docRemoved)="onDocRemoved()" (docInserted)="onDocInserted()"></aio-doc-viewer>
<aio-dt [on]="dtOn" [(doc)]="currentDocument"></aio-dt> <aio-dt [on]="dtOn" [(doc)]="currentDocument"></aio-dt>
</section> </section>

View File

@ -252,25 +252,25 @@ describe('AppComponent', () => {
const sidenavBackdrop = fixture.debugElement.query(By.css('.mat-drawer-backdrop')).nativeElement; const sidenavBackdrop = fixture.debugElement.query(By.css('.mat-drawer-backdrop')).nativeElement;
sidenavBackdrop.click(); sidenavBackdrop.click();
fixture.detectChanges(); fixture.detectChanges();
expect(sidenav.opened).toBe(false); expect(sidenav.opened).toBe(false);
}); });
it('should close when nav to another guide page', () => { it('should close when nav to another guide page', () => {
locationService.go('guide/bags'); locationService.go('guide/bags');
fixture.detectChanges(); fixture.detectChanges();
expect(sidenav.opened).toBe(false); expect(sidenav.opened).toBe(false);
}); });
it('should close when nav to api page', () => { it('should close when nav to api page', () => {
locationService.go('api'); locationService.go('api');
fixture.detectChanges(); fixture.detectChanges();
expect(sidenav.opened).toBe(false); expect(sidenav.opened).toBe(false);
}); });
it('should close again when nav to market page', () => { it('should close again when nav to market page', () => {
locationService.go('features'); locationService.go('features');
fixture.detectChanges(); fixture.detectChanges();
expect(sidenav.opened).toBe(false); expect(sidenav.opened).toBe(false);
}); });
}); });
@ -452,16 +452,19 @@ describe('AppComponent', () => {
const scrollDelay = 500; const scrollDelay = 500;
let scrollService: ScrollService; let scrollService: ScrollService;
let scrollSpy: jasmine.Spy; let scrollSpy: jasmine.Spy;
let scrollToTopSpy: jasmine.Spy;
beforeEach(() => { beforeEach(() => {
scrollService = fixture.debugElement.injector.get(ScrollService); scrollService = fixture.debugElement.injector.get(ScrollService);
scrollSpy = spyOn(scrollService, 'scroll'); scrollSpy = spyOn(scrollService, 'scroll');
scrollToTopSpy = spyOn(scrollService, 'scrollToTop');
}); });
it('should not scroll immediately when the docId (path) changes', () => { it('should not scroll immediately when the docId (path) changes', () => {
locationService.go('guide/pipes'); locationService.go('guide/pipes');
// deliberately not calling `fixture.detectChanges` because don't want `onDocRendered` // deliberately not calling `fixture.detectChanges` because don't want `onDocInserted`
expect(scrollSpy).not.toHaveBeenCalled(); expect(scrollSpy).not.toHaveBeenCalled();
expect(scrollToTopSpy).not.toHaveBeenCalled();
}); });
it('should scroll when just the hash changes (# alone)', () => { it('should scroll when just the hash changes (# alone)', () => {
@ -491,7 +494,7 @@ describe('AppComponent', () => {
expect(scrollSpy).toHaveBeenCalledTimes(1); expect(scrollSpy).toHaveBeenCalledTimes(1);
}); });
it('should scroll when e-nav to the empty path', () => { it('should scroll when re-nav to the empty path', () => {
locationService.go(''); locationService.go('');
scrollSpy.calls.reset(); scrollSpy.calls.reset();
@ -499,17 +502,29 @@ describe('AppComponent', () => {
expect(scrollSpy).toHaveBeenCalledTimes(1); expect(scrollSpy).toHaveBeenCalledTimes(1);
}); });
it('should scroll after a delay when call onDocRendered directly', fakeAsync(() => { it('should scroll to top when call `onDocRemoved` directly', () => {
component.onDocRendered(); scrollToTopSpy.calls.reset();
component.onDocRemoved();
expect(scrollToTopSpy).toHaveBeenCalled();
});
it('should scroll after a delay when call `onDocInserted` directly', fakeAsync(() => {
component.onDocInserted();
expect(scrollSpy).not.toHaveBeenCalled(); expect(scrollSpy).not.toHaveBeenCalled();
tick(scrollDelay); tick(scrollDelay);
expect(scrollSpy).toHaveBeenCalled(); expect(scrollSpy).toHaveBeenCalled();
})); }));
it('should scroll (via onDocRendered) when finish navigating to a new doc', fakeAsync(() => { it('should scroll (via `onDocInserted`) when finish navigating to a new doc', fakeAsync(() => {
expect(scrollToTopSpy).not.toHaveBeenCalled();
locationService.go('guide/pipes'); locationService.go('guide/pipes');
fixture.detectChanges(); // triggers the event that calls onDocRendered fixture.detectChanges(); // triggers the event that calls `onDocInserted`
expect(scrollToTopSpy).toHaveBeenCalled();
expect(scrollSpy).not.toHaveBeenCalled(); expect(scrollSpy).not.toHaveBeenCalled();
tick(scrollDelay); tick(scrollDelay);
expect(scrollSpy).toHaveBeenCalled(); expect(scrollSpy).toHaveBeenCalled();
})); }));
@ -872,7 +887,7 @@ describe('AppComponent', () => {
describe('with mocked DocViewer', () => { describe('with mocked DocViewer', () => {
const getDocViewer = () => fixture.debugElement.query(By.css('aio-doc-viewer')); const getDocViewer = () => fixture.debugElement.query(By.css('aio-doc-viewer'));
const triggerDocRendered = () => getDocViewer().triggerEventHandler('docRendered', {}); const triggerDocReady = () => getDocViewer().triggerEventHandler('docReady', undefined);
beforeEach(() => { beforeEach(() => {
createTestingModule('a/b'); createTestingModule('a/b');
@ -884,7 +899,7 @@ describe('AppComponent', () => {
}); });
describe('initial rendering', () => { describe('initial rendering', () => {
it('should initially add the starting class until the first document is rendered', fakeAsync(() => { it('should initially add the starting class until the first document is ready', fakeAsync(() => {
const getSidenavContainer = () => fixture.debugElement.query(By.css('mat-sidenav-container')); const getSidenavContainer = () => fixture.debugElement.query(By.css('mat-sidenav-container'));
initializeTest(); initializeTest();
@ -892,7 +907,7 @@ describe('AppComponent', () => {
expect(component.isStarting).toBe(true); expect(component.isStarting).toBe(true);
expect(getSidenavContainer().classes['starting']).toBe(true); expect(getSidenavContainer().classes['starting']).toBe(true);
triggerDocRendered(); triggerDocReady();
fixture.detectChanges(); fixture.detectChanges();
expect(component.isStarting).toBe(true); expect(component.isStarting).toBe(true);
expect(getSidenavContainer().classes['starting']).toBe(true); expect(getSidenavContainer().classes['starting']).toBe(true);
@ -915,7 +930,7 @@ describe('AppComponent', () => {
const getProgressBar = () => fixture.debugElement.query(By.directive(MatProgressBar)); const getProgressBar = () => fixture.debugElement.query(By.directive(MatProgressBar));
const initializeAndCompleteNavigation = () => { const initializeAndCompleteNavigation = () => {
initializeTest(); initializeTest();
triggerDocRendered(); triggerDocReady();
tick(HIDE_DELAY); tick(HIDE_DELAY);
}; };
@ -952,7 +967,7 @@ describe('AppComponent', () => {
it('should not be shown when re-navigating to the empty path', fakeAsync(() => { it('should not be shown when re-navigating to the empty path', fakeAsync(() => {
initializeAndCompleteNavigation(); initializeAndCompleteNavigation();
locationService.urlSubject.next(''); locationService.urlSubject.next('');
triggerDocRendered(); triggerDocReady();
locationService.urlSubject.next(''); locationService.urlSubject.next('');
@ -963,12 +978,12 @@ describe('AppComponent', () => {
tick(HIDE_DELAY); // Fire the remaining timer or `fakeAsync()` complains. tick(HIDE_DELAY); // Fire the remaining timer or `fakeAsync()` complains.
})); }));
it('should not be shown if the doc is rendered quickly', fakeAsync(() => { it('should not be shown if the doc is prepared quickly', fakeAsync(() => {
initializeAndCompleteNavigation(); initializeAndCompleteNavigation();
locationService.urlSubject.next('c/d'); locationService.urlSubject.next('c/d');
tick(SHOW_DELAY - 1); tick(SHOW_DELAY - 1);
triggerDocRendered(); triggerDocReady();
tick(1); tick(1);
fixture.detectChanges(); fixture.detectChanges();
@ -977,12 +992,12 @@ describe('AppComponent', () => {
tick(HIDE_DELAY); // Fire the remaining timer or `fakeAsync()` complains. tick(HIDE_DELAY); // Fire the remaining timer or `fakeAsync()` complains.
})); }));
it('should be shown if rendering the doc takes too long', fakeAsync(() => { it('should be shown if preparing the doc takes too long', fakeAsync(() => {
initializeAndCompleteNavigation(); initializeAndCompleteNavigation();
locationService.urlSubject.next('c/d'); locationService.urlSubject.next('c/d');
tick(SHOW_DELAY); tick(SHOW_DELAY);
triggerDocRendered(); triggerDocReady();
fixture.detectChanges(); fixture.detectChanges();
expect(getProgressBar()).toBeTruthy(); expect(getProgressBar()).toBeTruthy();
@ -990,12 +1005,12 @@ describe('AppComponent', () => {
tick(HIDE_DELAY); // Fire the remaining timer or `fakeAsync()` complains. tick(HIDE_DELAY); // Fire the remaining timer or `fakeAsync()` complains.
})); }));
it('should be hidden (after a delay) once the doc is rendered', fakeAsync(() => { it('should be hidden (after a delay) once the doc has been prepared', fakeAsync(() => {
initializeAndCompleteNavigation(); initializeAndCompleteNavigation();
locationService.urlSubject.next('c/d'); locationService.urlSubject.next('c/d');
tick(SHOW_DELAY); tick(SHOW_DELAY);
triggerDocRendered(); triggerDocReady();
fixture.detectChanges(); fixture.detectChanges();
expect(getProgressBar()).toBeTruthy(); expect(getProgressBar()).toBeTruthy();
@ -1012,10 +1027,10 @@ describe('AppComponent', () => {
it('should only take the latest request into account', fakeAsync(() => { it('should only take the latest request into account', fakeAsync(() => {
initializeAndCompleteNavigation(); initializeAndCompleteNavigation();
locationService.urlSubject.next('c/d'); // The URL changes. locationService.urlSubject.next('c/d'); // The URL changes.
locationService.urlSubject.next('e/f'); // The URL changes again before `onDocRendered()`. locationService.urlSubject.next('e/f'); // The URL changes again before `onDocReady()`.
tick(SHOW_DELAY - 1); // `onDocRendered()` is triggered (for the last doc), tick(SHOW_DELAY - 1); // `onDocReady()` is triggered (for the last doc),
triggerDocRendered(); // before the progress bar is shown. triggerDocReady(); // before the progress bar is shown.
tick(1); tick(1);
fixture.detectChanges(); fixture.detectChanges();

View File

@ -203,22 +203,29 @@ export class AppComponent implements OnInit {
this.scrollService.scroll(); this.scrollService.scroll();
} }
onDocRendered() { onDocReady() {
// Stop fetching timeout (which, when render is fast, means progress bar never shown) // Stop fetching timeout (which, when render is fast, means progress bar never shown)
clearTimeout(this.isFetchingTimeout); clearTimeout(this.isFetchingTimeout);
// Put page in a clean visual state // If progress bar has been shown, keep it for at least 500ms (to avoid flashing).
this.scrollService.scrollToTop();
// Scroll 500ms after the doc-viewer has finished rendering the new doc
// The delay is to allow time for async layout to complete
setTimeout(() => { setTimeout(() => {
this.autoScroll();
this.isStarting = false; this.isStarting = false;
this.isFetching = false; this.isFetching = false;
}, 500); }, 500);
} }
onDocRemoved() {
// The previous document has been removed.
// Scroll to top to restore a clean visual state for the new document.
this.scrollService.scrollToTop();
}
onDocInserted() {
// Scroll 500ms after the new document has been inserted into the doc-viewer.
// The delay is to allow time for async layout to complete.
setTimeout(() => this.autoScroll(), 500);
}
onDocVersionChange(versionIndex: number) { onDocVersionChange(versionIndex: number) {
const version = this.docVersions[versionIndex]; const version = this.docVersions[versionIndex];
if (version.url) { if (version.url) {

View File

@ -40,8 +40,7 @@ describe('DocViewerComponent', () => {
expect(docViewer).toEqual(jasmine.any(DocViewerComponent)); expect(docViewer).toEqual(jasmine.any(DocViewerComponent));
}); });
describe('#doc / #docRendered', () => { describe('#doc', () => {
let destroyEmbeddedComponentsSpy: jasmine.Spy;
let renderSpy: jasmine.Spy; let renderSpy: jasmine.Spy;
const setCurrentDoc = (contents, id = 'fizz/buzz') => { const setCurrentDoc = (contents, id = 'fizz/buzz') => {
@ -49,10 +48,7 @@ describe('DocViewerComponent', () => {
parentFixture.detectChanges(); parentFixture.detectChanges();
}; };
beforeEach(() => { beforeEach(() => renderSpy = spyOn(docViewer, 'render').and.returnValue([null]));
destroyEmbeddedComponentsSpy = spyOn(docViewer, 'destroyEmbeddedComponents');
renderSpy = spyOn(docViewer, 'render').and.returnValue([null]);
});
it('should render the new document', () => { it('should render the new document', () => {
setCurrentDoc('foo', 'bar'); setCurrentDoc('foo', 'bar');
@ -64,30 +60,6 @@ describe('DocViewerComponent', () => {
expect(renderSpy.calls.mostRecent().args).toEqual([{id: 'baz', contents: null}]); expect(renderSpy.calls.mostRecent().args).toEqual([{id: 'baz', contents: null}]);
}); });
it('should destroy the currently active components (before rendering the new document)', () => {
setCurrentDoc('foo');
expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1);
expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledBefore(renderSpy);
destroyEmbeddedComponentsSpy.calls.reset();
renderSpy.calls.reset();
setCurrentDoc(null);
expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1);
expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledBefore(renderSpy);
});
it('should emit `docRendered` after the new document has been rendered', done => {
let completeRender: () => void;
renderSpy.and.returnValue(new Promise(resolve => completeRender = resolve));
docViewer.docRendered.subscribe(done);
setCurrentDoc('foo');
expect(renderSpy).toHaveBeenCalledTimes(1);
completeRender();
});
it('should unsubscribe from the previous "render" observable upon new document', () => { it('should unsubscribe from the previous "render" observable upon new document', () => {
const obs = new ObservableWithSubscriptionSpies(); const obs = new ObservableWithSubscriptionSpies();
renderSpy.and.returnValue(obs); renderSpy.and.returnValue(obs);
@ -102,22 +74,15 @@ describe('DocViewerComponent', () => {
}); });
it('should ignore falsy document values', () => { it('should ignore falsy document values', () => {
const onDocRenderedSpy = jasmine.createSpy('onDocRendered');
docViewer.docRendered.subscribe(onDocRenderedSpy);
parentComponent.currentDoc = null; parentComponent.currentDoc = null;
parentFixture.detectChanges(); parentFixture.detectChanges();
expect(destroyEmbeddedComponentsSpy).not.toHaveBeenCalled();
expect(renderSpy).not.toHaveBeenCalled(); expect(renderSpy).not.toHaveBeenCalled();
expect(onDocRenderedSpy).not.toHaveBeenCalled();
parentComponent.currentDoc = undefined; parentComponent.currentDoc = undefined;
parentFixture.detectChanges(); parentFixture.detectChanges();
expect(destroyEmbeddedComponentsSpy).not.toHaveBeenCalled();
expect(renderSpy).not.toHaveBeenCalled(); expect(renderSpy).not.toHaveBeenCalled();
expect(onDocRenderedSpy).not.toHaveBeenCalled();
}); });
}); });
@ -160,166 +125,20 @@ describe('DocViewerComponent', () => {
}); });
it('should stop responding to document changes', () => { it('should stop responding to document changes', () => {
const destroyEmbeddedComponentsSpy = spyOn(docViewer, 'destroyEmbeddedComponents');
const renderSpy = spyOn(docViewer, 'render').and.returnValue([undefined]); const renderSpy = spyOn(docViewer, 'render').and.returnValue([undefined]);
const onDocRenderedSpy = jasmine.createSpy('onDocRendered');
docViewer.docRendered.subscribe(onDocRenderedSpy);
expect(destroyEmbeddedComponentsSpy).not.toHaveBeenCalled();
expect(renderSpy).not.toHaveBeenCalled(); expect(renderSpy).not.toHaveBeenCalled();
expect(onDocRenderedSpy).not.toHaveBeenCalled();
docViewer.doc = {contents: 'Some content', id: 'some-id'}; docViewer.doc = {contents: 'Some content', id: 'some-id'};
expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1);
expect(renderSpy).toHaveBeenCalledTimes(1); expect(renderSpy).toHaveBeenCalledTimes(1);
expect(onDocRenderedSpy).toHaveBeenCalledTimes(1);
docViewer.ngOnDestroy(); // Also calls `destroyEmbeddedComponents()`. docViewer.ngOnDestroy();
docViewer.doc = {contents: 'Other content', id: 'other-id'}; docViewer.doc = {contents: 'Other content', id: 'other-id'};
expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(2);
expect(renderSpy).toHaveBeenCalledTimes(1); expect(renderSpy).toHaveBeenCalledTimes(1);
expect(onDocRenderedSpy).toHaveBeenCalledTimes(1);
docViewer.doc = {contents: 'More content', id: 'more-id'}; docViewer.doc = {contents: 'More content', id: 'more-id'};
expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(2);
expect(renderSpy).toHaveBeenCalledTimes(1); expect(renderSpy).toHaveBeenCalledTimes(1);
expect(onDocRenderedSpy).toHaveBeenCalledTimes(1);
});
});
describe('#addTitleAndToc()', () => {
const EMPTY_DOC = '';
const DOC_WITHOUT_H1 = 'Some content';
const DOC_WITH_H1 = '<h1>Features</h1>Some content';
const DOC_WITH_NO_TOC_H1 = '<h1 class="no-toc">Features</h1>Some content';
const DOC_WITH_HIDDEN_H1_CONTENT = '<h1><i style="visibility: hidden">link</i>Features</h1>Some content';
const tryDoc = (contents: string, docId = '') => {
docViewer.currViewContainer.innerHTML = contents;
docViewer.addTitleAndToc(docId);
};
describe('(title)', () => {
let titleService: MockTitle;
beforeEach(() => titleService = TestBed.get(Title));
it('should set the title if there is an `<h1>` heading', () => {
tryDoc(DOC_WITH_H1);
expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features');
});
it('should set the title if there is a `.no-toc` `<h1>` heading', () => {
tryDoc(DOC_WITH_NO_TOC_H1);
expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features');
});
it('should set the default title if there is no `<h1>` heading', () => {
tryDoc(DOC_WITHOUT_H1);
expect(titleService.setTitle).toHaveBeenCalledWith('Angular');
tryDoc(EMPTY_DOC);
expect(titleService.setTitle).toHaveBeenCalledWith('Angular');
});
it('should not include hidden content of the `<h1>` heading in the title', () => {
tryDoc(DOC_WITH_HIDDEN_H1_CONTENT);
expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features');
});
it('should fall back to `textContent` if `innerText` is not available', () => {
const viewContainer = docViewer.currViewContainer;
const querySelector_ = viewContainer.querySelector;
spyOn(viewContainer, 'querySelector').and.callFake((selector: string) => {
const elem = querySelector_.call(viewContainer, selector);
return Object.defineProperties(elem, {
innerText: {value: undefined},
textContent: {value: 'Text Content'},
});
});
tryDoc(DOC_WITH_HIDDEN_H1_CONTENT);
expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Text Content');
});
it('should still use `innerText` if available but empty', () => {
const viewContainer = docViewer.currViewContainer;
const querySelector_ = viewContainer.querySelector;
spyOn(viewContainer, 'querySelector').and.callFake((selector: string) => {
const elem = querySelector_.call(viewContainer, selector);
return Object.defineProperties(elem, {
innerText: { value: '' },
textContent: { value: 'Text Content' }
});
});
tryDoc(DOC_WITH_HIDDEN_H1_CONTENT);
expect(titleService.setTitle).toHaveBeenCalledWith('Angular');
});
});
describe('(ToC)', () => {
let tocService: MockTocService;
const getTocEl = () => docViewerEl.querySelector('aio-toc');
beforeEach(() => tocService = TestBed.get(TocService));
it('should have an (embedded) ToC if there is an `<h1>` heading', () => {
tryDoc(DOC_WITH_H1, 'foo');
const tocEl = getTocEl()!;
expect(tocEl).toBeTruthy();
expect(tocEl.classList.contains('embedded')).toBe(true);
expect(tocService.genToc).toHaveBeenCalledTimes(1);
expect(tocService.genToc).toHaveBeenCalledWith(docViewer.currViewContainer, 'foo');
});
it('should have no ToC if there is a `.no-toc` `<h1>` heading', () => {
tryDoc(DOC_WITH_NO_TOC_H1);
expect(getTocEl()).toBeFalsy();
expect(tocService.genToc).not.toHaveBeenCalled();
});
it('should have no ToC if there is no `<h1>` heading', () => {
tryDoc(DOC_WITHOUT_H1);
expect(getTocEl()).toBeFalsy();
tryDoc(EMPTY_DOC);
expect(getTocEl()).toBeFalsy();
expect(tocService.genToc).not.toHaveBeenCalled();
});
it('should always reset the ToC (before generating the new one)', () => {
expect(tocService.reset).not.toHaveBeenCalled();
expect(tocService.genToc).not.toHaveBeenCalled();
tocService.genToc.calls.reset();
tryDoc(DOC_WITH_H1, 'foo');
expect(tocService.reset).toHaveBeenCalledTimes(1);
expect(tocService.reset).toHaveBeenCalledBefore(tocService.genToc);
expect(tocService.genToc).toHaveBeenCalledWith(docViewer.currViewContainer, 'foo');
tocService.genToc.calls.reset();
tryDoc(DOC_WITH_NO_TOC_H1, 'bar');
expect(tocService.reset).toHaveBeenCalledTimes(2);
expect(tocService.genToc).not.toHaveBeenCalled();
tocService.genToc.calls.reset();
tryDoc(DOC_WITHOUT_H1, 'baz');
expect(tocService.reset).toHaveBeenCalledTimes(3);
expect(tocService.genToc).not.toHaveBeenCalled();
tocService.genToc.calls.reset();
tryDoc(EMPTY_DOC, 'qux');
expect(tocService.reset).toHaveBeenCalledTimes(4);
expect(tocService.genToc).not.toHaveBeenCalled();
});
}); });
}); });
@ -350,9 +169,174 @@ describe('DocViewerComponent', () => {
}); });
}); });
describe('#prepareTitleAndToc()', () => {
const EMPTY_DOC = '';
const DOC_WITHOUT_H1 = 'Some content';
const DOC_WITH_H1 = '<h1>Features</h1>Some content';
const DOC_WITH_NO_TOC_H1 = '<h1 class="no-toc">Features</h1>Some content';
const DOC_WITH_HIDDEN_H1_CONTENT = '<h1><i style="visibility: hidden">link</i>Features</h1>Some content';
let titleService: MockTitle;
let tocService: MockTocService;
let targetEl: HTMLElement;
const getTocEl = () => targetEl.querySelector('aio-toc');
const doPrepareTitleAndToc = (contents: string, docId = '') => {
targetEl.innerHTML = contents;
return docViewer.prepareTitleAndToc(targetEl, docId);
};
const doAddTitleAndToc = (contents: string, docId = '') => {
const addTitleAndToc = doPrepareTitleAndToc(contents, docId);
return addTitleAndToc();
};
beforeEach(() => {
titleService = TestBed.get(Title);
tocService = TestBed.get(TocService);
targetEl = document.createElement('div');
document.body.appendChild(targetEl); // Required for `innerText` to work as expected.
});
afterEach(() => document.body.removeChild(targetEl));
it('should return a function for doing the actual work', () => {
const addTitleAndToc = doPrepareTitleAndToc(DOC_WITH_H1);
expect(getTocEl()).toBeTruthy();
expect(titleService.setTitle).not.toHaveBeenCalled();
expect(tocService.reset).not.toHaveBeenCalled();
expect(tocService.genToc).not.toHaveBeenCalled();
addTitleAndToc();
expect(titleService.setTitle).toHaveBeenCalledTimes(1);
expect(tocService.reset).toHaveBeenCalledTimes(1);
expect(tocService.genToc).toHaveBeenCalledTimes(1);
});
describe('(title)', () => {
it('should set the title if there is an `<h1>` heading', () => {
doAddTitleAndToc(DOC_WITH_H1);
expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features');
});
it('should set the title if there is a `.no-toc` `<h1>` heading', () => {
doAddTitleAndToc(DOC_WITH_NO_TOC_H1);
expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features');
});
it('should set the default title if there is no `<h1>` heading', () => {
doAddTitleAndToc(DOC_WITHOUT_H1);
expect(titleService.setTitle).toHaveBeenCalledWith('Angular');
doAddTitleAndToc(EMPTY_DOC);
expect(titleService.setTitle).toHaveBeenCalledWith('Angular');
});
it('should not include hidden content of the `<h1>` heading in the title', () => {
doAddTitleAndToc(DOC_WITH_HIDDEN_H1_CONTENT);
expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features');
});
it('should fall back to `textContent` if `innerText` is not available', () => {
const querySelector_ = targetEl.querySelector;
spyOn(targetEl, 'querySelector').and.callFake((selector: string) => {
const elem = querySelector_.call(targetEl, selector);
return Object.defineProperties(elem, {
innerText: {value: undefined},
textContent: {value: 'Text Content'},
});
});
doAddTitleAndToc(DOC_WITH_HIDDEN_H1_CONTENT);
expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Text Content');
});
it('should still use `innerText` if available but empty', () => {
const querySelector_ = targetEl.querySelector;
spyOn(targetEl, 'querySelector').and.callFake((selector: string) => {
const elem = querySelector_.call(targetEl, selector);
return Object.defineProperties(elem, {
innerText: { value: '' },
textContent: { value: 'Text Content' }
});
});
doAddTitleAndToc(DOC_WITH_HIDDEN_H1_CONTENT);
expect(titleService.setTitle).toHaveBeenCalledWith('Angular');
});
});
describe('(ToC)', () => {
it('should add an embedded ToC element if there is an `<h1>` heading', () => {
doPrepareTitleAndToc(DOC_WITH_H1);
const tocEl = getTocEl()!;
expect(tocEl).toBeTruthy();
expect(tocEl.classList.contains('embedded')).toBe(true);
});
it('should not add a ToC element if there is a `.no-toc` `<h1>` heading', () => {
doPrepareTitleAndToc(DOC_WITH_NO_TOC_H1);
expect(getTocEl()).toBeFalsy();
});
it('should not add a ToC element if there is no `<h1>` heading', () => {
doPrepareTitleAndToc(DOC_WITHOUT_H1);
expect(getTocEl()).toBeFalsy();
doPrepareTitleAndToc(EMPTY_DOC);
expect(getTocEl()).toBeFalsy();
});
it('should generate ToC entries if there is an `<h1>` heading', () => {
doAddTitleAndToc(DOC_WITH_H1, 'foo');
expect(tocService.genToc).toHaveBeenCalledTimes(1);
expect(tocService.genToc).toHaveBeenCalledWith(targetEl, 'foo');
});
it('should not generate ToC entries if there is a `.no-toc` `<h1>` heading', () => {
doAddTitleAndToc(DOC_WITH_NO_TOC_H1);
expect(tocService.genToc).not.toHaveBeenCalled();
});
it('should not generate ToC entries if there is no `<h1>` heading', () => {
doAddTitleAndToc(DOC_WITHOUT_H1);
doAddTitleAndToc(EMPTY_DOC);
expect(tocService.genToc).not.toHaveBeenCalled();
});
it('should always reset the ToC (before generating the new one)', () => {
doAddTitleAndToc(DOC_WITH_H1, 'foo');
expect(tocService.reset).toHaveBeenCalledTimes(1);
expect(tocService.reset).toHaveBeenCalledBefore(tocService.genToc);
expect(tocService.genToc).toHaveBeenCalledWith(targetEl, 'foo');
tocService.genToc.calls.reset();
doAddTitleAndToc(DOC_WITH_NO_TOC_H1, 'bar');
expect(tocService.reset).toHaveBeenCalledTimes(2);
expect(tocService.genToc).not.toHaveBeenCalled();
doAddTitleAndToc(DOC_WITHOUT_H1, 'baz');
expect(tocService.reset).toHaveBeenCalledTimes(3);
expect(tocService.genToc).not.toHaveBeenCalled();
doAddTitleAndToc(EMPTY_DOC, 'qux');
expect(tocService.reset).toHaveBeenCalledTimes(4);
expect(tocService.genToc).not.toHaveBeenCalled();
});
});
});
describe('#render()', () => { describe('#render()', () => {
let addTitleAndTocSpy: jasmine.Spy; let destroyEmbeddedComponentsSpy: jasmine.Spy;
let embedIntoSpy: jasmine.Spy; let embedIntoSpy: jasmine.Spy;
let prepareTitleAndTocSpy: jasmine.Spy;
let swapViewsSpy: jasmine.Spy; let swapViewsSpy: jasmine.Spy;
const doRender = (contents: string | null, id = 'foo') => const doRender = (contents: string | null, id = 'foo') =>
@ -362,8 +346,9 @@ describe('DocViewerComponent', () => {
beforeEach(() => { beforeEach(() => {
const embedComponentsService = TestBed.get(EmbedComponentsService) as MockEmbedComponentsService; const embedComponentsService = TestBed.get(EmbedComponentsService) as MockEmbedComponentsService;
addTitleAndTocSpy = spyOn(docViewer, 'addTitleAndToc'); destroyEmbeddedComponentsSpy = spyOn(docViewer, 'destroyEmbeddedComponents');
embedIntoSpy = embedComponentsService.embedInto.and.returnValue(of([])); embedIntoSpy = embedComponentsService.embedInto.and.returnValue(of([]));
prepareTitleAndTocSpy = spyOn(docViewer, 'prepareTitleAndToc');
swapViewsSpy = spyOn(docViewer, 'swapViews').and.returnValue(of(undefined)); swapViewsSpy = spyOn(docViewer, 'swapViews').and.returnValue(of(undefined));
}); });
@ -396,26 +381,37 @@ describe('DocViewerComponent', () => {
expect(docViewerEl.textContent).toBe(''); expect(docViewerEl.textContent).toBe('');
}); });
it('should prepare the title and ToC (before embedding components)', async () => {
prepareTitleAndTocSpy.and.callFake((targetEl: HTMLElement, docId: string) => {
expect(targetEl.innerHTML).toBe('Some content');
expect(docId).toBe('foo');
});
await doRender('Some content', 'foo');
expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1);
expect(prepareTitleAndTocSpy).toHaveBeenCalledBefore(embedIntoSpy);
});
it('should set the title and ToC (after the content has been set)', async () => { it('should set the title and ToC (after the content has been set)', async () => {
const addTitleAndTocSpy = jasmine.createSpy('addTitleAndToc');
prepareTitleAndTocSpy.and.returnValue(addTitleAndTocSpy);
addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Foo content')); addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Foo content'));
await doRender('Foo content', 'foo'); await doRender('Foo content');
expect(addTitleAndTocSpy).toHaveBeenCalledTimes(1); expect(addTitleAndTocSpy).toHaveBeenCalledTimes(1);
expect(addTitleAndTocSpy).toHaveBeenCalledWith('foo');
addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Bar content')); addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Bar content'));
await doRender('Bar content', 'bar'); await doRender('Bar content');
expect(addTitleAndTocSpy).toHaveBeenCalledTimes(2); expect(addTitleAndTocSpy).toHaveBeenCalledTimes(2);
expect(addTitleAndTocSpy).toHaveBeenCalledWith('bar');
addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('')); addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe(''));
await doRender('', 'baz'); await doRender('');
expect(addTitleAndTocSpy).toHaveBeenCalledTimes(3); expect(addTitleAndTocSpy).toHaveBeenCalledTimes(3);
expect(addTitleAndTocSpy).toHaveBeenCalledWith('baz');
addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Qux content')); addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Qux content'));
await doRender('Qux content', 'qux'); await doRender('Qux content');
expect(addTitleAndTocSpy).toHaveBeenCalledTimes(4); expect(addTitleAndTocSpy).toHaveBeenCalledTimes(4);
expect(addTitleAndTocSpy).toHaveBeenCalledWith('qux');
}); });
}); });
@ -461,12 +457,29 @@ describe('DocViewerComponent', () => {
}); });
}); });
describe('(destroying old embedded components)', () => {
it('should destroy old embedded components after creating new embedded components', async () => {
await doRender('<div></div>');
expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1);
expect(embedIntoSpy).toHaveBeenCalledBefore(destroyEmbeddedComponentsSpy);
});
it('should still destroy old embedded components if the new document is empty', async () => {
await doRender('');
expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1);
await doRender(null);
expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(2);
});
});
describe('(swapping views)', () => { describe('(swapping views)', () => {
it('should swap the views after creating embedded components', async () => { it('should swap the views after destroying old embedded components', async () => {
await doRender('<div></div>'); await doRender('<div></div>');
expect(swapViewsSpy).toHaveBeenCalledTimes(1); expect(swapViewsSpy).toHaveBeenCalledTimes(1);
expect(embedIntoSpy).toHaveBeenCalledBefore(swapViewsSpy); expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledBefore(swapViewsSpy);
}); });
it('should still swap the views if the document is empty', async () => { it('should still swap the views if the document is empty', async () => {
@ -477,6 +490,15 @@ describe('DocViewerComponent', () => {
expect(swapViewsSpy).toHaveBeenCalledTimes(2); expect(swapViewsSpy).toHaveBeenCalledTimes(2);
}); });
it('should pass the `addTitleAndToc` callback', async () => {
const addTitleAndTocSpy = jasmine.createSpy('addTitleAndToc');
prepareTitleAndTocSpy.and.returnValue(addTitleAndTocSpy);
await doRender('<div></div>');
expect(swapViewsSpy).toHaveBeenCalledWith(addTitleAndTocSpy);
});
it('should unsubscribe from the previous "swap" observable when unsubscribed from', () => { it('should unsubscribe from the previous "swap" observable when unsubscribed from', () => {
const obs = new ObservableWithSubscriptionSpies(); const obs = new ObservableWithSubscriptionSpies();
swapViewsSpy.and.returnValue(obs); swapViewsSpy.and.returnValue(obs);
@ -499,6 +521,25 @@ describe('DocViewerComponent', () => {
beforeEach(() => logger = TestBed.get(Logger)); beforeEach(() => logger = TestBed.get(Logger));
it('when `prepareTitleAndTocSpy()` fails', async () => {
const error = Error('Typical `prepareTitleAndToc()` error');
prepareTitleAndTocSpy.and.callFake(() => {
expect(docViewer.nextViewContainer.innerHTML).not.toBe('');
throw error;
});
await doRender('Some content', 'foo');
expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1);
expect(embedIntoSpy).not.toHaveBeenCalled();
expect(destroyEmbeddedComponentsSpy).not.toHaveBeenCalled();
expect(swapViewsSpy).not.toHaveBeenCalled();
expect(docViewer.nextViewContainer.innerHTML).toBe('');
expect(logger.output.error).toEqual([
['[DocViewer]: Error preparing document \'foo\'.', error],
]);
});
it('when `EmbedComponentsService.embedInto()` fails', async () => { it('when `EmbedComponentsService.embedInto()` fails', async () => {
const error = Error('Typical `embedInto()` error'); const error = Error('Typical `embedInto()` error');
embedIntoSpy.and.callFake(() => { embedIntoSpy.and.callFake(() => {
@ -506,14 +547,34 @@ describe('DocViewerComponent', () => {
throw error; throw error;
}); });
await doRender('Some content', 'foo'); await doRender('Some content', 'bar');
expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1);
expect(embedIntoSpy).toHaveBeenCalledTimes(1); expect(embedIntoSpy).toHaveBeenCalledTimes(1);
expect(destroyEmbeddedComponentsSpy).not.toHaveBeenCalled();
expect(swapViewsSpy).not.toHaveBeenCalled(); expect(swapViewsSpy).not.toHaveBeenCalled();
expect(addTitleAndTocSpy).not.toHaveBeenCalled();
expect(docViewer.nextViewContainer.innerHTML).toBe(''); expect(docViewer.nextViewContainer.innerHTML).toBe('');
expect(logger.output.error).toEqual([ expect(logger.output.error).toEqual([
['[DocViewer]: Error preparing document \'foo\'.', error], ['[DocViewer]: Error preparing document \'bar\'.', error],
]);
});
it('when `destroyEmbeddedComponents()` fails', async () => {
const error = Error('Typical `destroyEmbeddedComponents()` error');
destroyEmbeddedComponentsSpy.and.callFake(() => {
expect(docViewer.nextViewContainer.innerHTML).not.toBe('');
throw error;
});
await doRender('Some content', 'baz');
expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1);
expect(embedIntoSpy).toHaveBeenCalledTimes(1);
expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1);
expect(swapViewsSpy).not.toHaveBeenCalled();
expect(docViewer.nextViewContainer.innerHTML).toBe('');
expect(logger.output.error).toEqual([
['[DocViewer]: Error preparing document \'baz\'.', error],
]); ]);
}); });
@ -524,33 +585,49 @@ describe('DocViewerComponent', () => {
throw error; throw error;
}); });
await doRender('Some content', 'bar'); await doRender('Some content', 'qux');
expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1);
expect(embedIntoSpy).toHaveBeenCalledTimes(1); expect(embedIntoSpy).toHaveBeenCalledTimes(1);
expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1);
expect(swapViewsSpy).toHaveBeenCalledTimes(1); expect(swapViewsSpy).toHaveBeenCalledTimes(1);
expect(addTitleAndTocSpy).not.toHaveBeenCalled();
expect(docViewer.nextViewContainer.innerHTML).toBe(''); expect(docViewer.nextViewContainer.innerHTML).toBe('');
expect(logger.output.error).toEqual([ expect(logger.output.error).toEqual([
['[DocViewer]: Error preparing document \'bar\'.', error], ['[DocViewer]: Error preparing document \'qux\'.', error],
]); ]);
}); });
});
it('when `addTitleAndTocSpy()` fails', async () => { describe('(events)', () => {
const error = Error('Typical `addTitleAndToc()` error'); it('should emit `docReady` after embedding components', async () => {
addTitleAndTocSpy.and.callFake(() => { const onDocReadySpy = jasmine.createSpy('onDocReady');
expect(docViewer.nextViewContainer.innerHTML).not.toBe(''); docViewer.docReady.subscribe(onDocReadySpy);
throw error;
});
await doRender('Some content', 'baz'); await doRender('Some content');
expect(embedIntoSpy).toHaveBeenCalledTimes(1); expect(onDocReadySpy).toHaveBeenCalledTimes(1);
expect(swapViewsSpy).toHaveBeenCalledTimes(1); expect(embedIntoSpy).toHaveBeenCalledBefore(onDocReadySpy);
expect(addTitleAndTocSpy).toHaveBeenCalledTimes(1); });
expect(docViewer.nextViewContainer.innerHTML).toBe('');
expect(logger.output.error).toEqual([ it('should emit `docReady` before destroying old embedded components and swapping views', async () => {
['[DocViewer]: Error preparing document \'baz\'.', error], const onDocReadySpy = jasmine.createSpy('onDocReady');
]); docViewer.docReady.subscribe(onDocReadySpy);
await doRender('Some content');
expect(onDocReadySpy).toHaveBeenCalledTimes(1);
expect(onDocReadySpy).toHaveBeenCalledBefore(destroyEmbeddedComponentsSpy);
expect(onDocReadySpy).toHaveBeenCalledBefore(swapViewsSpy);
});
it('should emit `docRendered` after swapping views', async () => {
const onDocRenderedSpy = jasmine.createSpy('onDocRendered');
docViewer.docRendered.subscribe(onDocRenderedSpy);
await doRender('Some content');
expect(onDocRenderedSpy).toHaveBeenCalledTimes(1);
expect(swapViewsSpy).toHaveBeenCalledBefore(onDocRenderedSpy);
}); });
}); });
}); });
@ -559,8 +636,9 @@ describe('DocViewerComponent', () => {
let oldCurrViewContainer: HTMLElement; let oldCurrViewContainer: HTMLElement;
let oldNextViewContainer: HTMLElement; let oldNextViewContainer: HTMLElement;
const doSwapViews = () => new Promise<void>((resolve, reject) => const doSwapViews = (cb?: () => void) =>
docViewer.swapViews().subscribe(resolve, reject)); new Promise<void>((resolve, reject) =>
docViewer.swapViews(cb).subscribe(resolve, reject));
beforeEach(() => { beforeEach(() => {
oldCurrViewContainer = docViewer.currViewContainer; oldCurrViewContainer = docViewer.currViewContainer;
@ -598,6 +676,73 @@ describe('DocViewerComponent', () => {
expect(docViewer.nextViewContainer).toBe(oldNextViewContainer); expect(docViewer.nextViewContainer).toBe(oldNextViewContainer);
}); });
it('should emit `docRemoved` after removing the leaving view', async () => {
const onDocRemovedSpy = jasmine.createSpy('onDocRemoved').and.callFake(() => {
expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false);
expect(docViewerEl.contains(oldNextViewContainer)).toBe(false);
});
docViewer.docRemoved.subscribe(onDocRemovedSpy);
expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true);
expect(docViewerEl.contains(oldNextViewContainer)).toBe(false);
await doSwapViews();
expect(onDocRemovedSpy).toHaveBeenCalledTimes(1);
expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false);
expect(docViewerEl.contains(oldNextViewContainer)).toBe(true);
});
it('should not emit `docRemoved` if the leaving view is already removed', async () => {
const onDocRemovedSpy = jasmine.createSpy('onDocRemoved');
docViewer.docRemoved.subscribe(onDocRemovedSpy);
docViewerEl.removeChild(oldCurrViewContainer);
await doSwapViews();
expect(onDocRemovedSpy).not.toHaveBeenCalled();
});
it('should emit `docInserted` after inserting the entering view', async () => {
const onDocInsertedSpy = jasmine.createSpy('onDocInserted').and.callFake(() => {
expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false);
expect(docViewerEl.contains(oldNextViewContainer)).toBe(true);
});
docViewer.docInserted.subscribe(onDocInsertedSpy);
expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true);
expect(docViewerEl.contains(oldNextViewContainer)).toBe(false);
await doSwapViews();
expect(onDocInsertedSpy).toHaveBeenCalledTimes(1);
expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false);
expect(docViewerEl.contains(oldNextViewContainer)).toBe(true);
});
it('should call the callback after inserting the entering view', async () => {
const onInsertedCb = jasmine.createSpy('onInsertedCb').and.callFake(() => {
expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false);
expect(docViewerEl.contains(oldNextViewContainer)).toBe(true);
});
const onDocInsertedSpy = jasmine.createSpy('onDocInserted');
docViewer.docInserted.subscribe(onDocInsertedSpy);
expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true);
expect(docViewerEl.contains(oldNextViewContainer)).toBe(false);
await doSwapViews(onInsertedCb);
expect(onInsertedCb).toHaveBeenCalledTimes(1);
expect(onInsertedCb).toHaveBeenCalledBefore(onDocInsertedSpy);
expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false);
expect(docViewerEl.contains(oldNextViewContainer)).toBe(true);
});
it('should empty the previous view', async () => { it('should empty the previous view', async () => {
await doSwapViews(); await doSwapViews();

View File

@ -48,8 +48,21 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
} }
} }
@Output() // The new document is ready to be inserted into the viewer.
docRendered = new EventEmitter<void>(); // (Embedded components have been loaded and instantiated, if necessary.)
@Output() docReady = new EventEmitter<void>();
// The previous document has been removed from the viewer.
// (The leaving animation (if any) has been completed and the node has been removed from the DOM.)
@Output() docRemoved = new EventEmitter<void>();
// The new document has been inserted into the viewer.
// (The node has been inserted into the DOM, but the entering animation may still be in progress.)
@Output() docInserted = new EventEmitter<void>();
// The new document has been fully rendered into the viewer.
// (The entering animation has been completed.)
@Output() docRendered = new EventEmitter<void>();
constructor( constructor(
elementRef: ElementRef, elementRef: ElementRef,
@ -70,9 +83,7 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
this.onDestroy$.subscribe(() => this.destroyEmbeddedComponents()); this.onDestroy$.subscribe(() => this.destroyEmbeddedComponents());
this.docContents$ this.docContents$
.do(() => this.destroyEmbeddedComponents())
.switchMap(newDoc => this.render(newDoc)) .switchMap(newDoc => this.render(newDoc))
.do(() => this.docRendered.emit())
.takeUntil(this.onDestroy$) .takeUntil(this.onDestroy$)
.subscribe(); .subscribe();
} }
@ -85,27 +96,6 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
this.onDestroy$.emit(); this.onDestroy$.emit();
} }
/**
* Set up the window title and ToC.
*/
protected addTitleAndToc(docId: string): void {
this.tocService.reset();
const titleEl = this.currViewContainer.querySelector('h1');
let title = '';
// Only create TOC for docs with an <h1> title
// If you don't want a TOC, add "no-toc" class to <h1>
if (titleEl) {
title = (typeof titleEl.innerText === 'string') ? titleEl.innerText : titleEl.textContent;
if (!/(no-toc|notoc)/i.test(titleEl.className)) {
this.tocService.genToc(this.currViewContainer, docId);
titleEl.insertAdjacentHTML('afterend', '<aio-toc class="embedded"></aio-toc>');
}
}
this.titleService.setTitle(title ? `Angular - ${title}` : 'Angular');
}
/** /**
* Destroy the embedded components to avoid memory leaks. * Destroy the embedded components to avoid memory leaks.
*/ */
@ -114,20 +104,53 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
this.embeddedComponentRefs = []; this.embeddedComponentRefs = [];
} }
/**
* Prepare for setting the window title and ToC.
* Return a function to actually set them.
*/
protected prepareTitleAndToc(targetElem: HTMLElement, docId: string): () => void {
const titleEl = targetElem.querySelector('h1');
const hasToc = !!titleEl && !/no-?toc/i.test(titleEl.className);
if (hasToc) {
titleEl.insertAdjacentHTML('afterend', '<aio-toc class="embedded"></aio-toc>');
}
return () => {
this.tocService.reset();
let title = '';
// Only create ToC for docs with an `<h1>` heading.
// If you don't want a ToC, add "no-toc" class to `<h1>`.
if (titleEl) {
title = (typeof titleEl.innerText === 'string') ? titleEl.innerText : titleEl.textContent;
if (hasToc) {
this.tocService.genToc(targetElem, docId);
}
}
this.titleService.setTitle(title ? `Angular - ${title}` : 'Angular');
};
}
/** /**
* Add doc content to host element and build it out with embedded components. * Add doc content to host element and build it out with embedded components.
*/ */
protected render(doc: DocumentContents): Observable<void> { protected render(doc: DocumentContents): Observable<void> {
let addTitleAndToc: () => void;
return this.void$ return this.void$
.do(() => { // Security: `doc.contents` is always authored by the documentation team
// Security: `doc.contents` is always authored by the documentation team // and is considered to be safe.
// and is considered to be safe. .do(() => this.nextViewContainer.innerHTML = doc.contents || '')
this.nextViewContainer.innerHTML = doc.contents || ''; .do(() => addTitleAndToc = this.prepareTitleAndToc(this.nextViewContainer, doc.id))
})
.switchMap(() => this.embedComponentsService.embedInto(this.nextViewContainer)) .switchMap(() => this.embedComponentsService.embedInto(this.nextViewContainer))
.do(() => this.docReady.emit())
.do(() => this.destroyEmbeddedComponents())
.do(componentRefs => this.embeddedComponentRefs = componentRefs) .do(componentRefs => this.embeddedComponentRefs = componentRefs)
.switchMap(() => this.swapViews()) .switchMap(() => this.swapViews(addTitleAndToc))
.do(() => this.addTitleAndToc(doc.id)) .do(() => this.docRendered.emit())
.catch(err => { .catch(err => {
this.nextViewContainer.innerHTML = ''; this.nextViewContainer.innerHTML = '';
this.logger.error(`[DocViewer]: Error preparing document '${doc.id}'.`, err); this.logger.error(`[DocViewer]: Error preparing document '${doc.id}'.`, err);
@ -139,8 +162,12 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
* Swap the views, removing `currViewContainer` and inserting `nextViewContainer`. * Swap the views, removing `currViewContainer` and inserting `nextViewContainer`.
* (At this point all content should be ready, including having loaded and instantiated embedded * (At this point all content should be ready, including having loaded and instantiated embedded
* components.) * components.)
*
* Optionally, run a callback as soon as `nextViewContainer` has been inserted, but before the
* entering animation has been completed. This is useful for work that needs to be done as soon as
* the element has been attached to the DOM.
*/ */
protected swapViews(): Observable<void> { protected swapViews(onInsertedCb = () => undefined): Observable<void> {
const raf$ = new Observable<void>(subscriber => { const raf$ = new Observable<void>(subscriber => {
const rafId = requestAnimationFrame(() => { const rafId = requestAnimationFrame(() => {
subscriber.next(); subscriber.next();
@ -174,12 +201,15 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
done$ = done$ done$ = done$
// Remove the current view from the viewer. // Remove the current view from the viewer.
.switchMap(() => animateLeave(this.currViewContainer)) .switchMap(() => animateLeave(this.currViewContainer))
.do(() => this.currViewContainer.parentElement.removeChild(this.currViewContainer)); .do(() => this.currViewContainer.parentElement.removeChild(this.currViewContainer))
.do(() => this.docRemoved.emit());
} }
return done$ return done$
// Insert the next view into the viewer. // Insert the next view into the viewer.
.do(() => this.hostElement.appendChild(this.nextViewContainer)) .do(() => this.hostElement.appendChild(this.nextViewContainer))
.do(() => onInsertedCb())
.do(() => this.docInserted.emit())
.switchMap(() => animateEnter(this.nextViewContainer)) .switchMap(() => animateEnter(this.nextViewContainer))
// Update the view references and clean up unused nodes. // Update the view references and clean up unused nodes.
.do(() => { .do(() => {

View File

@ -21,10 +21,10 @@ export class TestDocViewerComponent extends DocViewerComponent {
currViewContainer: HTMLElement; currViewContainer: HTMLElement;
nextViewContainer: HTMLElement; nextViewContainer: HTMLElement;
addTitleAndToc(docId: string): void { return null as any; }
destroyEmbeddedComponents(): void { return null as any; } destroyEmbeddedComponents(): void { return null as any; }
prepareTitleAndToc(targetElem: HTMLElement, docId: string): () => void { return null as any; }
render(doc: DocumentContents): Observable<void> { return null as any; } render(doc: DocumentContents): Observable<void> { return null as any; }
swapViews(): Observable<void> { return null as any; } swapViews(onInsertedCb?: () => void): Observable<void> { return null as any; }
} }