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 (
+
+
+
+
+
+
+
+ {(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)')