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

Merge pull request #6582 from weseek/feat/keyword-highlighter

feat: keyword highlighter
Yuki Takei 3 лет назад
Родитель
Сommit
ba021f5ff5

+ 1 - 0
packages/app/package.json

@@ -239,6 +239,7 @@
     "react-use-ripple": "^1.5.2",
     "react-use-ripple": "^1.5.2",
     "react-waypoint": "^10.1.0",
     "react-waypoint": "^10.1.0",
     "reactstrap": "^8.9.0",
     "reactstrap": "^8.9.0",
+    "rehype-rewrite": "^3.0.6",
     "replacestream": "^4.0.3",
     "replacestream": "^4.0.3",
     "reveal.js": "^4.3.1",
     "reveal.js": "^4.3.1",
     "sass": "^1.53.0",
     "sass": "^1.53.0",

+ 0 - 1
packages/app/src/components/Page/RevisionLoader.jsx

@@ -111,7 +111,6 @@ class RevisionLoader extends React.Component {
       <RevisionRenderer
       <RevisionRenderer
         rendererOptions={this.props.rendererOptions}
         rendererOptions={this.props.rendererOptions}
         markdown={markdown}
         markdown={markdown}
-        highlightKeywords={this.props.highlightKeywords}
       />
       />
     );
     );
   }
   }

+ 1 - 70
packages/app/src/components/Page/RevisionRenderer.tsx

@@ -14,85 +14,16 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('components:Page:RevisionRenderer');
 const logger = loggerFactory('components:Page:RevisionRenderer');
 
 
 
 
-// function getHighlightedBody(body: string, _keywords: string | string[]): string {
-//   const normalizedKeywordsArray: string[] = [];
-
-//   const keywords = (typeof _keywords === 'string') ? [_keywords] : _keywords;
-
-//   if (keywords.length === 0) {
-//     return body;
-//   }
-
-//   // !!TODO!!: add test code refs: https://redmine.weseek.co.jp/issues/86841
-//   // Separate keywords
-//   // - Surrounded by double quotation
-//   // - Split by both full-width and half-width spaces
-//   // [...keywords.match(/"[^"]+"|[^\u{20}\u{3000}]+/ug)].forEach((keyword, i) => {
-//   keywords.forEach((keyword, i) => {
-//     if (keyword === '') {
-//       return;
-//     }
-//     const k = keyword
-//       .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape regex operators
-//       .replace(/(^"|"$)/g, ''); // for phrase (quoted) keyword
-//     normalizedKeywordsArray.push(k);
-//   });
-
-//   const normalizedKeywords = `(${normalizedKeywordsArray.join('|')})`;
-//   const keywordRegxp = new RegExp(`${normalizedKeywords}(?!(.*?"))`, 'ig'); // prior https://regex101.com/r/oX7dq5/1
-//   let keywordRegexp2 = keywordRegxp;
-
-//   // for non-chrome browsers compatibility
-//   try {
-// eslint-disable-next-line regex/invalid, max-len
-//     keywordRegexp2 = new RegExp(`(?<!<)${normalizedKeywords}(?!(.*?("|>)))`, 'ig'); // inferior (this doesn't work well when html tags exist a lot) https://regex101.com/r/Dfi61F/1
-//   }
-//   catch (err) {
-//     logger.debug('Failed to initialize regex:', err);
-//   }
-
-//   const highlighter = (str) => { return str.replace(keywordRegxp, '<em class="highlighted-keyword">$&</em>') }; // prior
-//   const highlighter2 = (str) => { return str.replace(keywordRegexp2, '<em class="highlighted-keyword">$&</em>') }; // inferior
-
-//   const insideTagRegex = /<[^<>]*>/g;
-//   const betweenTagRegex = />([^<>]*)</g; // use (group) to ignore >< around
-
-//   const insideTagStrs = body.match(insideTagRegex);
-//   const betweenTagMatches = Array.from(body.matchAll(betweenTagRegex));
-
-//   let returnBody = body;
-//   const isSafeHtml = insideTagStrs?.length === betweenTagMatches.length + 1; // to check whether is safe to join
-//   if (isSafeHtml) {
-//     // highlight
-//     const betweenTagStrs: string[] = betweenTagMatches.map(match => highlighter(match[1])); // get only grouped part (exclude >< around)
-
-//     const arr: string[] = [];
-//     insideTagStrs.forEach((str, i) => {
-//       arr.push(str);
-//       arr.push(betweenTagStrs[i]);
-//     });
-//     returnBody = arr.join('');
-//   }
-//   else {
-//     // inferior highlighter
-//     returnBody = highlighter2(body);
-//   }
-
-//   return returnBody;
-// }
-
-
 type Props = {
 type Props = {
   rendererOptions: RendererOptions,
   rendererOptions: RendererOptions,
   markdown: string,
   markdown: string,
-  highlightKeywords?: string | string[],
   additionalClassName?: string,
   additionalClassName?: string,
 }
 }
 
 
 const RevisionRenderer = React.memo((props: Props): JSX.Element => {
 const RevisionRenderer = React.memo((props: Props): JSX.Element => {
 
 
   const {
   const {
-    rendererOptions, markdown, highlightKeywords, additionalClassName,
+    rendererOptions, markdown, additionalClassName,
   } = props;
   } = props;
 
 
   return (
   return (

+ 13 - 27
packages/app/src/components/PageComment.tsx

@@ -1,5 +1,5 @@
 import React, {
 import React, {
-  FC, useEffect, useState, useMemo, memo, useCallback,
+  FC, useState, useMemo, memo, useCallback,
 } from 'react';
 } from 'react';
 
 
 import { IRevisionHasId, isPopulated, getIdForRef } from '@growi/core';
 import { IRevisionHasId, isPopulated, getIdForRef } from '@growi/core';
@@ -8,6 +8,8 @@ import { Button } from 'reactstrap';
 
 
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiPost } from '~/client/util/apiv1-client';
+import { RendererOptions } from '~/services/renderer/renderer';
+import { useCommentForCurrentPageOptions } from '~/stores/renderer';
 
 
 import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
 import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
 import { useSWRxPageComment } from '../stores/comment';
 import { useSWRxPageComment } from '../stores/comment';
@@ -26,6 +28,7 @@ const DeleteCommentModal = dynamic<DeleteCommentModalProps>(
 );
 );
 
 
 export type PageCommentProps = {
 export type PageCommentProps = {
+  rendererOptions?: RendererOptions,
   pageId: string,
   pageId: string,
   revision: string | IRevisionHasId,
   revision: string | IRevisionHasId,
   currentUser: any,
   currentUser: any,
@@ -38,46 +41,24 @@ export type PageCommentProps = {
 export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps): JSX.Element => {
 export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps): JSX.Element => {
 
 
   const {
   const {
+    rendererOptions: rendererOptionsByProps,
     pageId, revision, currentUser, highlightKeywords, isReadOnly, titleAlign, hideIfEmpty,
     pageId, revision, currentUser, highlightKeywords, isReadOnly, titleAlign, hideIfEmpty,
   } = props;
   } = props;
 
 
   const { data: comments, mutate } = useSWRxPageComment(pageId);
   const { data: comments, mutate } = useSWRxPageComment(pageId);
+  const { data: rendererOptionsForCurrentPage } = useCommentForCurrentPageOptions();
 
 
   const [commentToBeDeleted, setCommentToBeDeleted] = useState<ICommentHasId | null>(null);
   const [commentToBeDeleted, setCommentToBeDeleted] = useState<ICommentHasId | null>(null);
   const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState<boolean>(false);
   const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState<boolean>(false);
   const [showEditorIds, setShowEditorIds] = useState<Set<string>>(new Set());
   const [showEditorIds, setShowEditorIds] = useState<Set<string>>(new Set());
-  const [formatedComments, setFormatedComments] = useState<ICommentHasIdList | null>(null);
   const [errorMessageOnDelete, setErrorMessageOnDelete] = useState<string>('');
   const [errorMessageOnDelete, setErrorMessageOnDelete] = useState<string>('');
 
 
-  const commentsFromOldest = useMemo(() => (formatedComments != null ? [...formatedComments].reverse() : null), [formatedComments]);
+  const commentsFromOldest = useMemo(() => (comments != null ? [...comments].reverse() : null), [comments]);
   const commentsExceptReply: ICommentHasIdList | undefined = useMemo(
   const commentsExceptReply: ICommentHasIdList | undefined = useMemo(
     () => commentsFromOldest?.filter(comment => comment.replyTo == null), [commentsFromOldest],
     () => commentsFromOldest?.filter(comment => comment.replyTo == null), [commentsFromOldest],
   );
   );
   const allReplies = {};
   const allReplies = {};
 
 
-  const highlightComment = useCallback((comment: string):string => {
-    if (highlightKeywords == null) return comment;
-
-    let highlightedComment = '';
-    highlightKeywords.forEach((highlightKeyword) => {
-      highlightedComment = comment.replaceAll(highlightKeyword, '<em class="highlighted-keyword">$&</em>');
-    });
-    return highlightedComment;
-  }, [highlightKeywords]);
-
-  useEffect(() => {
-    if (comments != null) {
-      const preprocessedCommentList: string[] = comments.map((comment) => {
-        const highlightedComment: string = highlightComment(comment.comment);
-        return highlightedComment;
-      });
-      const preprocessedComments: ICommentHasIdList = comments.map((comment, index) => {
-        return { ...comment, comment: preprocessedCommentList[index] };
-      });
-      setFormatedComments(preprocessedComments);
-    }
-  }, [comments, highlightComment]);
-
   if (commentsFromOldest != null) {
   if (commentsFromOldest != null) {
     commentsFromOldest.forEach((comment) => {
     commentsFromOldest.forEach((comment) => {
       if (comment.replyTo != null) {
       if (comment.replyTo != null) {
@@ -128,7 +109,9 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
   let commentTitleClasses = 'border-bottom py-3 mb-3';
   let commentTitleClasses = 'border-bottom py-3 mb-3';
   commentTitleClasses = titleAlign != null ? `${commentTitleClasses} text-${titleAlign}` : `${commentTitleClasses} text-center`;
   commentTitleClasses = titleAlign != null ? `${commentTitleClasses} text-${titleAlign}` : `${commentTitleClasses} text-center`;
 
 
-  if (commentsFromOldest == null || commentsExceptReply == null) {
+  const rendererOptions = rendererOptionsByProps ?? rendererOptionsForCurrentPage;
+
+  if (commentsFromOldest == null || commentsExceptReply == null || rendererOptions == null) {
     if (hideIfEmpty) {
     if (hideIfEmpty) {
       return <></>;
       return <></>;
     }
     }
@@ -142,11 +125,13 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
 
 
   const generateCommentElement = (comment: ICommentHasId) => (
   const generateCommentElement = (comment: ICommentHasId) => (
     <Comment
     <Comment
+      rendererOptions={rendererOptions}
       comment={comment}
       comment={comment}
       revisionId={revisionId}
       revisionId={revisionId}
       revisionCreatedAt={revisionCreatedAt as Date}
       revisionCreatedAt={revisionCreatedAt as Date}
       currentUser={currentUser}
       currentUser={currentUser}
       isReadOnly={isReadOnly}
       isReadOnly={isReadOnly}
+      highlightKeywords={highlightKeywords}
       deleteBtnClicked={onClickDeleteButton}
       deleteBtnClicked={onClickDeleteButton}
       onComment={mutate}
       onComment={mutate}
     />
     />
@@ -154,6 +139,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
 
 
   const generateReplyCommentsElement = (replyComments: ICommentHasIdList) => (
   const generateReplyCommentsElement = (replyComments: ICommentHasIdList) => (
     <ReplyComments
     <ReplyComments
+      rendererOptions={rendererOptions}
       isReadOnly={isReadOnly}
       isReadOnly={isReadOnly}
       revisionId={revisionId}
       revisionId={revisionId}
       revisionCreatedAt={revisionCreatedAt as Date}
       revisionCreatedAt={revisionCreatedAt as Date}

+ 5 - 3
packages/app/src/components/PageComment/Comment.tsx

@@ -7,7 +7,7 @@ import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 
-import { useCommentPreviewOptions } from '~/stores/renderer';
+import { RendererOptions } from '~/services/renderer/renderer';
 
 
 import { ICommentHasId } from '../../interfaces/comment';
 import { ICommentHasId } from '../../interfaces/comment';
 import FormattedDistanceDate from '../FormattedDistanceDate';
 import FormattedDistanceDate from '../FormattedDistanceDate';
@@ -24,10 +24,12 @@ const CommentEditor = dynamic<CommentEditorProps>(() => import('./CommentEditor'
 
 
 type CommentProps = {
 type CommentProps = {
   comment: ICommentHasId,
   comment: ICommentHasId,
+  rendererOptions: RendererOptions,
   revisionId: string,
   revisionId: string,
   revisionCreatedAt: Date,
   revisionCreatedAt: Date,
   currentUser: IUser,
   currentUser: IUser,
   isReadOnly: boolean,
   isReadOnly: boolean,
+  highlightKeywords?: string[],
   deleteBtnClicked: (comment: ICommentHasId) => void,
   deleteBtnClicked: (comment: ICommentHasId) => void,
   onComment: () => void,
   onComment: () => void,
 }
 }
@@ -35,11 +37,11 @@ type CommentProps = {
 export const Comment = (props: CommentProps): JSX.Element => {
 export const Comment = (props: CommentProps): JSX.Element => {
 
 
   const {
   const {
-    comment, revisionId, revisionCreatedAt, currentUser, isReadOnly, deleteBtnClicked, onComment,
+    comment, rendererOptions, revisionId, revisionCreatedAt, currentUser, isReadOnly,
+    deleteBtnClicked, onComment,
   } = props;
   } = props;
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { data: rendererOptions } = useCommentPreviewOptions();
 
 
   const [markdown, setMarkdown] = useState('');
   const [markdown, setMarkdown] = useState('');
   const [isReEdit, setIsReEdit] = useState(false);
   const [isReEdit, setIsReEdit] = useState(false);

+ 8 - 1
packages/app/src/components/PageComment/ReplyComments.tsx

@@ -3,6 +3,8 @@ import React, { useState } from 'react';
 
 
 import { Collapse } from 'reactstrap';
 import { Collapse } from 'reactstrap';
 
 
+import { RendererOptions } from '~/services/renderer/renderer';
+
 import { ICommentHasId, ICommentHasIdList } from '../../interfaces/comment';
 import { ICommentHasId, ICommentHasIdList } from '../../interfaces/comment';
 import { IUser } from '../../interfaces/user';
 import { IUser } from '../../interfaces/user';
 import { useIsAllReplyShown } from '../../stores/context';
 import { useIsAllReplyShown } from '../../stores/context';
@@ -13,11 +15,13 @@ import styles from './ReplyComments.module.scss';
 
 
 
 
 type ReplycommentsProps = {
 type ReplycommentsProps = {
+  rendererOptions: RendererOptions,
   isReadOnly: boolean,
   isReadOnly: boolean,
   revisionId: string,
   revisionId: string,
   revisionCreatedAt: Date,
   revisionCreatedAt: Date,
   currentUser: IUser,
   currentUser: IUser,
   replyList: ICommentHasIdList,
   replyList: ICommentHasIdList,
+  highlightKeywords?: string[],
   deleteBtnClicked: (comment: ICommentHasId) => void,
   deleteBtnClicked: (comment: ICommentHasId) => void,
   onComment: () => void,
   onComment: () => void,
 }
 }
@@ -25,7 +29,8 @@ type ReplycommentsProps = {
 export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
 export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
 
 
   const {
   const {
-    isReadOnly, revisionId, revisionCreatedAt, currentUser, replyList, deleteBtnClicked, onComment,
+    rendererOptions, isReadOnly, revisionId, revisionCreatedAt, currentUser, replyList, highlightKeywords,
+    deleteBtnClicked, onComment,
   } = props;
   } = props;
 
 
   const { data: isAllReplyShown } = useIsAllReplyShown();
   const { data: isAllReplyShown } = useIsAllReplyShown();
@@ -36,11 +41,13 @@ export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
     return (
     return (
       <div key={reply._id} className={`${styles['page-comment-reply']} ml-4 ml-sm-5 mr-3`}>
       <div key={reply._id} className={`${styles['page-comment-reply']} ml-4 ml-sm-5 mr-3`}>
         <Comment
         <Comment
+          rendererOptions={rendererOptions}
           comment={reply}
           comment={reply}
           revisionId={revisionId}
           revisionId={revisionId}
           revisionCreatedAt={revisionCreatedAt}
           revisionCreatedAt={revisionCreatedAt}
           currentUser={currentUser}
           currentUser={currentUser}
           isReadOnly={isReadOnly}
           isReadOnly={isReadOnly}
+          highlightKeywords={highlightKeywords}
           deleteBtnClicked={deleteBtnClicked}
           deleteBtnClicked={deleteBtnClicked}
           onComment={onComment}
           onComment={onComment}
         />
         />

+ 2 - 1
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -123,7 +123,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
-  const { data: rendererOptions } = useSearchResultOptions();
+  const { data: rendererOptions } = useSearchResultOptions(pageWithMeta.data.path, highlightKeywords);
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
 
 
   const duplicateItemClickedHandler = useCallback(async(pageToDuplicate) => {
   const duplicateItemClickedHandler = useCallback(async(pageToDuplicate) => {
@@ -217,6 +217,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
           highlightKeywords={highlightKeywords}
           highlightKeywords={highlightKeywords}
         />
         />
         <PageComment
         <PageComment
+          rendererOptions={rendererOptions}
           pageId={page._id}
           pageId={page._id}
           revision={page.revision}
           revision={page.revision}
           currentUser={currentUser}
           currentUser={currentUser}

+ 90 - 0
packages/app/src/services/renderer/rehype-plugins/keyword-highlighter.ts

@@ -0,0 +1,90 @@
+import { Root, Element, Text } from 'hast';
+import rehypeRewrite from 'rehype-rewrite';
+import { Plugin } from 'unified';
+
+
+/**
+ * This method returns ['foo', 'bar', 'foo']
+ *  when the arguments are { keyword: 'foo', value: 'foobarfoo' }
+ * @param keyword
+ * @param value
+ * @returns
+ */
+function splitWithKeyword(keyword: string, value: string): string[] {
+  if (value.length === 0) {
+    return [];
+  }
+
+  let cursorStart = 0;
+  let cursorEnd = 0;
+
+  const splitted: string[] = [];
+
+  do {
+    cursorEnd = value.indexOf(keyword, cursorStart);
+
+    // not found
+    if (cursorEnd === -1) {
+      cursorEnd = value.length;
+    }
+    // keyword is found
+    else if (cursorEnd === cursorStart) {
+      cursorEnd += keyword.length;
+    }
+
+    splitted.push(value.slice(cursorStart, cursorEnd));
+    cursorStart = cursorEnd;
+  } while (cursorStart < value.length);
+
+  return splitted;
+}
+
+function wrapWithEm(textElement: Text): Element {
+  return {
+    type: 'element',
+    tagName: 'em',
+    properties: {
+      className: 'highlighted-keyword',
+    },
+    children: [textElement],
+  };
+}
+
+function highlight(keyword: string, node: Text, index: number, parent: Root | Element): void {
+  if (node.value.includes(keyword)) {
+    const splitted = splitWithKeyword(keyword, node.value);
+
+    parent.children[index] = {
+      type: 'element',
+      tagName: 'span',
+      properties: {},
+      children: splitted.map((text) => {
+        return text === keyword
+          ? wrapWithEm({ type: 'text', value: keyword })
+          : { type: 'text', value: text };
+      }),
+    };
+  }
+}
+
+
+export type KeywordHighlighterPluginParams = {
+  keywords?: string | string[],
+}
+
+export const rehypePlugin: Plugin<[KeywordHighlighterPluginParams]> = (options) => {
+  if (options?.keywords == null) {
+    return node => node;
+  }
+
+  const keywords = (typeof options.keywords === 'string') ? [options.keywords] : options.keywords;
+
+  // return rehype-rewrite with hithlighter
+  return rehypeRewrite.bind(this)({
+    rewrite: (node, index, parent) => {
+      if (parent != null && index != null && node.type === 'text') {
+        keywords.forEach(keyword => highlight(keyword, node, index, parent));
+      }
+    },
+  });
+};

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

@@ -29,6 +29,7 @@ import loggerFactory from '~/utils/logger';
 
 
 import * as addClass from './rehype-plugins/add-class';
 import * as addClass from './rehype-plugins/add-class';
 import * as addLineNumberAttribute from './rehype-plugins/add-line-number-attribute';
 import * as addLineNumberAttribute from './rehype-plugins/add-line-number-attribute';
+import * as keywordHighlighter from './rehype-plugins/keyword-highlighter';
 import { relativeLinks } from './rehype-plugins/relative-links';
 import { relativeLinks } from './rehype-plugins/relative-links';
 import { relativeLinksByPukiwikiLikeLinker } from './rehype-plugins/relative-links-by-pukiwiki-like-linker';
 import { relativeLinksByPukiwikiLikeLinker } from './rehype-plugins/relative-links-by-pukiwiki-like-linker';
 import { pukiwikiLikeLinker } from './remark-plugins/pukiwiki-like-linker';
 import { pukiwikiLikeLinker } from './remark-plugins/pukiwiki-like-linker';
@@ -282,6 +283,7 @@ const generateCommonOptions = (pagePath: string|undefined, config: RendererConfi
   return {
   return {
     remarkPlugins: [
     remarkPlugins: [
       gfm,
       gfm,
+      emoji,
       pukiwikiLikeLinker,
       pukiwikiLikeLinker,
       growiPlugin,
       growiPlugin,
     ],
     ],
@@ -312,7 +314,6 @@ export const generateViewOptions = (
 
 
   // add remark plugins
   // add remark plugins
   remarkPlugins.push(
   remarkPlugins.push(
-    emoji,
     math,
     math,
     lsxGrowiPlugin.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
   );
   );
@@ -386,10 +387,10 @@ export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementN
 
 
   const options = generateCommonOptions(undefined, config);
   const options = generateCommonOptions(undefined, config);
 
 
-  const { remarkPlugins, rehypePlugins } = options;
+  const { rehypePlugins } = options;
 
 
   // add remark plugins
   // add remark plugins
-  remarkPlugins.push(emoji);
+  // remarkPlugins.push();
 
 
   // add rehype plugins
   // add rehype plugins
   rehypePlugins.push(
   rehypePlugins.push(
@@ -407,24 +408,13 @@ export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementN
   return options;
   return options;
 };
 };
 
 
-export const generatePreviewOptions = (pagePath: string, config: RendererConfig): RendererOptions => {
-  // // Add configurers for preview
-  // renderer.addConfigurers([
-  //   new FooternoteConfigurer(),
-  //   new HeaderLineNumberConfigurer(),
-  //   new TableConfigurer(),
-  // ]);
-
-  // renderer.setMarkdownSettings({ breaks: rendererSettings?.isEnabledLinebreaks });
-  // renderer.configure();
-
+export const generateSimpleViewOptions = (config: RendererConfig, pagePath: string, highlightKeywords?: string | string[]): RendererOptions => {
   const options = generateCommonOptions(pagePath, config);
   const options = generateCommonOptions(pagePath, config);
 
 
   const { remarkPlugins, rehypePlugins, components } = options;
   const { remarkPlugins, rehypePlugins, components } = options;
 
 
   // add remark plugins
   // add remark plugins
   remarkPlugins.push(
   remarkPlugins.push(
-    emoji,
     math,
     math,
     lsxGrowiPlugin.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
   );
   );
@@ -435,16 +425,12 @@ export const generatePreviewOptions = (pagePath: string, config: RendererConfig)
   // add rehype plugins
   // add rehype plugins
   rehypePlugins.push(
   rehypePlugins.push(
     [lsxGrowiPlugin.rehypePlugin, { pagePath }],
     [lsxGrowiPlugin.rehypePlugin, { pagePath }],
-    addLineNumberAttribute.rehypePlugin,
+    [keywordHighlighter.rehypePlugin, { keywords: highlightKeywords }],
     [sanitize, deepmerge(
     [sanitize, deepmerge(
       commonSanitizeOption,
       commonSanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
-      addLineNumberAttribute.sanitizeOption,
     )],
     )],
     katex,
     katex,
-    // [autoLinkHeadings, {
-    //   behavior: 'append',
-    // }]
   );
   );
 
 
   // add components
   // add components
@@ -456,29 +442,38 @@ export const generatePreviewOptions = (pagePath: string, config: RendererConfig)
   return options;
   return options;
 };
 };
 
 
-export const generateCommentPreviewOptions = (config: RendererConfig): RendererOptions => {
-  const options = generateCommonOptions(undefined, config);
-  const { remarkPlugins, rehypePlugins } = options;
+export const generatePreviewOptions = (config: RendererConfig, pagePath: string): RendererOptions => {
+  const options = generateCommonOptions(pagePath, config);
+
+  const { remarkPlugins, rehypePlugins, components } = options;
 
 
   // add remark plugins
   // add remark plugins
-  remarkPlugins.push(emoji);
-  if (config.isEnabledLinebreaksInComments) {
+  remarkPlugins.push(
+    math,
+    lsxGrowiPlugin.remarkPlugin,
+  );
+  if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
     remarkPlugins.push(breaks);
   }
   }
 
 
-  // renderer.addConfigurers([
-  //   new TableConfigurer(),
-  // ]);
-
-  // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaksInComments });
-  // renderer.configure();
-
   // add rehype plugins
   // add rehype plugins
   rehypePlugins.push(
   rehypePlugins.push(
-    [sanitize, commonSanitizeOption],
+    [lsxGrowiPlugin.rehypePlugin, { pagePath }],
+    addLineNumberAttribute.rehypePlugin,
+    [sanitize, deepmerge(
+      commonSanitizeOption,
+      lsxGrowiPlugin.sanitizeOption,
+      addLineNumberAttribute.sanitizeOption,
+    )],
+    katex,
   );
   );
 
 
-  verifySanitizePlugin(options);
+  // add components
+  if (components != null) {
+    components.lsx = props => <Lsx {...props} />;
+  }
+
+  verifySanitizePlugin(options, false);
   return options;
   return options;
 };
 };
 
 

+ 37 - 10
packages/app/src/stores/renderer.tsx

@@ -5,7 +5,7 @@ import useSWRImmutable from 'swr/immutable';
 import { RendererConfig } from '~/interfaces/services/renderer';
 import { RendererConfig } from '~/interfaces/services/renderer';
 import {
 import {
   RendererOptions,
   RendererOptions,
-  generatePreviewOptions, generateCommentPreviewOptions, generateOthersOptions,
+  generateSimpleViewOptions, generatePreviewOptions, generateOthersOptions,
   generateViewOptions, generateTocOptions,
   generateViewOptions, generateTocOptions,
 } from '~/services/renderer/renderer';
 } from '~/services/renderer/renderer';
 
 
@@ -83,29 +83,56 @@ export const usePreviewOptions = (): SWRResponse<RendererOptions, Error> => {
   const isAllDataValid = currentPagePath != null && rendererConfig != null;
   const isAllDataValid = currentPagePath != null && rendererConfig != null;
 
 
   const key = isAllDataValid
   const key = isAllDataValid
-    ? ['previewOptions', currentPagePath, rendererConfig]
+    ? ['previewOptions', rendererConfig, currentPagePath]
     : null;
     : null;
 
 
   return useSWRImmutable<RendererOptions, Error>(
   return useSWRImmutable<RendererOptions, Error>(
     key,
     key,
-    (rendererId, currentPagePath, rendererConfig) => generatePreviewOptions(currentPagePath, rendererConfig),
+    (rendererId, rendererConfig, currentPagePath) => generatePreviewOptions(rendererConfig, currentPagePath),
     {
     {
-      fallbackData: isAllDataValid ? generatePreviewOptions(currentPagePath, rendererConfig) : undefined,
+      fallbackData: isAllDataValid ? generatePreviewOptions(rendererConfig, currentPagePath) : undefined,
     },
     },
   );
   );
 };
 };
 
 
-export const useCommentPreviewOptions = (): SWRResponse<RendererOptions, Error> => {
-  const key = 'commentPreviewOptions';
+export const useCommentForCurrentPageOptions = (): SWRResponse<RendererOptions, Error> => {
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: rendererConfig } = useRendererConfig();
+
+  const isAllDataValid = currentPagePath != null && rendererConfig != null;
+
+  const key = isAllDataValid
+    ? ['commentForCurrentPageOptions', rendererConfig, currentPagePath]
+    : null;
 
 
-  return _useOptionsBase(key, generateCommentPreviewOptions);
+  return useSWRImmutable<RendererOptions, Error>(
+    key,
+    (rendererId, rendererConfig, currentPagePath) => generateSimpleViewOptions(rendererConfig, currentPagePath),
+    {
+      fallbackData: isAllDataValid ? generateSimpleViewOptions(rendererConfig, currentPagePath) : undefined,
+    },
+  );
 };
 };
+export const useCommentPreviewOptions = useCommentForCurrentPageOptions;
 
 
-export const useSearchResultOptions = (): SWRResponse<RendererOptions, Error> => {
-  const key = 'searchResultOptions';
+export const useSelectedPagePreviewOptions = (pagePath: string, highlightKeywords?: string | string[]): SWRResponse<RendererOptions, Error> => {
+  const { data: rendererConfig } = useRendererConfig();
 
 
-  return _useOptionsBase(key, generateOthersOptions);
+  const isAllDataValid = rendererConfig != null;
+
+  const key = isAllDataValid
+    ? ['selectedPagePreviewOptions', rendererConfig, pagePath, highlightKeywords]
+    : null;
+
+  return useSWRImmutable<RendererOptions, Error>(
+    key,
+    (rendererId, rendererConfig, pagePath, highlightKeywords) => generateSimpleViewOptions(rendererConfig, pagePath, highlightKeywords),
+    {
+      fallbackData: isAllDataValid ? generateSimpleViewOptions(rendererConfig, pagePath, highlightKeywords) : undefined,
+    },
+  );
 };
 };
+export const useSearchResultOptions = useSelectedPagePreviewOptions;
 
 
 export const useTimelineOptions = (): SWRResponse<RendererOptions, Error> => {
 export const useTimelineOptions = (): SWRResponse<RendererOptions, Error> => {
   const key = 'timelineOptions';
   const key = 'timelineOptions';

+ 28 - 2
yarn.lock

@@ -11456,7 +11456,7 @@ hast-util-sanitize@^4.0.0:
   dependencies:
   dependencies:
     "@types/hast" "^2.0.0"
     "@types/hast" "^2.0.0"
 
 
-hast-util-select@^5.0.2:
+hast-util-select@^5.0.2, hast-util-select@~5.0.1:
   version "5.0.2"
   version "5.0.2"
   resolved "https://registry.yarnpkg.com/hast-util-select/-/hast-util-select-5.0.2.tgz#8c603ebacf0f47e154c5fa2e5b7efc520813866b"
   resolved "https://registry.yarnpkg.com/hast-util-select/-/hast-util-select-5.0.2.tgz#8c603ebacf0f47e154c5fa2e5b7efc520813866b"
   integrity sha512-QGN5o7N8gq1BhUX96ApLE8izOXlf+IPkOVGXcp9Dskdd3w0OqZrn6faPAmS0/oVogwJOd0lWFSYmBK75e+030g==
   integrity sha512-QGN5o7N8gq1BhUX96ApLE8izOXlf+IPkOVGXcp9Dskdd3w0OqZrn6faPAmS0/oVogwJOd0lWFSYmBK75e+030g==
@@ -19240,6 +19240,15 @@ rehype-raw@^6.1.1:
     hast-util-raw "^7.2.0"
     hast-util-raw "^7.2.0"
     unified "^10.0.0"
     unified "^10.0.0"
 
 
+rehype-rewrite@^3.0.6:
+  version "3.0.6"
+  resolved "https://registry.yarnpkg.com/rehype-rewrite/-/rehype-rewrite-3.0.6.tgz#21e86982c7f2c169121bf10dd191f3768c6a6b29"
+  integrity sha512-REDTNCvsKcAazy8IQWzKp66AhSUDSOIKssSCqNqCcT9sN7JCwAAm3mWGTUdUzq80ABuy8d0D6RBwbnewu1aY1g==
+  dependencies:
+    hast-util-select "~5.0.1"
+    unified "~10.1.1"
+    unist-util-visit "~4.1.0"
+
 rehype-sanitize@^5.0.1:
 rehype-sanitize@^5.0.1:
   version "5.0.1"
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/rehype-sanitize/-/rehype-sanitize-5.0.1.tgz#dac01a7417bdd329260c74c74449697b4be5eb56"
   resolved "https://registry.yarnpkg.com/rehype-sanitize/-/rehype-sanitize-5.0.1.tgz#dac01a7417bdd329260c74c74449697b4be5eb56"
@@ -23617,7 +23626,7 @@ unified-message-control@^4.0.0:
     vfile-location "^4.0.0"
     vfile-location "^4.0.0"
     vfile-message "^3.0.0"
     vfile-message "^3.0.0"
 
 
-unified@^10.0.0, unified@^10.1.0:
+unified@^10.0.0, unified@^10.1.0, unified@~10.1.1:
   version "10.1.2"
   version "10.1.2"
   resolved "https://registry.yarnpkg.com/unified/-/unified-10.1.2.tgz#b1d64e55dafe1f0b98bb6c719881103ecf6c86df"
   resolved "https://registry.yarnpkg.com/unified/-/unified-10.1.2.tgz#b1d64e55dafe1f0b98bb6c719881103ecf6c86df"
   integrity sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==
   integrity sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==
@@ -23836,6 +23845,14 @@ unist-util-visit-parents@^5.0.0:
     "@types/unist" "^2.0.0"
     "@types/unist" "^2.0.0"
     unist-util-is "^5.0.0"
     unist-util-is "^5.0.0"
 
 
+unist-util-visit-parents@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-5.1.1.tgz#868f353e6fce6bf8fa875b251b0f4fec3be709bb"
+  integrity sha512-gks4baapT/kNRaWxuGkl5BIhoanZo7sC/cUT/JToSRNL1dYoXRFl75d++NkjYk4TAu2uv2Px+l8guMajogeuiw==
+  dependencies:
+    "@types/unist" "^2.0.0"
+    unist-util-is "^5.0.0"
+
 unist-util-visit@^1.1.0:
 unist-util-visit@^1.1.0:
   version "1.4.1"
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-1.4.1.tgz#4724aaa8486e6ee6e26d7ff3c8685960d560b1e3"
   resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-1.4.1.tgz#4724aaa8486e6ee6e26d7ff3c8685960d560b1e3"
@@ -23861,6 +23878,15 @@ unist-util-visit@^4.0.0, unist-util-visit@^4.1.0:
     unist-util-is "^5.0.0"
     unist-util-is "^5.0.0"
     unist-util-visit-parents "^5.0.0"
     unist-util-visit-parents "^5.0.0"
 
 
+unist-util-visit@~4.1.0:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-4.1.1.tgz#1c4842d70bd3df6cc545276f5164f933390a9aad"
+  integrity sha512-n9KN3WV9k4h1DxYR1LoajgN93wpEi/7ZplVe02IoB4gH5ctI1AaF2670BLHQYbwj+pY83gFtyeySFiyMHJklrg==
+  dependencies:
+    "@types/unist" "^2.0.0"
+    unist-util-is "^5.0.0"
+    unist-util-visit-parents "^5.1.1"
+
 universal-bunyan@^0.9.2:
 universal-bunyan@^0.9.2:
   version "0.9.2"
   version "0.9.2"
   resolved "https://registry.yarnpkg.com/universal-bunyan/-/universal-bunyan-0.9.2.tgz#4cf09dc34070390d8f5df4fe9af6a80fcd0dd574"
   resolved "https://registry.yarnpkg.com/universal-bunyan/-/universal-bunyan-0.9.2.tgz#4cf09dc34070390d8f5df4fe9af6a80fcd0dd574"