Skip to content

Commit

Permalink
Handle links in XML text values (#202)
Browse files Browse the repository at this point in the history
* feature (viewer): handle links in XML text values

* feature (viewer): add listeners for internal links

* feature (viewer): add @outputs to propagate events

* tests (viewer): add specs for TextValueHtmlLinkDirective

* tests (viewer): add specs for TextValueHtmlLinkDirective

* tests (viewer): deactivate test

* docs (design docs): clarify handling of internal links

* tests (viewer): reactivate test

* tests (viewer): adapt test for external link

* tests (viewer): adapt test for external link

* refactor (viewer): rename method
  • Loading branch information
tobiasschweizer committed Oct 7, 2020
1 parent 9a5b537 commit c0522e6
Show file tree
Hide file tree
Showing 5 changed files with 194 additions and 21 deletions.
27 changes: 8 additions & 19 deletions projects/dsp-ui/design-documentation.md
Expand Up @@ -134,29 +134,18 @@ Value components may have additional specific inputs for configuration that can

#### Integration of CKEditor

### General Setup

To edit XML, the viewer module relies on CKEditor. `TextValueAsXMLComponent` integrates the CKEditor library for Angular.
In addition, a custom build of CKEditor is needed which is accessible on [GitHub](https://github.com/dasch-swiss/ckeditor_custom_build).
To make a new custom build, follow the [instructions](https://github.com/dasch-swiss/ckeditor_custom_build/blob/master/how-to-build.md).
To add support for a specific mapping, a configuration object has to be added to the config file such as in the following example:

```json
"xmlTransform": {
"http://rdfh.ch/standoff/mappings/StandardMapping": {
"<hr>": "<hr/>",
"</hr>": "",
"<s>": "<strike>",
"</s>": "</strike>",
"<i>": "<em>",
"</i>": "</em>",
"<figure class=\"table\">": "",
"</figure>": ""
}
}
```

The tags on the left will be replaced with the tags on the right,
e.g. CKEditor's `<s>` will be converted to a `<strike` which is expected by Knora.
Note that this is a simple search-and-replace algorithm which does not make use of an XML parser.
Note that currently only the standard mapping is supported.

### Handling Internal Links When Displaying Text

When a text created with CKEditor is shown in read-mode, click and hover events on internal links can be reacted to by applying the directive `TextValueHtmlLinkDirective` with the selector `dspHtmlLink`.
Internal links have the class "salsah-link".

## Search Module

Expand Down
@@ -0,0 +1,135 @@
import { Component } from '@angular/core';
import { TextValueHtmlLinkDirective } from './text-value-html-link.directive';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { By } from '@angular/platform-browser';

/**
* Test host component to simulate parent component.
*/
@Component({
template: `
<div [innerHTML]="html" dspHtmlLink (internalLinkClicked)="clicked($event)" (internalLinkHovered)="hovered($event)"></div>`
})
class TestHostComponent {

// the href attribute of the external link is empty
// because otherwise the test browser would attempt to access it
html =
'This is a test <a>external link</a> and a test <a class="salsah-link" href="http://rdfh.ch/0001/H6gBWUuJSuuO-CilHV8kQw">internal link</a>';

internalLinkClickedIri: string;

internalLinkHoveredIri: string;

clicked(iri: string) {
this.internalLinkClickedIri = iri;
}

hovered(iri: string) {
this.internalLinkHoveredIri = iri;
}
}

describe('TextValueHtmlLinkDirective', () => {
let testHostComponent: TestHostComponent;
let testHostFixture: ComponentFixture<TestHostComponent>;

beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
BrowserAnimationsModule
],
declarations: [
TextValueHtmlLinkDirective,
TestHostComponent
]
}).compileComponents();

}));

beforeEach(() => {

testHostFixture = TestBed.createComponent(TestHostComponent);
testHostComponent = testHostFixture.componentInstance;
testHostFixture.detectChanges();

expect(testHostComponent).toBeTruthy();
});

it('should create an instance', () => {
expect(testHostComponent).toBeTruthy();
});

it('should react to clicking on an internal link', () => {
expect(testHostComponent).toBeTruthy();

const hostCompDe = testHostFixture.debugElement;
const directiveDe = hostCompDe.query(By.directive(TextValueHtmlLinkDirective));

const internalLinkDe = directiveDe.query(By.css('a.salsah-link'));

internalLinkDe.nativeElement.click();

testHostFixture.detectChanges();

expect(testHostComponent.internalLinkClickedIri).toEqual('http://rdfh.ch/0001/H6gBWUuJSuuO-CilHV8kQw');

});

it('should not react to clicking on an external link', () => {
expect(testHostComponent).toBeTruthy();

const hostCompDe = testHostFixture.debugElement;
const directiveDe = hostCompDe.query(By.directive(TextValueHtmlLinkDirective));

const externalLinkDe = directiveDe.query(By.css('a:not(.salsah-link)'));

externalLinkDe.nativeElement.click();

testHostFixture.detectChanges();

expect(testHostComponent.internalLinkClickedIri).toBeUndefined();

});

it('should react to hovering over an internal link', () => {
expect(testHostComponent).toBeTruthy();

const hostCompDe = testHostFixture.debugElement;
const directiveDe = hostCompDe.query(By.directive(TextValueHtmlLinkDirective));

const internalLinkDe = directiveDe.query(By.css('a.salsah-link'));

internalLinkDe.nativeElement.dispatchEvent(new MouseEvent('mouseover', {
view: window,
bubbles: true,
cancelable: true
}));

testHostFixture.detectChanges();

expect(testHostComponent.internalLinkHoveredIri).toEqual('http://rdfh.ch/0001/H6gBWUuJSuuO-CilHV8kQw');

});

it('should not react to hovering over an external link', () => {
expect(testHostComponent).toBeTruthy();

const hostCompDe = testHostFixture.debugElement;
const directiveDe = hostCompDe.query(By.directive(TextValueHtmlLinkDirective));

const externalLinkDe = directiveDe.query(By.css('a:not(.salsah-link)'));

externalLinkDe.nativeElement.dispatchEvent(new MouseEvent('mouseover', {
view: window,
bubbles: true,
cancelable: true
}));

testHostFixture.detectChanges();

expect(testHostComponent.internalLinkHoveredIri).toBeUndefined();

});
});
@@ -0,0 +1,44 @@
import { Directive, EventEmitter, HostListener, Output } from '@angular/core';
import { Constants } from '@dasch-swiss/dsp-js';

@Directive({
selector: '[dspHtmlLink]'
})
export class TextValueHtmlLinkDirective {

@Output() internalLinkClicked = new EventEmitter<string>();
@Output() internalLinkHovered = new EventEmitter<string>();

/**
* React to a click event for an internal link.
*
* @param targetElement the element that was clicked.
*/
@HostListener('click', ['$event.target'])
onClick(targetElement) {
if (targetElement.nodeName.toLowerCase() === 'a'
&& targetElement.className.toLowerCase().indexOf(Constants.SalsahLink) !== -1) {
this.internalLinkClicked.emit(targetElement.href);

// preventDefault (propagation)
return false;
}
}

/**
* React to a mouseover event for an internal link.
*
* @param targetElement the element that was hovered.
*/
@HostListener('mouseover', ['$event.target'])
onMouseOver(targetElement) {
if (targetElement.nodeName.toLowerCase() === 'a'
&& targetElement.className.toLowerCase().indexOf(Constants.SalsahLink) !== -1) {
this.internalLinkHovered.emit(targetElement.href);

// preventDefault (propagation)
return false;
}
}

}
2 changes: 2 additions & 0 deletions projects/dsp-ui/src/lib/viewer/index.ts
Expand Up @@ -30,3 +30,5 @@ export * from './views/list-view/resource-grid/resource-grid.component';
export * from './representation/still-image/still-image.component';
// services
export * from './services/value-type.service';
// directives
export * from './directives/text-value-html-link.directive';
7 changes: 5 additions & 2 deletions projects/dsp-ui/src/lib/viewer/viewer.module.ts
Expand Up @@ -52,6 +52,7 @@ import { ResourceListComponent } from './views/list-view/resource-list/resource-
import { PropertyToolbarComponent } from './views/property-view/property-toolbar/property-toolbar.component';
import { PropertyViewComponent } from './views/property-view/property-view.component';
import { ResourceViewComponent } from './views/resource-view/resource-view.component';
import { TextValueHtmlLinkDirective } from './directives/text-value-html-link.directive';


@NgModule({
Expand Down Expand Up @@ -85,7 +86,8 @@ import { ResourceViewComponent } from './views/resource-view/resource-view.compo
TextValueAsStringComponent,
TimeInputComponent,
TimeValueComponent,
UriValueComponent
UriValueComponent,
TextValueHtmlLinkDirective
],
imports: [
ClipboardModule,
Expand Down Expand Up @@ -136,7 +138,8 @@ import { ResourceViewComponent } from './views/resource-view/resource-view.compo
TextValueAsHtmlComponent,
TextValueAsStringComponent,
TimeValueComponent,
UriValueComponent
UriValueComponent,
TextValueHtmlLinkDirective
]
})
export class DspViewerModule {
Expand Down

0 comments on commit c0522e6

Please sign in to comment.