Преглед изворни кода

Merge pull request #6259 from weseek/feat/render-react-component-by-remark

feat: Render react component by remark
Yuki Takei пре 3 година
родитељ
комит
12aca97117

+ 49 - 0
packages/app/src/components/ReactMarkdownComponents/Header.tsx

@@ -0,0 +1,49 @@
+import { Element } from 'react-markdown/lib/rehype-filter';
+
+import { NextLink } from './NextLink';
+
+
+type EditLinkProps = {
+  line?: number,
+}
+
+/**
+ * Inner FC to display edit link icon
+ */
+const EditLink = (props: EditLinkProps): JSX.Element => {
+  const isDisabled = props.line == null;
+
+  return (
+    <span className="revision-head-edit-button">
+      <a href="#edit" aria-disabled={isDisabled} onClick={() => console.log(`TODO: Jump to the line '${props.line}'`)}>
+        <i className="icon-note"></i>
+      </a>
+    </span>
+  );
+};
+
+
+type HeaderProps = {
+  children: React.ReactNode,
+  node: Element,
+  level: number,
+  id?: string,
+}
+
+export const Header = (props: HeaderProps): JSX.Element => {
+  const {
+    node, id, children, level,
+  } = props;
+
+  const CustomTag = `h${level}` as keyof JSX.IntrinsicElements;
+
+  return (
+    <CustomTag id={id} className="revision-head">
+      {children}
+      <NextLink href={`#${id}`} className="revision-head-link">
+        <span className="icon-link"></span>
+      </NextLink>
+      <EditLink line={node.position?.start.line} />
+    </CustomTag>
+  );
+};

+ 46 - 0
packages/app/src/components/ReactMarkdownComponents/NextLink.tsx

@@ -0,0 +1,46 @@
+import Link, { LinkProps } from 'next/link';
+
+import { useSiteUrl } from '~/stores/context';
+
+const isAnchorLink = (href: string): boolean => {
+  return href.toString().length > 0 && href[0] === '#';
+};
+
+const isExternalLink = (href: string, siteUrl: string | undefined): boolean => {
+  const baseUrl = new URL(siteUrl ?? 'https://example.com');
+  const hrefUrl = new URL(href, baseUrl);
+
+  return baseUrl.host !== hrefUrl.host;
+};
+
+type Props = Omit<LinkProps, 'href'> & {
+  children: React.ReactNode,
+  href?: string,
+  className?: string,
+} ;
+
+export const NextLink = ({
+  href, children, className, ...props
+}: Props): JSX.Element => {
+
+  const { data: siteUrl } = useSiteUrl();
+
+  // when href is an anchor link
+  if (href == null || isAnchorLink(href)) {
+    return <a href={href} className={className}>{children}</a>;
+  }
+
+  if (isExternalLink(href, siteUrl)) {
+    return (
+      <a href={href} className={className} target="_blank" rel="noopener noreferrer">
+        {children}&nbsp;<i className='icon-share-alt small'></i>
+      </a>
+    );
+  }
+
+  return (
+    <Link {...props} href={href}>
+      <a className={className}>{children}</a>
+    </Link>
+  );
+};

+ 10 - 11
packages/app/src/components/TableOfContents.jsx

@@ -6,7 +6,6 @@ import PropTypes from 'prop-types';
 import PageContainer from '~/client/services/PageContainer';
 import { blinkElem } from '~/client/util/blink-section-header';
 import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
-import { useGlobalEventEmitter } from '~/stores/context';
 import loggerFactory from '~/utils/logger';
 
 
@@ -26,8 +25,6 @@ const TableOfContents = (props) => {
   const { pageUser } = pageContainer.state;
   const isUserPage = pageUser != null;
 
-  const { data: globalEmitter } = useGlobalEventEmitter();
-
   const [tocHtml, setTocHtml] = useState('');
 
   const calcViewHeight = useCallback(() => {
@@ -56,15 +53,17 @@ const TableOfContents = (props) => {
     addSmoothScrollEvent(anchorsInToc, blinkElem);
   }, [tocHtml]);
 
+  // == TODO: render ToC without globalEmitter -- Yuki Takei
+  //
   // set handler to render ToC
-  useEffect(() => {
-    const handler = html => setTocHtml(html);
-    globalEmitter.on('renderTocHtml', handler);
-
-    return function cleanup() {
-      globalEmitter.removeListener('renderTocHtml', handler);
-    };
-  }, [globalEmitter]);
+  // useEffect(() => {
+  //   const handler = html => setTocHtml(html);
+  //   globalEmitter.on('renderTocHtml', handler);
+
+  //   return function cleanup() {
+  //     globalEmitter.removeListener('renderTocHtml', handler);
+  //   };
+  // }, [globalEmitter]);
 
   return (
     <StickyStretchableScroller

+ 20 - 5
packages/app/src/services/renderer/growi-renderer.ts → packages/app/src/services/renderer/growi-renderer.tsx

@@ -6,6 +6,8 @@ import emoji from 'remark-emoji';
 import footnotes from 'remark-footnotes';
 import gfm from 'remark-gfm';
 
+import { Header } from '~/components/ReactMarkdownComponents/Header';
+import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 import { GrowiRendererConfig, RendererSettings } from '~/interfaces/services/renderer';
 import loggerFactory from '~/utils/logger';
 
@@ -215,6 +217,9 @@ const generateCommonOptions: ReactMarkdownOptionsGenerator = (
   return {
     remarkPlugins: [gfm],
     rehypePlugins: [slug],
+    components: {
+      a: NextLink,
+    },
   };
 };
 
@@ -224,14 +229,17 @@ export const generateViewOptions: ReactMarkdownOptionsGenerator = (
 
   const options = generateCommonOptions(growiRendererConfig, rendererSettings);
 
-  const { remarkPlugins, rehypePlugins } = options;
+  const { remarkPlugins, rehypePlugins, components } = options;
 
   // add remark plugins
-  remarkPlugins?.push(footnotes);
-  remarkPlugins?.push(emoji);
-  if (rendererSettings.isEnabledLinebreaks) {
-    remarkPlugins?.push(breaks);
+  if (remarkPlugins != null) {
+    remarkPlugins.push(footnotes);
+    remarkPlugins.push(emoji);
+    if (rendererSettings.isEnabledLinebreaks) {
+      remarkPlugins.push(breaks);
+    }
   }
+
   // add rehypePlugins
   // rehypePlugins.push([toc, {
   //   headings: ['h1', 'h2', 'h3'],
@@ -241,6 +249,13 @@ export const generateViewOptions: ReactMarkdownOptionsGenerator = (
   //   behavior: 'append',
   // }]);
 
+  // add components
+  if (components != null) {
+    components.h1 = Header;
+    components.h2 = Header;
+    components.h3 = Header;
+  }
+
   // // Add configurers for viewer
   // renderer.addConfigurers([
   //   new FooternoteConfigurer(),

+ 1 - 5
packages/app/src/stores/context.tsx

@@ -5,7 +5,7 @@ import useSWRImmutable from 'swr/immutable';
 
 
 import { SupportedActionType } from '~/interfaces/activity';
-import { CustomWindow } from '~/interfaces/global';
+// import { CustomWindow } from '~/interfaces/global';
 import { GrowiRendererConfig } from '~/interfaces/services/renderer';
 import InterceptorManager from '~/services/interceptor-manager';
 
@@ -18,10 +18,6 @@ import { useStaticSWR } from './use-static-swr';
 type Nullable<T> = T | null;
 
 
-export const useGlobalEventEmitter = (): SWRResponse<EventEmitter, Error> => {
-  return useStaticSWR<EventEmitter, Error>('globalEventEmitter', undefined, { fallbackData: (window as CustomWindow).globalEmitter });
-};
-
 export const useInterceptorManager = (): SWRResponse<InterceptorManager, Error> => {
   return useStaticSWR<InterceptorManager, Error>('interceptorManager', undefined, { fallbackData: new InterceptorManager() });
 };