Просмотр исходного кода

Merge pull request #6589 from weseek/imprv/toc

imprv: ToC
Yuki Takei 3 лет назад
Родитель
Сommit
e01fa1617a

+ 1 - 0
packages/app/package.json

@@ -236,6 +236,7 @@
     "react-dropzone": "^11.2.4",
     "react-frame-component": "^4.0.0",
     "react-hotkeys": "^2.0.0",
+    "react-scroll": "^1.8.7",
     "react-use-ripple": "^1.5.2",
     "react-waypoint": "^10.1.0",
     "reactstrap": "^8.9.0",

+ 0 - 28
packages/app/src/client/util/blink-section-header.ts

@@ -1,28 +0,0 @@
-let lastBlinkedElem;
-
-export const blinkElem = (elem: HTMLElement): void => {
-  if (lastBlinkedElem != null) {
-    lastBlinkedElem.classList.remove('blink');
-  }
-
-  elem.classList.add('blink');
-  lastBlinkedElem = elem;
-};
-
-export const blinkSectionHeaderAtBoot = (): HTMLElement | undefined => {
-  const { hash } = window.location;
-
-  if (hash.length === 0) {
-    return;
-  }
-
-  // omit '#'
-  const id = hash.replace('#', '');
-  // don't use jQuery and document.querySelector
-  //  because hash may containe Base64 encoded strings
-  const elem = document.getElementById(id);
-  if (elem != null && elem.tagName.match(/h\d+/i)) { // match h1, h2, h3...
-    blinkElem(elem);
-    return elem;
-  }
-};

+ 1 - 12
packages/app/src/components/Page.tsx

@@ -8,10 +8,9 @@ import dynamic from 'next/dynamic';
 
 import { HtmlElementNode } from 'rehype-toc';
 
-import { blinkSectionHeaderAtBoot } from '~/client/util/blink-section-header';
 // import { getOptionsToSave } from '~/client/util/editor';
 import {
-  useIsGuestUser, useIsBlinkedHeaderAtBoot, useCurrentPageTocNode,
+  useIsGuestUser, useCurrentPageTocNode,
 } from '~/stores/context';
 import {
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
@@ -210,20 +209,10 @@ export const Page = (props) => {
   const { data: pageTags } = usePageTagsForEditors(null); // TODO: pass pageId
   const { data: rendererOptions } = useViewOptions(storeTocNodeHandler);
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
-  const { data: isBlinkedAtBoot, mutate: mutateBlinkedAtBoot } = useIsBlinkedHeaderAtBoot();
   const { mutate: mutateCurrentPageTocNode } = useCurrentPageTocNode();
 
   const pageRef = useRef(null);
 
-  useEffect(() => {
-    if (isBlinkedAtBoot) {
-      return;
-    }
-
-    blinkSectionHeaderAtBoot();
-    mutateBlinkedAtBoot(true);
-  }, [isBlinkedAtBoot, mutateBlinkedAtBoot]);
-
   useEffect(() => {
     mutateCurrentPageTocNode(tocRef.current);
   // eslint-disable-next-line react-hooks/exhaustive-deps

+ 0 - 5
packages/app/src/components/Page/RevisionRenderer.tsx

@@ -2,12 +2,7 @@ import React from 'react';
 
 import ReactMarkdown from 'react-markdown';
 
-import { blinkElem } from '~/client/util/blink-section-header';
-import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
-import { CustomWindow } from '~/interfaces/global';
 import { RendererOptions } from '~/services/renderer/renderer';
-import { useCurrentPathname, useInterceptorManager } from '~/stores/context';
-import { useEditorSettings } from '~/stores/editor';
 import loggerFactory from '~/utils/logger';
 
 

+ 22 - 1
packages/app/src/components/ReactMarkdownComponents/Header.tsx

@@ -1,5 +1,8 @@
+import { useEffect, useState } from 'react';
+
 import EventEmitter from 'events';
 
+import { useRouter } from 'next/router';
 import { Element } from 'react-markdown/lib/rehype-filter';
 
 import { NextLink } from './NextLink';
@@ -48,10 +51,28 @@ export const Header = (props: HeaderProps): JSX.Element => {
     node, id, children, level,
   } = props;
 
+  const router = useRouter();
+
+  const [isActive, setActive] = useState(false);
+
   const CustomTag = `h${level}` as keyof JSX.IntrinsicElements;
 
+  // update isActive when hash is changed
+  useEffect(() => {
+    const handler = (url: string) => {
+      const hash = (new URL(url, 'https://example.com')).hash.slice(1);
+      setActive(hash === id);
+    };
+
+    router.events.on('hashChangeComplete', handler);
+
+    return () => {
+      router.events.off('hashChangeComplete', handler);
+    };
+  }, [id, router.events]);
+
   return (
-    <CustomTag id={id} className={`revision-head ${styles['revision-head']}`}>
+    <CustomTag id={id} className={`revision-head ${styles['revision-head']} ${isActive ? 'blink' : ''}`}>
       {children}
       <NextLink href={`#${id}`} className="revision-head-link">
         <span className="icon-link"></span>

+ 14 - 2
packages/app/src/components/ReactMarkdownComponents/NextLink.tsx

@@ -1,4 +1,5 @@
 import Link, { LinkProps } from 'next/link';
+import { Link as ScrollLink } from 'react-scroll';
 
 import { useSiteUrl } from '~/stores/context';
 
@@ -25,9 +26,20 @@ export const NextLink = ({
 
   const { data: siteUrl } = useSiteUrl();
 
+  if (href == null) {
+    return <a className={className}>{children}</a>;
+  }
+
   // when href is an anchor link
-  if (href == null || isAnchorLink(href)) {
-    return <a href={href} className={className}>{children}</a>;
+  if (isAnchorLink(href)) {
+    const to = href.slice(1);
+    return (
+      <Link href={href} scroll={false}>
+        <ScrollLink href={href} to={to} className={className} smooth="easeOutQuart" offset={-100} duration={800}>
+          {children}
+        </ScrollLink>
+      </Link>
+    );
   }
 
   if (isExternalLink(href, siteUrl)) {

+ 4 - 1
packages/app/src/components/TableOfContents.module.scss

@@ -9,6 +9,10 @@
   border-bottom: 1px solid transparent;
 
   .revision-toc-content {
+    ul {
+      list-style-type: disc;
+    }
+
     li {
       margin: 6px;
     }
@@ -22,7 +26,6 @@
     // first level of li
     > ul > li {
       padding: 5px;
-      margin-right: 4px;
       margin-left: 17px;
     }
   }

+ 2 - 11
packages/app/src/components/TableOfContents.tsx

@@ -1,9 +1,7 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useCallback } from 'react';
 
 import ReactMarkdown from 'react-markdown';
 
-import { blinkElem } from '~/client/util/blink-section-header';
-import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
 import { useIsUserPage } from '~/stores/context';
 import { useTocOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';
@@ -22,7 +20,7 @@ const TableOfContents = (): JSX.Element => {
 
   const { data: isUserPage } = useIsUserPage();
 
-  const [tocHtml, setTocHtml] = useState('');
+  // const [tocHtml, setTocHtml] = useState('');
 
   const { data: rendererOptions } = useTocOptions();
 
@@ -49,13 +47,6 @@ const TableOfContents = (): JSX.Element => {
     return bottom - (containerTop + containerPaddingTop);
   }, [isUserPage]);
 
-  useEffect(() => {
-    const tocDom = document.getElementById('revision-toc-content');
-    if (tocDom == null) { return }
-    const anchorsInToc = Array.from(tocDom.getElementsByTagName('a'));
-    addSmoothScrollEvent(anchorsInToc, blinkElem);
-  }, [tocHtml]);
-
   return (
     <div id="revision-toc" className={`revision-toc ${styles['revision-toc']}`}>
       <StickyStretchableScroller

+ 54 - 0
packages/app/src/services/renderer/rehype-plugins/relocate-toc.ts

@@ -0,0 +1,54 @@
+import rehypeToc, { HtmlElementNode } from 'rehype-toc';
+import { Plugin } from 'unified';
+import { Node } from 'unist';
+
+type StoreTocPluginParams = {
+  storeTocNode: (toc: HtmlElementNode) => void,
+}
+
+export const rehypePluginStore: Plugin<[StoreTocPluginParams]> = (options) => {
+  return rehypeToc.bind(this)({
+    nav: false,
+    headings: ['h1', 'h2', 'h3'],
+    customizeTOC: (toc: HtmlElementNode) => {
+      // For storing tocNode to global state with swr
+      // search: tocRef.current
+      options.storeTocNode(toc);
+
+      return false; // not show toc in body
+    },
+  });
+};
+
+
+// method for replace <ol> to <ul>
+const replaceOlToUl = (children: Node[]) => {
+  children.forEach((child) => {
+    if (child.type === 'element' && child.tagName === 'ol') {
+      child.tagName = 'ul';
+    }
+    if (child.children != null) {
+      replaceOlToUl(child.children as Node[]);
+    }
+  });
+};
+
+type RestoreTocPluginParams = {
+  tocNode?: HtmlElementNode,
+}
+
+export const rehypePluginRestore: Plugin<[RestoreTocPluginParams]> = (options) => {
+  const { tocNode } = options;
+
+  return rehypeToc.bind(this)({
+    headings: ['h1', 'h2', 'h3'],
+    customizeTOC: () => {
+      if (tocNode != null) {
+        replaceOlToUl([tocNode]); // replace <ol> to <ul>
+
+        // restore toc
+        return tocNode;
+      }
+    },
+  });
+};

+ 5 - 29
packages/app/src/services/renderer/renderer.tsx

@@ -12,7 +12,7 @@ import katex from 'rehype-katex';
 import raw from 'rehype-raw';
 import sanitize, { defaultSchema as sanitizeDefaultSchema } from 'rehype-sanitize';
 import slug from 'rehype-slug';
-import toc, { HtmlElementNode } from 'rehype-toc';
+import { HtmlElementNode } from 'rehype-toc';
 import breaks from 'remark-breaks';
 import emoji from 'remark-emoji';
 import gfm from 'remark-gfm';
@@ -32,6 +32,7 @@ import * as addLineNumberAttribute from './rehype-plugins/add-line-number-attrib
 import * as keywordHighlighter from './rehype-plugins/keyword-highlighter';
 import { relativeLinks } from './rehype-plugins/relative-links';
 import { relativeLinksByPukiwikiLikeLinker } from './rehype-plugins/relative-links-by-pukiwiki-like-linker';
+import * as toc from './rehype-plugins/relocate-toc';
 import { pukiwikiLikeLinker } from './remark-plugins/pukiwiki-like-linker';
 import * as xsvToTable from './remark-plugins/xsv-to-table';
 
@@ -247,6 +248,7 @@ export type RendererOptions = Omit<ReactMarkdownOptions, 'remarkPlugins' | 'rehy
 const commonSanitizeOption: SanitizeOption = deepmerge(
   sanitizeDefaultSchema,
   {
+    clobberPrefix: 'mdcont-',
     attributes: {
       '*': ['class', 'className', 'style'],
     },
@@ -326,36 +328,13 @@ export const generateViewOptions = (
   // add rehype plugins
   rehypePlugins.push(
     slug,
-    [toc, {
-      nav: false,
-      headings: ['h1', 'h2', 'h3'],
-      customizeTOC: (toc: HtmlElementNode) => {
-        // method for replace <ol> to <ul>
-        const replacer = (children) => {
-          children.forEach((child) => {
-            if (child.type === 'element' && child.tagName === 'ol') {
-              child.tagName = 'ul';
-            }
-            if (child.children) {
-              replacer(child.children);
-            }
-          });
-        };
-        replacer([toc]); // replace <ol> to <ul>
-
-        // For storing tocNode to global state with swr
-        // search: tocRef.current
-        storeTocNode(toc);
-
-        return false; // not show toc in body
-      },
-    }],
     [lsxGrowiPlugin.rehypePlugin, { pagePath }],
     [sanitize, deepmerge(
       commonSanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
     )],
     katex,
+    [toc.rehypePluginStore, { storeTocNode }],
     // [autoLinkHeadings, {
     //   behavior: 'append',
     // }]
@@ -396,10 +375,7 @@ export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementN
 
   // add rehype plugins
   rehypePlugins.push(
-    [toc, {
-      headings: ['h1', 'h2', 'h3'],
-      customizeTOC: () => tocNode,
-    }],
+    [toc.rehypePluginRestore, { tocNode }],
     [sanitize, commonSanitizeOption],
   );
   // renderer.rehypePlugins.push([autoLinkHeadings, {

+ 9 - 0
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -245,6 +245,15 @@ ul.pagination {
   }
 }
 
+/*
+ * GROWI ToC
+ */
+.revision-toc-content {
+  ::marker {
+    color: lighten($bgcolor-global, 30%);
+  }
+}
+
 /*
  * GROWI subnavigation
  */

+ 9 - 0
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -242,6 +242,15 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
   }
 }
 
+/*
+ * GROWI ToC
+ */
+.revision-toc-content {
+  ::marker {
+    color: darken($bgcolor-global, 20%);
+  }
+}
+
 /*
  * GROWI on-edit
  */

+ 8 - 0
yarn.lock

@@ -18586,6 +18586,14 @@ react-popper@^2.2.5:
     react-fast-compare "^3.0.1"
     warning "^4.0.2"
 
+react-scroll@^1.8.7:
+  version "1.8.7"
+  resolved "https://registry.yarnpkg.com/react-scroll/-/react-scroll-1.8.7.tgz#8020035329efad00f03964e18aff6822137de3aa"
+  integrity sha512-fBOIwweAlhicx8RqP9tQXn/Uhd+DTtVRjw+0VBsIn1Z+MjRYLhTZ0tMoTAU1vOD3dce8mI6copexI4yWII+Luw==
+  dependencies:
+    lodash.throttle "^4.1.1"
+    prop-types "^15.7.2"
+
 react-scrolllock@^1.0.9:
   version "1.0.9"
   resolved "https://registry.yarnpkg.com/react-scrolllock/-/react-scrolllock-1.0.9.tgz#7c9c3c0cce2ed55042af2808b6483b85b121cdcb"