From b5c8f1d60a3ba087bda2b2d838ae6f4ae07e9dff Mon Sep 17 00:00:00 2001 From: Arkadzi Rakouski Date: Tue, 1 Aug 2023 00:43:41 +0300 Subject: [PATCH] implement table of contents (#146) * implement table of contents * refactor by review comments * refactor by review comments * lint * minor fix --------- Co-authored-by: bniwredyc --- public/icons/hide-table-of-contents.svg | 3 + public/icons/show-table-of-contents.svg | 4 + public/locales/en/translation.json | 1 + public/locales/ru/translation.json | 1 + src/components/Article/FullArticle.tsx | 80 +++++++------ src/components/Editor/Editor.tsx | 48 +++++--- src/components/Editor/Prosemirror.scss | 1 + .../TableOfContents.module.scss | 113 ++++++++++++++++++ .../TableOfContents/TableOfContents.tsx | 103 ++++++++++++++++ src/components/TableOfContents/index.tsx | 1 + src/stores/router.ts | 5 +- src/utils/media-query.ts | 1 + 12 files changed, 306 insertions(+), 55 deletions(-) create mode 100644 public/icons/hide-table-of-contents.svg create mode 100644 public/icons/show-table-of-contents.svg create mode 100644 src/components/TableOfContents/TableOfContents.module.scss create mode 100644 src/components/TableOfContents/TableOfContents.tsx create mode 100644 src/components/TableOfContents/index.tsx diff --git a/public/icons/hide-table-of-contents.svg b/public/icons/hide-table-of-contents.svg new file mode 100644 index 00000000..6c8b9a0e --- /dev/null +++ b/public/icons/hide-table-of-contents.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/show-table-of-contents.svg b/public/icons/show-table-of-contents.svg new file mode 100644 index 00000000..d887df58 --- /dev/null +++ b/public/icons/show-table-of-contents.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 0a400d03..1cb4269e 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -72,6 +72,7 @@ "Create gallery": "Create gallery", "Create post": "Create post", "Create video": "Create video", + "contents": "contents", "Date of Birth": "Date of Birth", "Decline": "Decline", "Delete": "Delete", diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json index 72f3047e..9814529a 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -76,6 +76,7 @@ "Create gallery": "Создать галерею", "Create post": "Создать публикацию", "Create video": "Создать видео", + "contents": "оглавление", "Date of Birth": "Дата рождения", "Decline": "Отмена", "Delete": "Удалить", diff --git a/src/components/Article/FullArticle.tsx b/src/components/Article/FullArticle.tsx index 78a76403..57ee81b4 100644 --- a/src/components/Article/FullArticle.tsx +++ b/src/components/Article/FullArticle.tsx @@ -1,37 +1,45 @@ -import { formatDate } from '../../utils' -import { Icon } from '../_shared/Icon' -import { AuthorCard } from '../Author/AuthorCard' -import { AudioPlayer } from './AudioPlayer' -import type { Author, Shout } from '../../graphql/types.gen' -import MD from './MD' -import { SharePopup } from './SharePopup' -import { getDescription } from '../../utils/meta' -import { ShoutRatingControl } from './ShoutRatingControl' -import { clsx } from 'clsx' -import { CommentsTree } from './CommentsTree' -import { useSession } from '../../context/session' -import { VideoPlayer } from '../_shared/VideoPlayer' -import { getPagePath } from '@nanostores/router' -import { router, useRouter } from '../../stores/router' -import { useReactions } from '../../context/reactions' -import { Title } from '@solidjs/meta' -import { useLocalize } from '../../context/localize' -import stylesHeader from '../Nav/Header.module.scss' -import styles from './Article.module.scss' -import { imageProxy } from '../../utils/imageProxy' -import { Popover } from '../_shared/Popover' -import article from '../Editor/extensions/Article' import { createEffect, For, createMemo, onMount, Show, createSignal } from 'solid-js' +import { Title } from '@solidjs/meta' +import { clsx } from 'clsx' +import { getPagePath } from '@nanostores/router' + +import MD from './MD' + +import type { Author, Shout } from '../../graphql/types.gen' +import { useSession } from '../../context/session' +import { useLocalize } from '../../context/localize' +import { useReactions } from '../../context/reactions' + import { MediaItem } from '../../pages/types' + +import { router, useRouter } from '../../stores/router' + +import { formatDate } from '../../utils' +import { getDescription } from '../../utils/meta' +import { imageProxy } from '../../utils/imageProxy' +import { isDesktop } from '../../utils/media-query' +import { AuthorCard } from '../Author/AuthorCard' +import { TableOfContents } from '../TableOfContents' +import { AudioPlayer } from './AudioPlayer' +import { SharePopup } from './SharePopup' +import { ShoutRatingControl } from './ShoutRatingControl' +import { CommentsTree } from './CommentsTree' +import stylesHeader from '../Nav/Header.module.scss' import { AudioHeader } from './AudioHeader' + +import { Popover } from '../_shared/Popover' +import { VideoPlayer } from '../_shared/VideoPlayer' +import { Icon } from '../_shared/Icon' import { SolidSwiper } from '../_shared/SolidSwiper' -interface ArticleProps { +import styles from './Article.module.scss' + +interface Props { article: Shout scrollToComments?: boolean } -export const FullArticle = (props: ArticleProps) => { +export const FullArticle = (props: Props) => { const { t } = useLocalize() const { user, @@ -39,6 +47,7 @@ export const FullArticle = (props: ArticleProps) => { actions: { requireAuthentication } } = useSession() const [isReactionsLoaded, setIsReactionsLoaded] = createSignal(false) + const formattedDate = createMemo(() => formatDate(new Date(props.article.createdAt))) const mainTopic = createMemo( @@ -47,14 +56,6 @@ export const FullArticle = (props: ArticleProps) => { props.article.topics[0] ) - onMount(async () => { - await loadReactionsBy({ - by: { shout: props.article.slug } - }) - - setIsReactionsLoaded(true) - }) - const canEdit = () => props.article.authors?.some((a) => a.slug === user()?.slug) const handleBookmarkButtonClick = (ev) => { @@ -118,6 +119,14 @@ export const FullArticle = (props: ArticleProps) => { actions: { loadReactionsBy } } = useReactions() + onMount(async () => { + await loadReactionsBy({ + by: { shout: props.article.slug } + }) + + setIsReactionsLoaded(true) + }) + return ( <> {props.article.title} @@ -201,13 +210,16 @@ export const FullArticle = (props: ArticleProps) => { -
+
}>
+ + +
diff --git a/src/components/Editor/Editor.tsx b/src/components/Editor/Editor.tsx index 88c02846..79ed2e94 100644 --- a/src/components/Editor/Editor.tsx +++ b/src/components/Editor/Editor.tsx @@ -1,6 +1,9 @@ -import { createEffect, createSignal } from 'solid-js' +import { createEffect, createSignal, Show } from 'solid-js' import { createTiptapEditor, useEditorHTML } from 'solid-tiptap' -import { useLocalize } from '../../context/localize' +import { IndexeddbPersistence } from 'y-indexeddb' +import uniqolor from 'uniqolor' +import * as Y from 'yjs' +import type { Doc } from 'yjs/dist/src/utils/Doc' import { Bold } from '@tiptap/extension-bold' import { BubbleMenu } from '@tiptap/extension-bubble-menu' import { Dropcursor } from '@tiptap/extension-dropcursor' @@ -21,28 +24,32 @@ import { Highlight } from '@tiptap/extension-highlight' import { Link } from '@tiptap/extension-link' import { Document } from '@tiptap/extension-document' import { Text } from '@tiptap/extension-text' +import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor' +import { isTextSelection } from '@tiptap/core' +import { Paragraph } from '@tiptap/extension-paragraph' +import Focus from '@tiptap/extension-focus' +import { Collaboration } from '@tiptap/extension-collaboration' +import { HocuspocusProvider } from '@hocuspocus/provider' + import { CustomImage } from './extensions/CustomImage' import { CustomBlockquote } from './extensions/CustomBlockquote' import { Figure } from './extensions/Figure' -import { Paragraph } from '@tiptap/extension-paragraph' -import Focus from '@tiptap/extension-focus' -import * as Y from 'yjs' -import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor' -import { Collaboration } from '@tiptap/extension-collaboration' -import { IndexeddbPersistence } from 'y-indexeddb' -import { useSession } from '../../context/session' -import uniqolor from 'uniqolor' -import { HocuspocusProvider } from '@hocuspocus/provider' import { Embed } from './extensions/Embed' + +import { useSession } from '../../context/session' +import { useLocalize } from '../../context/localize' +import { useEditorContext } from '../../context/editor' +import { TrailingNode } from './extensions/TrailingNode' +import Article from './extensions/Article' + import { TextBubbleMenu } from './TextBubbleMenu' import { FigureBubbleMenu, BlockquoteBubbleMenu, IncutBubbleMenu } from './BubbleMenu' import { EditorFloatingMenu } from './EditorFloatingMenu' -import { useEditorContext } from '../../context/editor' -import { isTextSelection } from '@tiptap/core' -import type { Doc } from 'yjs/dist/src/utils/Doc' +import { TableOfContents } from '../TableOfContents' + +import { isDesktop } from '../../utils/media-query' + import './Prosemirror.scss' -import { TrailingNode } from './extensions/TrailingNode' -import Article from './extensions/Article' type Props = { shoutId: number @@ -243,8 +250,11 @@ export const Editor = (props: Props) => { }) return ( - <> -
(editorElRef.current = el)} /> +
+
(editorElRef.current = el)} id="editorBody" /> + + + { }} /> (floatingMenuRef.current = el)} /> - +
) } diff --git a/src/components/Editor/Prosemirror.scss b/src/components/Editor/Prosemirror.scss index a2890a03..9e6667ad 100644 --- a/src/components/Editor/Prosemirror.scss +++ b/src/components/Editor/Prosemirror.scss @@ -35,6 +35,7 @@ } .articleEditor blockquote, +.articleEditor figure, .articleEditor article[data-type='incut'] { @media (min-width: 768px) { margin-left: calc(21.9% + 3px) !important; diff --git a/src/components/TableOfContents/TableOfContents.module.scss b/src/components/TableOfContents/TableOfContents.module.scss new file mode 100644 index 00000000..56ab8ace --- /dev/null +++ b/src/components/TableOfContents/TableOfContents.module.scss @@ -0,0 +1,113 @@ +.TableOfContentsFixedWrapper { + position: fixed; + top: 150px; + right: 20px; + + width: 281px; +} + +.TableOfContentsFixedWrapperLefted { + right: auto; + left: 20px; +} + +.TableOfContentsContainer { + position: absolute; + right: 0; + top: 0; + + display: flex; + width: 100%; + height: auto; + padding: 20px; + flex-direction: column; + align-items: flex-start; + + background-color: transparent; +} + +.TableOfContentsHeader { + width: 100%; + display: flex; + justify-content: space-between; +} + +.TableOfContentsHeading { + margin: 0; + + color: #000; + font-size: 22px; + font-style: normal; + font-weight: 700; + line-height: 24px; +} + +.TableOfContentsPrimaryButton { + position: absolute; + right: 20px; + top: 10px; + + display: flex; + align-items: center; + justify-content: center; + + width: 40px; + height: 40px; + + background: transparent; + border: none; + cursor: pointer; + + &:hover { + box-shadow: 0px 0px 1px 1px rgba(0, 0, 0, 0.3); + } +} + +.TableOfContentsPrimaryButtonLefted { + right: auto; + left: 20px; +} + +.TableOfContentsHeadingsList { + position: relative; + + display: flex; + flex-direction: column; + list-style-type: none; + + margin: 0; + padding: 0 38px 0 0; +} + +.TableOfContentsHeadingsItem { + margin-top: 20px; + + color: #000; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + text-align: left; + letter-spacing: -0.14px; + + &:hover { + transform: scale(1.05); + } +} + +.TableOfContentsHeadingsItemH3, +.TableOfContentsHeadingsItemH4 { + margin-top: 8px; +} + +.TableOfContentsHeadingsItemH3 { + padding-left: 8px; +} + +.TableOfContentsHeadingsItemH4 { + padding-left: 16px; +} + +.TableOfContentsIconRotated { + transform: rotate(180deg); +} diff --git a/src/components/TableOfContents/TableOfContents.tsx b/src/components/TableOfContents/TableOfContents.tsx new file mode 100644 index 00000000..a0b36dd2 --- /dev/null +++ b/src/components/TableOfContents/TableOfContents.tsx @@ -0,0 +1,103 @@ +import { onMount, For, Show, createSignal } from 'solid-js' +import { clsx } from 'clsx' + +import { DEFAULT_HEADER_OFFSET } from '../../stores/router' + +import { useLocalize } from '../../context/localize' + +import { Icon } from '../_shared/Icon' + +import styles from './TableOfContents.module.scss' + +interface Props { + variant: 'article' | 'editor' + parentSelector: string +} + +const scrollToHeader = (element) => { + window.scrollTo({ + behavior: 'smooth', + top: + element.getBoundingClientRect().top - + document.body.getBoundingClientRect().top - + DEFAULT_HEADER_OFFSET + }) +} + +export const TableOfContents = (props: Props) => { + const { t } = useLocalize() + + const [headings, setHeadings] = createSignal([]) + const [areHeadingsLoaded, setAreHeadingsLoaded] = createSignal(false) + + const [isVisible, setIsVisible] = createSignal(true) + const toggleIsVisible = () => { + setIsVisible((visible) => !visible) + } + + onMount(() => { + const { parentSelector } = props + // eslint-disable-next-line unicorn/prefer-spread + setHeadings(Array.from(document.querySelector(parentSelector).querySelectorAll('h2, h3, h4'))) + + setAreHeadingsLoaded(true) + }) + + return ( + +
+
+ +
+

{t('contents')}

+
+
    + + {(h) => ( +
  • +
  • + )} +
    +
+
+ + +
+
+
+ ) +} diff --git a/src/components/TableOfContents/index.tsx b/src/components/TableOfContents/index.tsx new file mode 100644 index 00000000..cb544a98 --- /dev/null +++ b/src/components/TableOfContents/index.tsx @@ -0,0 +1 @@ +export { TableOfContents } from './TableOfContents' diff --git a/src/stores/router.ts b/src/stores/router.ts index 9afb1d59..bd23d7bb 100644 --- a/src/stores/router.ts +++ b/src/stores/router.ts @@ -49,6 +49,8 @@ const routerStore = createRouter(ROUTES, { export const router = routerStore +export const DEFAULT_HEADER_OFFSET = 80 // 80px for header + const checkOpenOnClient = (link: HTMLAnchorElement, event) => { return ( link && @@ -73,9 +75,8 @@ const scrollToHash = (hash: string) => { } const anchor = document.querySelector(selector) - const headerOffset = 80 // 80px for header const elementPosition = anchor ? anchor.getBoundingClientRect().top : 0 - const newScrollTop = elementPosition + window.scrollY - headerOffset + const newScrollTop = elementPosition + window.scrollY - DEFAULT_HEADER_OFFSET window.scrollTo({ top: newScrollTop, diff --git a/src/utils/media-query.ts b/src/utils/media-query.ts index 0fb977a2..90de6b79 100644 --- a/src/utils/media-query.ts +++ b/src/utils/media-query.ts @@ -1,3 +1,4 @@ import { createMediaQuery } from '@solid-primitives/media' export const isMobile = createMediaQuery('(max-width: 767px)') +export const isDesktop = createMediaQuery('(min-width: 1200px)')