implement table of contents (#146)

* implement table of contents

* refactor by review comments

* refactor by review comments

* lint

* minor fix

---------

Co-authored-by: bniwredyc <bniwredyc@gmail.com>
This commit is contained in:
Arkadzi Rakouski 2023-08-01 00:43:41 +03:00 committed by GitHub
parent fd2841ab6c
commit b5c8f1d60a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 306 additions and 55 deletions

View File

@ -0,0 +1,3 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 24V21H12V19H22V16L26 20L22 24Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 163 B

View File

@ -0,0 +1,4 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" fill="#F7F7F7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 15.2444C12 14.5558 12.5794 14 13.2971 14C14.0148 14 14.5941 14.5558 14.5941 15.2444C14.5941 15.9329 14.0148 16.4887 13.2971 16.4887C12.5794 16.4887 12 15.9329 12 15.2444ZM12 20.222C12 19.5334 12.5794 18.9776 13.2971 18.9776C14.0148 18.9776 14.5941 19.5334 14.5941 20.222C14.5941 20.9105 14.0148 21.4663 13.2971 21.4663C12.5794 21.4663 12 20.9105 12 20.222ZM13.2971 23.9548C12.5794 23.9548 12 24.5189 12 25.1991C12 25.8794 12.588 26.4435 13.2971 26.4435C14.0061 26.4435 14.5941 25.8794 14.5941 25.1991C14.5941 24.5189 14.0148 23.9548 13.2971 23.9548ZM28.0015 26.0284H15.8956V24.3692H28.0015V26.0284ZM15.8956 21.0517H28.0015V19.3925H15.8956V21.0517ZM15.8956 16.0741V14.4149H28.0015V16.0741H15.8956Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 915 B

View File

@ -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",

View File

@ -76,6 +76,7 @@
"Create gallery": "Создать галерею",
"Create post": "Создать публикацию",
"Create video": "Создать видео",
"contents": "оглавление",
"Date of Birth": "Дата рождения",
"Decline": "Отмена",
"Delete": "Удалить",

View File

@ -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 (
<>
<Title>{props.article.title}</Title>
@ -201,13 +210,16 @@ export const FullArticle = (props: ArticleProps) => {
</Show>
<Show when={body()}>
<div class={styles.shoutBody}>
<div id="shoutBody" class={styles.shoutBody}>
<Show when={!body().startsWith('<')} fallback={<div innerHTML={body()} />}>
<MD body={body()} />
</Show>
</div>
</Show>
</article>
<Show when={isDesktop() && body()}>
<TableOfContents variant="article" parentSelector="#shoutBody" />
</Show>
</div>
</div>

View File

@ -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 (
<>
<div ref={(el) => (editorElRef.current = el)} />
<div class="position-relative">
<div ref={(el) => (editorElRef.current = el)} id="editorBody" />
<Show when={isDesktop() && html()}>
<TableOfContents variant="editor" parentSelector="#editorBody" />
</Show>
<TextBubbleMenu
isCommonMarkup={isCommonMarkup()}
editor={editor()}
@ -269,6 +279,6 @@ export const Editor = (props: Props) => {
}}
/>
<EditorFloatingMenu editor={editor()} ref={(el) => (floatingMenuRef.current = el)} />
</>
</div>
)
}

View File

@ -35,6 +35,7 @@
}
.articleEditor blockquote,
.articleEditor figure,
.articleEditor article[data-type='incut'] {
@media (min-width: 768px) {
margin-left: calc(21.9% + 3px) !important;

View File

@ -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);
}

View File

@ -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<Element[]>([])
const [areHeadingsLoaded, setAreHeadingsLoaded] = createSignal<boolean>(false)
const [isVisible, setIsVisible] = createSignal<boolean>(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 (
<Show when={areHeadingsLoaded()}>
<div
class={clsx(styles.TableOfContentsFixedWrapper, {
[styles.TableOfContentsFixedWrapperLefted]: props.variant === 'editor'
})}
>
<div class={styles.TableOfContentsContainer}>
<Show when={isVisible()}>
<div class={styles.TableOfContentsHeader}>
<p class={styles.TableOfContentsHeading}>{t('contents')}</p>
</div>
<ul class={styles.TableOfContentsHeadingsList}>
<For each={headings()}>
{(h) => (
<li>
<button
class={clsx(styles.TableOfContentsHeadingsItem, {
[styles.TableOfContentsHeadingsItemH3]: h.nodeName === 'H3',
[styles.TableOfContentsHeadingsItemH4]: h.nodeName === 'H4'
})}
innerHTML={h.textContent}
onClick={(e) => {
e.preventDefault()
scrollToHeader(h)
}}
/>
</li>
)}
</For>
</ul>
</Show>
<button
class={clsx(styles.TableOfContentsPrimaryButton, {
[styles.TableOfContentsPrimaryButtonLefted]: props.variant === 'editor' && !isVisible()
})}
onClick={(e) => {
e.preventDefault()
toggleIsVisible()
}}
>
<Show when={isVisible()} fallback={<Icon name="show-table-of-contents" class={'icon'} />}>
<Icon
name="hide-table-of-contents"
class={clsx('icon', {
[styles.TableOfContentsIconRotated]: props.variant === 'editor'
})}
/>
</Show>
</button>
</div>
</div>
</Show>
)
}

View File

@ -0,0 +1 @@
export { TableOfContents } from './TableOfContents'

View File

@ -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,

View File

@ -1,3 +1,4 @@
import { createMediaQuery } from '@solid-primitives/media'
export const isMobile = createMediaQuery('(max-width: 767px)')
export const isDesktop = createMediaQuery('(min-width: 1200px)')