diff --git a/aio/src/app/app.component.html b/aio/src/app/app.component.html
index a5f35a92ac..b65a14ec00 100644
--- a/aio/src/app/app.component.html
+++ b/aio/src/app/app.component.html
@@ -14,7 +14,7 @@
\ No newline at end of file
diff --git a/aio/src/app/app.component.spec.ts b/aio/src/app/app.component.spec.ts
index f8c9312d54..17edc77854 100644
--- a/aio/src/app/app.component.spec.ts
+++ b/aio/src/app/app.component.spec.ts
@@ -7,6 +7,7 @@ import { GaService } from 'app/shared/ga.service';
import { SearchService } from 'app/search/search.service';
import { SearchResultsComponent } from 'app/search/search-results/search-results.component';
import { SearchBoxComponent } from 'app/search/search-box/search-box.component';
+import { AutoScrollService } from 'app/shared/auto-scroll.service';
import { MockSearchService } from 'testing/search.service';
import { LocationService } from 'app/shared/location.service';
import { MockLocationService } from 'testing/location.service';
@@ -32,6 +33,7 @@ describe('AppComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(AppComponent);
component = fixture.componentInstance;
+ fixture.detectChanges();
});
it('should create', () => {
@@ -70,6 +72,23 @@ describe('AppComponent', () => {
console.log('PENDING: AppComponent navigationViews');
});
+ describe('autoScrolling', () => {
+ it('should AutoScrollService.scroll when the url changes', () => {
+ const locationService: MockLocationService = fixture.debugElement.injector.get(LocationService) as any;
+ const scrollService: AutoScrollService = fixture.debugElement.injector.get(AutoScrollService);
+ spyOn(scrollService, 'scroll');
+ locationService.urlSubject.next('some/url#fragment');
+ expect(scrollService.scroll).toHaveBeenCalledWith(jasmine.any(HTMLElement));
+ });
+
+ it('should be called when a document has been rendered', () => {
+ const scrollService: AutoScrollService = fixture.debugElement.injector.get(AutoScrollService);
+ spyOn(scrollService, 'scroll');
+ component.onDocRendered(null);
+ expect(scrollService.scroll).toHaveBeenCalledWith(jasmine.any(HTMLElement));
+ });
+ });
+
describe('initialisation', () => {
it('should initialize the search worker', inject([SearchService], (searchService: SearchService) => {
fixture.detectChanges(); // triggers ngOnInit
diff --git a/aio/src/app/app.component.ts b/aio/src/app/app.component.ts
index cd7e8a9e5c..688d7a6cee 100644
--- a/aio/src/app/app.component.ts
+++ b/aio/src/app/app.component.ts
@@ -4,9 +4,11 @@ import { Observable } from 'rxjs/Observable';
import { GaService } from 'app/shared/ga.service';
import { LocationService } from 'app/shared/location.service';
import { DocumentService, DocumentContents } from 'app/documents/document.service';
+import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
import { NavigationService, NavigationViews, NavigationNode } from 'app/navigation/navigation.service';
import { SearchService } from 'app/search/search.service';
import { SearchResultsComponent } from 'app/search/search-results/search-results.component';
+import { AutoScrollService } from 'app/shared/auto-scroll.service';
@Component({
selector: 'aio-shell',
@@ -29,12 +31,16 @@ export class AppComponent implements OnInit {
@ViewChild(SearchResultsComponent)
searchResults: SearchResultsComponent;
- constructor(
- documentService: DocumentService,
- gaService: GaService,
- private locationService: LocationService,
- navigationService: NavigationService,
- private searchService: SearchService) {
+ // We need the doc-viewer element for scrolling the contents
+ @ViewChild(DocViewerComponent, { read: ElementRef })
+ docViewer: ElementRef;
+
+ constructor(documentService: DocumentService,
+ gaService: GaService,
+ navigationService: NavigationService,
+ private autoScroll: AutoScrollService,
+ private locationService: LocationService,
+ private searchService: SearchService) {
this.currentDocument = documentService.currentDocument;
locationService.currentUrl.subscribe(url => gaService.locationChanged(url));
this.navigationViews = navigationService.navigationViews;
@@ -46,6 +52,18 @@ export class AppComponent implements OnInit {
this.searchService.loadIndex();
this.onResize(window.innerWidth);
+
+ // The url changed, so scroll to the anchor in the hash fragment.
+ // This subscription is needed when navigating between anchors within a document
+ // and the document itself has not changed
+ this.locationService.currentUrl.subscribe(url => this.autoScroll.scroll(this.docViewer.nativeElement.offsetParent));
+ }
+
+ onDocRendered(doc: DocumentContents) {
+ // A new document has been rendered, so scroll to the anchor in the hash fragment.
+ // This handler is needed because the subscription to the `currentUrl` in `ngOnInit`
+ // gets triggered too early before the doc-viewer has finished rendering the doc
+ this.autoScroll.scroll(this.docViewer.nativeElement.offsetParent);
}
@HostListener('window:resize', ['$event.target.innerWidth'])
diff --git a/aio/src/app/app.module.ts b/aio/src/app/app.module.ts
index 0f2d94641c..13781a4e86 100644
--- a/aio/src/app/app.module.ts
+++ b/aio/src/app/app.module.ts
@@ -30,6 +30,7 @@ import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
import { NavItemComponent } from 'app/layout/nav-item/nav-item.component';
import { SearchResultsComponent } from './search/search-results/search-results.component';
import { SearchBoxComponent } from './search/search-box/search-box.component';
+import { AutoScrollService } from 'app/shared/auto-scroll.service';
@NgModule({
imports: [
@@ -62,7 +63,8 @@ import { SearchBoxComponent } from './search/search-box/search-box.component';
NavigationService,
DocumentService,
SearchService,
- Platform
+ Platform,
+ AutoScrollService,
],
entryComponents: [ embeddedComponents ],
bootstrap: [AppComponent]
diff --git a/aio/src/app/layout/doc-viewer/doc-viewer.component.ts b/aio/src/app/layout/doc-viewer/doc-viewer.component.ts
index c70848fa26..a81c407bc9 100644
--- a/aio/src/app/layout/doc-viewer/doc-viewer.component.ts
+++ b/aio/src/app/layout/doc-viewer/doc-viewer.component.ts
@@ -1,6 +1,7 @@
import {
Component, ComponentFactory, ComponentFactoryResolver, ComponentRef,
- DoCheck, ElementRef, Injector, Input, OnDestroy, ViewEncapsulation
+ DoCheck, ElementRef, EventEmitter, Injector, Input, OnDestroy,
+ Output, ViewEncapsulation
} from '@angular/core';
import { EmbeddedComponents } from 'app/embedded';
@@ -33,6 +34,9 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
private embeddedComponentFactories: Map = new Map();
private hostElement: HTMLElement;
+ @Output()
+ docRendered = new EventEmitter();
+
constructor(
componentFactoryResolver: ComponentFactoryResolver,
elementRef: ElementRef,
@@ -55,8 +59,8 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
set doc(newDoc: DocumentContents) {
this.ngOnDestroy();
if (newDoc) {
- window.scrollTo(0, 0);
this.build(newDoc);
+ this.docRendered.emit(newDoc);
}
}
diff --git a/aio/src/app/shared/auto-scroll.service.spec.ts b/aio/src/app/shared/auto-scroll.service.spec.ts
new file mode 100644
index 0000000000..6fb5309241
--- /dev/null
+++ b/aio/src/app/shared/auto-scroll.service.spec.ts
@@ -0,0 +1,64 @@
+import { ReflectiveInjector } from '@angular/core';
+import { PlatformLocation } from '@angular/common';
+import { DOCUMENT } from '@angular/platform-browser';
+import { AutoScrollService } from './auto-scroll.service';
+
+
+describe('AutoScrollService', () => {
+ let injector: ReflectiveInjector,
+ autoScroll: AutoScrollService,
+ container: HTMLElement,
+ location: MockPlatformLocation,
+ document: MockDocument;
+
+ class MockPlatformLocation {
+ hash: string;
+ }
+
+ class MockDocument {
+ getElementById = jasmine.createSpy('Document getElementById');
+ }
+
+ class MockElement {
+ scrollIntoView = jasmine.createSpy('Element scrollIntoView');
+ }
+
+ beforeEach(() => {
+ injector = ReflectiveInjector.resolveAndCreate([
+ AutoScrollService,
+ { provide: DOCUMENT, useClass: MockDocument },
+ { provide: PlatformLocation, useClass: MockPlatformLocation }
+ ]);
+ location = injector.get(PlatformLocation);
+ document = injector.get(DOCUMENT);
+ container = window.document.createElement('div');
+ container.scrollTop = 100;
+ autoScroll = injector.get(AutoScrollService);
+ });
+
+ it('should scroll the container to the top if there is no hash', () => {
+ location.hash = '';
+
+ autoScroll.scroll(container);
+ expect(container.scrollTop).toEqual(0);
+ });
+
+ it('should scroll the container to the top if the hash does not match an element id', () => {
+ location.hash = 'some-id';
+ document.getElementById.and.returnValue(null);
+
+ autoScroll.scroll(container);
+ expect(document.getElementById).toHaveBeenCalledWith('some-id');
+ expect(container.scrollTop).toEqual(0);
+ });
+
+ it('should scroll the container to the element whose id matches the hash', () => {
+ const element = new MockElement();
+ location.hash = 'some-id';
+ document.getElementById.and.returnValue(element);
+
+ autoScroll.scroll(container);
+ expect(document.getElementById).toHaveBeenCalledWith('some-id');
+ expect(element.scrollIntoView).toHaveBeenCalled();
+ });
+});
diff --git a/aio/src/app/shared/auto-scroll.service.ts b/aio/src/app/shared/auto-scroll.service.ts
new file mode 100644
index 0000000000..e99efb6375
--- /dev/null
+++ b/aio/src/app/shared/auto-scroll.service.ts
@@ -0,0 +1,36 @@
+import { Injectable, Inject, InjectionToken } from '@angular/core';
+import { PlatformLocation } from '@angular/common';
+import { DOCUMENT } from '@angular/platform-browser';
+
+/**
+ * A service that supports automatically scrolling elements into view
+ */
+@Injectable()
+export class AutoScrollService {
+
+ constructor(
+ @Inject(DOCUMENT) private document: any,
+ private location: PlatformLocation) { }
+
+ /**
+ * Scroll the contents of the container
+ * to the element with id extracted from the current location hash fragment
+ */
+ scroll(container: HTMLElement) {
+ const hash = this.getCurrentHash();
+ const element: HTMLElement = this.document.getElementById(hash);
+ if (element) {
+ element.scrollIntoView();
+ } else {
+ container.scrollTop = 0;
+ }
+ }
+
+ /**
+ * We can get the hash fragment from the `PlatformLocation` but
+ * it needs the `#` char removing from the front.
+ */
+ private getCurrentHash() {
+ return this.location.hash.replace(/^#/, '');
+ }
+}