Skip to content

Commit

Permalink
fix: internal links and external work with ckeditor footnotes (#2084)
Browse files Browse the repository at this point in the history
  • Loading branch information
derschnee68 authored Feb 6, 2025
1 parent 27165aa commit 58676b9
Show file tree
Hide file tree
Showing 14 changed files with 128 additions and 109 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ <h3 matLine class="res-class-value shorten-multiline-text">{{ resource.label }}<
<span class="res-prop-label shorten-long-text">
{{ resource.entityInfo.properties[val.property].label }}
</span>
<div class="shorten-long-text" [innerHtml]="val.strval | internalLinkReplacer"></div>
<div class="shorten-long-text" [innerHtml]="val.strval | internalLinkReplacer | addTargetBlank"></div>
</div>
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions libs/vre/resource-editor/resource-properties/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@ export * from './lib/footnote.service';
export * from './lib/footnotes.component';
export * from './lib/footnote-tooltip.component';
export * from './lib/footnote.directive';
export * from './lib/footnote-parser.pipe';
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { FootnoteService } from './footnote.service';

@Pipe({
name: 'footnoteParser',
})
export class FootnoteParserPipe implements PipeTransform {
private readonly _footnoteRegExp = /<footnote content="([^>]+)">([^<]*)<\/footnote>/g;

constructor(
private _sanitizer: DomSanitizer,
private _footnoteService: FootnoteService
) {}

transform(value: null | string): SafeHtml | null {
if (value === null) {
return value; // Return as is if value is empty or null
} // does nothing if only displayMode changes

if (!this._containsFootnote(value)) {
return this._sanitizer.bypassSecurityTrustHtml(value);
}

return this._parseFootnotes(value);
}

private _containsFootnote(text: string) {
return text.match(this._footnoteRegExp) !== null;
}

private _parseFootnotes(controlValue: string): SafeHtml {
const matches = controlValue.matchAll(this._footnoteRegExp);
let newValue = controlValue;
if (matches) {
Array.from(matches).forEach(matchArray => {
const uuid = window.crypto.getRandomValues(new Uint32Array(1))[0].toString();
const parsedFootnote = `<footnote content="${matchArray[1]}" id="${uuid}">${this._footnoteService.footnotes.length + 1}</footnote>`;
newValue = newValue.replace(matchArray[0], parsedFootnote);
this._footnoteService.addFootnote(
uuid,
this._sanitizer.bypassSecurityTrustHtml(this._unescapeHtml(this._unescapeHtml(matchArray[1])))
);
});
}
return this._sanitizer.bypassSecurityTrustHtml(this._unescapeHtml(newValue));
}

private _unescapeHtml(str: string) {
const unescapeMap = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#039;': "'",
};
return str.replace(/&(amp|lt|gt|quot|#039);/g, match => unescapeMap[match]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { SafeHtml } from '@angular/platform-browser';
@Component({
selector: 'app-footnote-tooltip',
template: ` <div class="content" [@fadeIn]="'in'">
<div [innerHTML]="content"></div>
<div [innerHTML]="content | internalLinkReplacer | addTargetBlank"></div>
</div>`,
animations: [
trigger('fadeIn', [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ export class FootnoteDirective {
const targetFootnote = document.querySelector(`.footnote[data-uuid="${uuid}"]`);

if (targetFootnote) {
console.log('d', event);
// Scroll to the target footnote element
targetFootnote.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { FootnoteService } from './footnote.service';
[attr.data-uuid]="footnote.uuid"
data-cy="footnote">
<a style="padding-top: 1em" (click)="goToFootnote(footnote.uuid)">{{ index + 1 }}.</a>
<span [innerHTML]="footnote.content"></span>
<span [innerHTML]="footnote.content | internalLinkReplacer | addTargetBlank"></span>
</div>`,
styles: [
`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
import { Component, Input, OnChanges, Optional, SimpleChanges } from '@angular/core';
import { Component, Input } from '@angular/core';
import { FormControl } from '@angular/forms';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ReadLinkValue } from '@dasch-swiss/dsp-js';
import { ResourceService } from '@dasch-swiss/vre/shared/app-common';
import { Subscription } from 'rxjs';
import { FootnoteService } from '../footnote.service';
import { IsSwitchComponent } from './is-switch-component.interface';

@Component({
selector: 'app-rich-text-switch',
template: ` <div
*ngIf="displayMode; else editMode"
data-cy="rich-text-switch"
[innerHTML]="sanitizedHtml | internalLinkReplacer"
[innerHTML]="control.value | footnoteParser | internalLinkReplacer | addTargetBlank"
appFootnote></div>
<ng-template #editMode>
<app-ck-editor [control]="myControl" />
Expand All @@ -34,79 +29,11 @@ import { IsSwitchComponent } from './is-switch-component.interface';
`,
],
})
export class RichTextSwitchComponent implements IsSwitchComponent, OnChanges {
export class RichTextSwitchComponent implements IsSwitchComponent {
@Input({ required: true }) control!: FormControl<string | null>;
@Input() displayMode = true;

sanitizedHtml!: SafeHtml;
subscription?: Subscription;

private readonly _footnoteRegExp = /<footnote content="([^>]+)">([^<]*)<\/footnote>/g;

get myControl() {
return this.control as FormControl<string>;
}

constructor(
private _resourceService: ResourceService,
private _sanitizer: DomSanitizer,
@Optional() private _footnoteService: FootnoteService
) {}

ngOnChanges(changes: SimpleChanges) {
// does nothing if only displayMode changes
if (!changes['control'] && changes['displayMode'].currentValue === !changes['displayMode'].previousValue) {
return;
}

if (this.control.value === null) {
this.sanitizedHtml = this._sanitizer.bypassSecurityTrustHtml('');
return;
}

if (!this._containsFootnote(this.control.value)) {
this.sanitizedHtml = this._sanitizer.bypassSecurityTrustHtml(this.control.value);
return;
}

this._parseFootnotes(this.control.value);
}

openResource(linkValue: ReadLinkValue | string) {
const iri = typeof linkValue == 'string' ? linkValue : linkValue.linkedResourceIri;
const path = this._resourceService.getResourcePath(iri);
window.open(`/resource${path}`, '_blank');
}

private _containsFootnote(text: string) {
return text.match(this._footnoteRegExp) !== null;
}

private _parseFootnotes(controlValue: string) {
const matches = controlValue.matchAll(this._footnoteRegExp);
let newValue = controlValue;
if (matches) {
Array.from(matches).forEach(matchArray => {
const uuid = window.crypto.getRandomValues(new Uint32Array(1))[0].toString();
const parsedFootnote = `<footnote content="${matchArray[1]}" id="${uuid}">${this._footnoteService.footnotes.length + 1}</footnote>`;
newValue = newValue.replace(matchArray[0], parsedFootnote);
this._footnoteService.addFootnote(
uuid,
this._sanitizer.bypassSecurityTrustHtml(this._unescapeHtml(this._unescapeHtml(matchArray[1])))
);
});
}
this.sanitizedHtml = this._sanitizer.bypassSecurityTrustHtml(this._unescapeHtml(newValue));
}

private _unescapeHtml(str: string) {
const unescapeMap = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#039;': "'",
};
return str.replace(/&(amp|lt|gt|quot|#039);/g, match => unescapeMap[match]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { IsSwitchComponent } from './is-switch-component.interface';
template: ` <div
*ngIf="displayMode; else editMode"
data-cy="text-html-switch"
[innerHTML]="control.value | internalLinkReplacer"
[innerHTML]="control.value | internalLinkReplacer | addTargetBlank"
appMathjax></div>
<ng-template #editMode> This value cannot be edited.</ng-template>`,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { DeleteValueDialogComponent } from './lib/delete-value-dialog.component'
import { EditResourceLabelDialogComponent } from './lib/edit-resource-label-dialog.component';
import { EraseResourceDialogComponent } from './lib/erase-resource-dialog.component';
import { ExistingPropertyValueComponent } from './lib/existing-property-value.component';
import { FootnoteParserPipe } from './lib/footnote-parser.pipe';
import { FootnoteTooltipComponent } from './lib/footnote-tooltip.component';
import { FootnoteDirective } from './lib/footnote.directive';
import { FootnotesComponent } from './lib/footnotes.component';
Expand Down Expand Up @@ -92,4 +93,5 @@ export const ResourcePropertiesComponents = [
FootnotesComponent,
FootnoteTooltipComponent,
FootnoteDirective,
FootnoteParserPipe,
];
1 change: 1 addition & 0 deletions libs/vre/ui/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ export * from './lib/pipes/string-transformation/linkify.pipe';
export * from './lib/hint/hint.component';
export * from './lib/validator-error.interface';
export * from './lib/pipes/internal-link-replacer.pipe';
export * from './lib/pipes/add-target-blank.pipe';
17 changes: 0 additions & 17 deletions libs/vre/ui/ui/src/lib/ck-editor/ck-editor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { Constants } from '@dasch-swiss/dsp-js';

export class ckEditor {
static config = {
entities: false,
Expand Down Expand Up @@ -59,21 +57,6 @@ export class ckEditor {
languages: [{ language: 'plaintext', label: 'Plain text', class: '' }],
},
language: 'en',
link: {
addTargetToExternalLinks: false,
decorators: {
isInternal: {
// label: 'internal link to a Knora resource',
mode: 'automatic', // automatic requires callback -> but the callback is async and the user could save the text before the check ...
callback: (
url: string // console.log(url, url.startsWith( 'http://rdfh.ch/' ));
) => !!url && url.startsWith('http://rdfh.ch/'), // --> TODO: get this from config via AppInitService
attributes: {
class: Constants.SalsahLink,
},
},
},
},
table: {
contentToolbar: ['tableColumn', 'tableRow'], // mergeTableCells is not supported by the backend due to colspan html tag mapping.
},
Expand Down
32 changes: 32 additions & 0 deletions libs/vre/ui/ui/src/lib/pipes/add-target-blank.pipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';

@Pipe({
name: 'addTargetBlank',
})
export class AddTargetBlankPipe implements PipeTransform {
constructor(private sanitizer: DomSanitizer) {}

transform(value: null | string | SafeHtml): SafeHtml | null {
if (value === null) {
return value; // Return as is if value is empty or null
}

const htmlString = typeof value === 'string' ? value : value['changingThisBreaksApplicationSecurity'];

// Create a temporary DOM element to manipulate the HTML string
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlString;

// Find all <a> tags and add `target="_blank"` if it doesn't exist
const links = tempDiv.querySelectorAll('a');
links.forEach((link: HTMLAnchorElement) => {
if (!link.hasAttribute('target')) {
link.setAttribute('target', '_blank');
}
});

// Return the modified HTML string
return this.sanitizer.bypassSecurityTrustHtml(tempDiv.innerHTML);
}
}
35 changes: 24 additions & 11 deletions libs/vre/ui/ui/src/lib/pipes/internal-link-replacer.pipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,31 @@ export class InternalLinkReplacerPipe implements PipeTransform {
return value; // Return as is if value is empty or null
}

let htmlString = typeof value === 'string' ? value : value['changingThisBreaksApplicationSecurity'];

// Replace href="http://rdfh/..." with href="/..."
htmlString = htmlString.replace(
/<a\s+href="http:\/\/rdfh\.ch\/([^"]+)".*>([^<]+)<\/a>/g,
(_: any, path: string, content: string) => {
const newHref = `href="/resource/${path}"`; // Replace 'rdfh/' with '/'
return `<a ${newHref} target="_blank">${content}</a>`;
// Convert SafeHtml back to string if needed
const htmlString = typeof value === 'string' ? value : value['changingThisBreaksApplicationSecurity'];

// Create a temporary div element to parse and manipulate the HTML
const div = document.createElement('div');
div.innerHTML = htmlString;

// Find all <a> tags
const links = div.querySelectorAll('a');

// Iterate over each <a> tag and modify the href
links.forEach((link: HTMLAnchorElement) => {
const href = link.getAttribute('href');

// If the href starts with "http://rdfh.ch/...", replace it with "/resource/..."
if (href && href.startsWith('http://rdfh.ch/')) {
const path = href.replace('http://rdfh.ch/', '');
link.setAttribute('href', `/resource/${path}`);
}
);
});

// Get the updated HTML content
const updatedHtml = div.innerHTML;

// Sanitize the modified HTML string back to SafeHtml
return this.sanitizer.bypassSecurityTrustHtml(htmlString);
// Sanitize and return the updated HTML string as SafeHtml
return this.sanitizer.bypassSecurityTrustHtml(updatedHtml);
}
}
2 changes: 2 additions & 0 deletions libs/vre/ui/ui/src/ui.components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { HintComponent } from './lib/hint/hint.component';
import { IncomingResourcePagerComponent } from './lib/incoming-resource-pager/incoming-resource-pager.component';
import { InvalidControlScrollDirective } from './lib/invalid-control-scroll.directive';
import { PagerComponent } from './lib/pager/pager.component';
import { AddTargetBlankPipe } from './lib/pipes/add-target-blank.pipe';
import { KnoraDatePipe } from './lib/pipes/formatting/knoradate.pipe';
import { InternalLinkReplacerPipe } from './lib/pipes/internal-link-replacer.pipe';
import { IsFalsyPipe } from './lib/pipes/isFalsy.piipe';
Expand Down Expand Up @@ -44,5 +45,6 @@ export const UiComponents = [
TruncatePipe,
HintComponent,
InternalLinkReplacerPipe,
AddTargetBlankPipe,
];
export const UiStandaloneComponents = [PagerComponent, IncomingResourcePagerComponent];

0 comments on commit 58676b9

Please sign in to comment.