-
-
Notifications
You must be signed in to change notification settings - Fork 181
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Angularize the output markdown input for the purpose of links #125
Comments
+1. I'm currently stuck because of this. Would love to see it added or hear of a work around. |
+1. Need this functionality |
Since the angular compiler isn't included when you use AOT compilation (which I wanted) I came up with this alternative. <div markdown #postDiv [src]="'./assets/blog/blog.md'" (load)="onMarkdownLoad($event);"></div> const isAbsolute = new RegExp('(?:^[a-z][a-z0-9+.-]*:|\/\/)', 'i');
export class MainComponent implements OnInit, OnDestroy {
private listenObj: any;
@ViewChild('postDiv', {static: false})
private postDiv: MarkdownComponent;
constructor(private markdownService: MarkdownService, private renderer: Renderer2, private router: Router,) { }
public onMarkdownLoad() {
// because MarkdownComponent isn't 'compiled' the links don't use the angular router,
// so I'll catch the link click events here and pass them to the router...
if (this.postDiv) {
this.listenObj = this.renderer.listen(this.postDiv.element.nativeElement, 'click', (e: Event) => {
if (e.target && (e.target as any).tagName === 'A') {
const el = (e.target as HTMLElement);
const linkURL = el.getAttribute && el.getAttribute('href');
if (linkURL && !isAbsolute.test(linkURL)) {
e.preventDefault();
this.router.navigate([linkURL]);
}
}
});
}
}
ngOnInit() { }
ngOnDestroy(): void {
if (this.listenObj) {
this.listenObj();
}
}
} |
Hi fellas, I just want you guys to know that this will be my next priority when I'll get some time to work on the library (which is pretty hard these days). In the meanwhile, if any of you want to jump into the wagon, please feel free to contribute as this might not be an easy one! Thanks for understanding. |
@jfcere, I'll have some free time mid-week and I'd be more than glad to help with this. If I'm not mistaken, the main issue is that However, I found this StackOverflow thread. If this works, then the markdown component could do something like this. import {RouterLink} from '@angular/router';
// code...
export class MarkdownComponent {
// code...
@HostBinding('attr.routerLink') dynamicRouterLink = new RouterLink(this.elementRef);
} I'll test this as soon as I can. But most likely that will be in a couple of days. If someone can test it out sooner that would help. Edit: It seems like the RouterLink does not have a |
Hey guys I'm stuck too because of that (need of Has anyone found a good workaround? |
This is very much a hack, but what we use to work around this limitation import { ElementRef, Injectable } from '@angular/core';
import { Router } from '@angular/router';
const WIRED_LINK_ATTRIBUTE = '_ngx_markdown_rewired_link';
/**
* !!! HERE BE DRAGONS !!!
* @hack in order to support local links created from markdown rather than shipping the entire angular compiler just
* to compile a <a [routerLink]=""> directive, instead we attach a custom event handler to dispatch a router navigation
* event.
* This is truly awful and if a better solution exists, slay this beast! Maybe the Ivy compiler will be our saviour?
*/
@Injectable({ providedIn: 'root' })
export class LinkHandlerService {
constructor(private router: Router) {}
private linkShouldBeWired(link: HTMLAnchorElement): boolean {
const attributeNames = link.getAttributeNames();
const isAngularLink = attributeNames.some(n => n.startsWith('_ngcontent'));
const isAlreadyWired = attributeNames.some(n => n === WIRED_LINK_ATTRIBUTE);
return !isAngularLink && !isAlreadyWired && link.getAttribute('href').startsWith('/');
}
public wireInsertedLinks(element: ElementRef): void {
const allLinks: HTMLAnchorElement[] = Array.from(element.nativeElement.getElementsByTagName('a'));
allLinks.filter(this.linkShouldBeWired).forEach(link => {
link.addEventListener('click', $event => {
$event.preventDefault();
this.router.navigateByUrl(link.getAttribute('href'));
});
link.setAttribute(WIRED_LINK_ATTRIBUTE, '');
});
}
} To use this, inject the service into your component (or directive) that contains links and pass the Note this does not handle relative links; only links starting with |
Hi fellas, Working on the new demo for ngx-markdown I stumbled across this issue. I red workaround propositions and did some reverse-engineering with angular This is not an official solution that is integrated to ngx-markdown yet but this is something I am considering (unless Ivy solves the problem in a more fashionable way). For anybody who end up here, I'd like you to give it a try and comment on how was the integration, if you had issues or any possible improvements that could benefit all of us. AnchorServiceI've created an
import { LocationStrategy } from '@angular/common';
import { Injectable } from '@angular/core';
import { ActivatedRoute, Router, UrlTree } from '@angular/router';
/**
* Service to handle links generated through markdown parsing.
* The following `RouterModule` configuration is required to enabled anchors
* to be scrolled to when URL has a fragment via the Angular router:
* ```
* RouterModule.forRoot(routes, {
* anchorScrolling: 'enabled', // scrolls to the anchor element when the URL has a fragment
* scrollOffset: [0, 64], // scroll offset when scrolling to an element (optional)
* scrollPositionRestoration: 'enabled', // restores the previous scroll position on backward navigation
* })
* ```
* _Refer to [Angular Router documentation](https://angular.io/api/router/ExtraOptions#anchorScrolling) for more details._
*/
@Injectable({ providedIn: 'root' })
export class AnchorService {
constructor(
private locationStrategy: LocationStrategy,
private route: ActivatedRoute,
private router: Router,
) { }
/**
* Intercept clicks on `HTMLAnchorElement` to use `Router.navigate()`
* when `href` is an internal URL not handled by `routerLink` directive.
* @param event The event to evaluated for link click.
*/
interceptClick(event: Event) {
const element = event.target;
if (!(element instanceof HTMLAnchorElement)) {
return;
}
const href = element.getAttribute('href');
if (this.isExternalUrl(href) || this.isRouterLink(element)) {
return;
}
this.navigate(href);
event.preventDefault();
}
/**
* Navigate to URL using angular `Router`.
* @param url Destination path to navigate to.
* @param replaceUrl If `true`, replaces current state in browser history.
*/
navigate(url: string, replaceUrl = false) {
const urlTree = this.getUrlTree(url);
this.router.navigated = false;
this.router.navigateByUrl(urlTree, { replaceUrl });
}
/**
* Transform a relative URL to its absolute representation according to current router state.
* @param url Relative URL path.
* @return Absolute URL based on the current route.
*/
normalizeExternalUrl(url: string): string {
if (this.isExternalUrl(url)) {
return url;
}
const urlTree = this.getUrlTree(url);
const serializedUrl = this.router.serializeUrl(urlTree);
return this.locationStrategy.prepareExternalUrl(serializedUrl);
}
/**
* Scroll view to the anchor corresponding to current route fragment.
*/
scrollToAnchor() {
const url = this.router.parseUrl(this.router.url);
if (url.fragment) {
this.navigate(this.router.url, true);
}
}
private getUrlTree(url: string): UrlTree {
const urlPath = this.stripFragment(url) || this.stripFragment(this.router.url);
const urlFragment = this.router.parseUrl(url).fragment;
return this.router.createUrlTree([urlPath], { relativeTo: this.route, fragment: urlFragment });
}
private isExternalUrl(url: string): boolean {
return /^(?!http(s?):\/\/).+$/.exec(url) == null;
}
private isRouterLink(element: HTMLAnchorElement): boolean {
return element.getAttributeNames().some(n => n.startsWith('_ngcontent'));
}
private stripFragment(url: string): string {
return /[^#]*/.exec(url)[0];
}
} RouterModule configurationThe following app-routing.module.tsRouterModule.forRoot(routes, {
anchorScrolling: 'enabled', // scrolls to the anchor element when the URL has a fragment
scrollOffset: [0, 64], // scroll offset when scrolling to an element (optional)
scrollPositionRestoration: 'enabled', // restores the previous scroll position on backward navigation
})
Intercept link click eventThis is where the magic happens! Using Doing so in the
app.component.ts@HostListener('document:click', ['$event'])
onDocumentClick(event: Event) {
this.anchorService.interceptClick(event);
}
constructor(
private anchorService: AnchorService,
) { } Landing directly on a page with fragment (hash)To be able to scroll to an element when loading the application for the first time when there is a fragment (#hash) in the URL you can call
Fix generated
|
Hi I recently had a similar problem with scrolling to the right position on page load because the markdown component was not fully loaded when the rest of the page already was. Thanks @jfcere for the solution provided above. You wrote there:
Exactly that was my problem. I could not find the correct lifecycle hook to scroll to the markdown element due to loading the content from an external .md file. After a while I found a solution for it in creating a directive that fires an event when the markdown view is initialized properly: Element ready directiveimport { Directive, EventEmitter, Output, AfterViewInit } from '@angular/core';
@Directive({
selector: '[elementReady]'
})
export class ElementReadyDirective implements AfterViewInit {
@Output() initEvent: EventEmitter<any> = new EventEmitter();
constructor() {}
ngAfterViewInit() {
this.initEvent.emit();
}
} Attach directive in template of the component<markdown elementReady (initEvent)="scrollToAnchor()" [innerHtml]="compiledFAQ"></markdown> Just wanted to share this solution. Maybe it helps someone. Inspired by this SO question: https://stackoverflow.com/questions/48335720/angular-i-want-to-call-a-function-when-element-is-loaded?noredirect=1&lq=1 Thanks a lot for the library @jfcere! Cheers |
I got to the point this afternoon where I had to tackle this and inspired by the answers above, I got to this, which is working for me. First provide a custom Renderer: import { MarkedRenderer } from 'ngx-markdown';
export class MarkdownRenderer extends MarkedRenderer {
public link(href: string | null, title: string | null, text: string): string {
return `<a routerLink="${href || ''}">${text}</a>`;
}
} This is passed into the renderer options in The second part of the solution is a directive to post-process the output of the component or directive: import {
Directive,
ElementRef,
Injector,
ApplicationRef,
ComponentFactoryResolver,
Component,
Input,
Inject,
HostListener
} from '@angular/core';
import { DOCUMENT } from '@angular/common';
@Component({
template: '<a [routerLink]="href">{{text}}</a>',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RouterLinkComponent {
@Input() public href: string;
@Input() public text: string;
}
@Directive({
// tslint:disable-next-line: directive-selector
selector: 'markdown,[markdown]'
})
export class ConvertLinksDirective {
constructor(
@Inject(DOCUMENT) private document: Document,
private injector: Injector,
private applicationRef: ApplicationRef,
private componentFactoryResolver: ComponentFactoryResolver,
private element: ElementRef<HTMLElement>
) { }
@HostListener('ready')
public processAnchors() {
this.element.nativeElement.querySelectorAll(
'a[routerLink]'
).forEach(a => {
const parent = a.parentElement;
if (parent) {
const container = this.document.createElement('span');
const component = this.componentFactoryResolver.resolveComponentFactory(
RouterLinkComponent
).create(this.injector, [], container);
this.applicationRef.attachView(component.hostView);
component.instance.href = a.getAttribute('routerLink') || '';
component.instance.text = a.textContent || '';
parent.replaceChild(container, a);
}
});
}
} As shown, the renderer doesn't deal with external links, which should be dealt with by rendering This won't help if you use the pipe rather than the component or directive. This can probably be improved on, but it seems to do what I need. |
I just came across this and I really liked the solution that @Simon-TheHelpfulCat implemented. I thought it was simple and super easy to extend/change (i.e. can replace routerLink with click for custom logic prior to routing). |
The solution of @Simon-TheHelpfulCat did not work for me, because Angular stripped the anchor attributes to prevent XSS. The |
This is probably the simplest solution // 拦截MarkdownComponent下所有href
@ViewChild(MarkdownComponent, { read: ElementRef }) markdownZone: ElementRef;
test() {
let links = this.markdownZone.nativeElement.querySelectorAll('a');
links.forEach(link => {
//console.log(link.href);
if (link.href.indexOf(window.location.origin) > -1) {
let target = link.href.replace(window.location.origin, '')
//console.log(target);
link.addEventListener('click', (evt) => {
evt.preventDefault()
this.router.navigateByUrl(target)
})
}
})
} |
By inspecting element of the link, I can see that But below link hits the router, but nothing happens
Should something be done in I have already added
|
I used @jfcere solution as a starting point and modified for my use case (I use no hash fragments in my project). I ended up with this: import { Injectable } from "@angular/core";
import { ActivatedRoute, Router, UrlTree } from "@angular/router";
@Injectable({
providedIn: "root",
})
export class BsMarkdownAnchorService {
constructor(
private _route: ActivatedRoute,
private _router: Router,
) {
}
isExternalUrl(href: string | null): boolean {
return !href
|| href.startsWith("http:")
|| href.startsWith("https:")
|| href.startsWith("mailto:")
|| href.startsWith("tel:")
|| href.startsWith("/");
}
stripQuery(url: string): string {
return /[^?]*/.exec(url)[0];
}
stripFragmentAndQuery(url: string): string {
return this.stripQuery(/[^#]*/.exec(url)[0]);
}
getUrlTree(url: string): UrlTree {
const urlPath = this.stripFragmentAndQuery(url) || this.stripFragmentAndQuery(this._router.url);
const parsedUrl = this._router.parseUrl(url);
const fragment = parsedUrl.fragment;
const queryParams = parsedUrl.queryParams;
return this._router.createUrlTree([urlPath], { relativeTo: this._route, fragment, queryParams });
}
navigate(url: string, replaceUrl = false) {
const urlTree = this.getUrlTree(url);
this._router.navigated = false;
this._router.navigateByUrl(urlTree, { replaceUrl });
}
interceptClick(event: Event) {
const element = event.target;
if (!(element instanceof HTMLAnchorElement)) {
return;
}
const href = element.getAttribute("href");
if (this.isExternalUrl(href)) {
return;
}
this.navigate(`/${href}`);
event.preventDefault();
}
} No extra router configuration or anything else. |
Hi @flensrocker thanks for the class. I cloned I added the following to
Every time I click on one of these links at Any suggestions? |
ok, replaying to self here. What I attempted above won't work because the markdown files are loaded statically, probably at startup, per this code and this html.....but still would appreciate some suggestions on how to implement your BsMarkdownAnchorService |
Ok, this is missing in my example. @HostListener("click", ["$event"])
onDocumentClick(event: Event) {
this._anchorService.interceptClick(event);
} |
Will try that later, thx. |
Due to "special" reasons in our application we are not able to use ActivatedRoute. Thus, I had to develop this "special" technique to manually figure out the relative link path based on the current window.location. @Directive({
// tslint:disable-next-line: directive-selector
selector: 'markdown,[markdown]'
})
export class ConvertMarkdownLinksDirective {
constructor(private _router: Router, private _element: ElementRef<HTMLElement>) {}
// unable to use the standard technique involving ActivatedRoute
@HostListener('ready')
processAnchors() {
const isAbsolute = new RegExp('(?:^[a-z][a-z0-9+.-]*:|//)', 'i');
this._element.nativeElement.querySelectorAll('a').forEach(a => {
const linkURL = a.getAttribute && a.getAttribute('href');
if (linkURL) {
if (isAbsolute.test(linkURL)) {
a.target = '_blank';
} else {
a.addEventListener('click', e => {
e.preventDefault();
const url = new URL(window.location.href);
const path = url.pathname;
const pathParts = path.split('/').filter(p => !!p);
const linkParts = linkURL.split('/').filter(p => !!p);
while (linkParts[0] === '..') {
linkParts.shift();
pathParts.pop();
}
let newPath = '/' + [...pathParts, ...linkParts].join('/') + '/';
const linkRest = linkURL.split('?')[1];
if (linkRest) {
newPath += `?${linkRest}`;
}
this._router.navigateByUrl(newPath);
});
}
}
});
}
} |
I found a waaay simpler solution for this if anyone wants to listen on any click/touch event on a specific element inside any rendered content: import { Directive, HostListener } from "@angular/core"
/**
* Handle click events on HTML elements inside a safe HTML content projection.
*
* @example
* ```html
* <div [innerHtml]="..." appSafeHTMLClickHandler></div>
* ```
*/
@Directive({
selector: "[appSafeHTMLClickHandler]",
})
export class SafeHTMLClickDirective {
constructor() {}
/**
* Listener for click events in any HTMLElement.
*/
@HostListener("click", ["$event"])
onClick($event: MouseEvent) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const target = $event.target as any
switch ($event.target.constructor) {
case HTMLAnchorElement:
$event.preventDefault()
this.handleAnchorElement(target)
// add even more handlers for different stuff you want to do on an anchor element
break
// or add even more handlers
}
}
/**
* Handle anchor element click
*/
private async handleAnchorElement(target: HTMLAnchorElement): Promise<void> {
// for example: if starts with `mailto:` exec function to open native client
// or if no protocol and url is in router navigate to internal route
// ANY Angular/Ionic/Capacitor stuff will work here
}
} Might need to add that this does not require and unsubscribing functions or event listerner removal. |
Any updates? |
recap..component.html <div *ngIf="data" [innerHTML]="data | markdown"></div> component.ts export class MdViewComponent implements OnInit {
@HostListener('click', ['$event'])
public data: string = ""
onClick(event: any): void {
event.preventDefault();
// find anchor
const anchor = event.target.closest("a");
if (!anchor) return;
// navigate
const href = anchor.getAttribute('href')
this.router.navigate([href])
}
constructor(
private router: Router,
private route: ActivatedRoute,
private httpClient: HttpClient,
) {
}
ngOnInit(): void {
console.log("Markdown home init")
this.httpClient.get("my-md-file", { responseType: 'text' })
.subscribe(data => {
this.data = data
});
}
} |
I cannot believe this is still a problem in Markdown in Angular, but @delogix-daniel solution works, with the addition of handling a couple of cases with target or href starting with specific URLs like "/api/..."
|
Follow-up on @szymarcus comment about scrolling an
in |
I created a PR for anyone that is interested: #506 |
If it is a toc anchor point, you also need renderer heading
I am using github-slugger
|
app.module.ts
|
We are creating a knowledge base using markdown and would like to be able to navigate between the different pages using markdown links.
I want to be able to do like:
[click this link](routerlink#./other%20document.md)
or something like that and have it render as<a [routerLink]="['./other document.md']">click this link</a>
I can do that using the renderer, but Angular doesn't pick up the routerLink and bootstrap it. The link is unclickable.
This means that when I click links, the entire application reloads instead of using SPA routing.
Is there a way to do what I'm trying to do here? Can my documentation writers create links in our markdown documents?
The text was updated successfully, but these errors were encountered: