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:
parent
fd2841ab6c
commit
b5c8f1d60a
3
public/icons/hide-table-of-contents.svg
Normal file
3
public/icons/hide-table-of-contents.svg
Normal 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 |
4
public/icons/show-table-of-contents.svg
Normal file
4
public/icons/show-table-of-contents.svg
Normal 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 |
|
@ -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",
|
||||
|
|
|
@ -76,6 +76,7 @@
|
|||
"Create gallery": "Создать галерею",
|
||||
"Create post": "Создать публикацию",
|
||||
"Create video": "Создать видео",
|
||||
"contents": "оглавление",
|
||||
"Date of Birth": "Дата рождения",
|
||||
"Decline": "Отмена",
|
||||
"Delete": "Удалить",
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
}
|
||||
|
||||
.articleEditor blockquote,
|
||||
.articleEditor figure,
|
||||
.articleEditor article[data-type='incut'] {
|
||||
@media (min-width: 768px) {
|
||||
margin-left: calc(21.9% + 3px) !important;
|
||||
|
|
113
src/components/TableOfContents/TableOfContents.module.scss
Normal file
113
src/components/TableOfContents/TableOfContents.module.scss
Normal 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);
|
||||
}
|
103
src/components/TableOfContents/TableOfContents.tsx
Normal file
103
src/components/TableOfContents/TableOfContents.tsx
Normal 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>
|
||||
)
|
||||
}
|
1
src/components/TableOfContents/index.tsx
Normal file
1
src/components/TableOfContents/index.tsx
Normal file
|
@ -0,0 +1 @@
|
|||
export { TableOfContents } from './TableOfContents'
|
|
@ -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,
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { createMediaQuery } from '@solid-primitives/media'
|
||||
|
||||
export const isMobile = createMediaQuery('(max-width: 767px)')
|
||||
export const isDesktop = createMediaQuery('(min-width: 1200px)')
|
||||
|
|
Loading…
Reference in New Issue
Block a user