feat: note preview popup

This commit is contained in:
liqingwei 2021-05-27 22:09:20 +08:00 committed by cinwell.li
parent 4656f10796
commit 9f73c21aca
17 changed files with 274 additions and 47 deletions

View File

@ -1,48 +1,23 @@
import NoteState from 'libs/web/state/note'
import { removeMarkdown } from 'libs/web/utils/markdown'
import { FC, useMemo } from 'react'
import { NextSeo } from 'next-seo'
import { FC } from 'react'
// TODO: Maybe can custom
import 'highlight.js/styles/zenburn.css'
import { PageMode } from 'libs/shared/page'
import Error from 'next/error'
import useI18n from 'libs/web/hooks/use-i18n'
import { useEditorTheme } from 'components/editor/theme'
import classNames from 'classnames'
export const PostContainer: FC<{
baseURL: string
pageMode: PageMode
post?: string
}> = ({ baseURL, pageMode, post = '' }) => {
const { t } = useI18n()
small?: boolean
}> = ({ post = '', small = false }) => {
const { note } = NoteState.useContainer()
const description = useMemo(
() => removeMarkdown(note?.content).slice(0, 100),
[note]
)
const editorTheme = useEditorTheme()
if (pageMode !== PageMode.PUBLIC) {
return <Error statusCode={404} title={t('Not a public page')}></Error>
}
return (
<article className="prose mx-auto pb-10 prose-sm md:prose-2xl px-4 md:px-0">
<NextSeo
title={note?.title}
titleTemplate="%s - Powered by Notea"
description={description}
openGraph={{
title: note?.title,
description,
url: `${baseURL}/${note?.id}`,
images: [{ url: note?.pic ?? `${baseURL}/logo_1280x640.png` }],
type: 'article',
article: {
publishedTime: note?.date,
},
}}
/>
<article
className={classNames('prose mx-auto pb-10 prose-sm px-4 md:px-0', {
'md:prose-2xl': !small,
})}
>
<header>
<h1 className="pt-10">{note?.title}</h1>
</header>

View File

@ -6,6 +6,7 @@ import useFetcher from 'libs/web/api/fetcher'
import { NOTE_DELETED } from 'libs/shared/meta'
import { isNoteLink } from 'libs/shared/note'
import { useToast } from 'libs/web/hooks/use-toast'
import PortalState from 'libs/web/state/portal'
const onSearchLink = async (keyword: string) => {
const list = await searchNote(keyword, NOTE_DELETED.NORMAL)
@ -72,7 +73,28 @@ const useEditState = () => {
[error, request, toast]
)
return { onCreateLink, onSearchLink, onClickLink, onUploadImage }
const { preview } = PortalState.useContainer()
const onHoverLink = useCallback(
(event: MouseEvent) => {
const link = event.target as HTMLLinkElement
const href = link.href.replace(location.origin, '')
if (isNoteLink(href)) {
preview.setData({ id: href.slice(1) })
preview.setAnchor(link)
}
return true
},
[preview]
)
return {
onCreateLink,
onSearchLink,
onClickLink,
onUploadImage,
onHoverLink,
}
}
export default useEditState

View File

@ -7,6 +7,7 @@ import { DebouncedState } from 'use-debounce/lib/useDebouncedCallback'
import { useEditorTheme } from './theme'
import useMounted from 'libs/web/hooks/use-mounted'
import useI18n from 'libs/web/hooks/use-i18n'
import Tooltip from './tooltip'
const Editor: FC<{
note?: NoteModel
@ -18,6 +19,7 @@ const Editor: FC<{
onCreateLink,
onClickLink,
onUploadImage,
onHoverLink,
} = useEditState()
const height = use100vh()
const mounted = useMounted()
@ -107,7 +109,9 @@ const Editor: FC<{
onSearchLink={onSearchLink}
onCreateLink={onCreateLink}
onClickLink={onClickLink}
onHoverLink={onHoverLink}
dictionary={dictionary}
tooltip={Tooltip}
className="px-4 md:px-0"
/>
<style jsx global>{`

View File

@ -0,0 +1,15 @@
import { Tooltip as MuiTooltip } from '@material-ui/core'
import { FC } from 'react'
const Tooltip: FC<{
tooltip: string
placement: 'top' | 'bottom' | 'left' | 'right'
}> = ({ children, tooltip, placement }) => {
return (
<MuiTooltip title={tooltip} placement={placement}>
<div>{children}</div>
</MuiTooltip>
)
}
export default Tooltip

View File

@ -10,6 +10,7 @@ import Share from 'heroicons/react/outline/Share'
import Duplicate from 'heroicons/react/outline/Duplicate'
import Document from 'heroicons/react/outline/Document'
import DocumentText from 'heroicons/react/outline/DocumentText'
import Link from 'heroicons/react/outline/Link'
import Selector from 'heroicons/react/outline/Selector'
export const ICONS = {
@ -24,6 +25,7 @@ export const ICONS = {
Document,
DocumentText,
Selector,
Link,
}
const IconButton = forwardRef<
@ -45,7 +47,7 @@ const IconButton = forwardRef<
ref={ref}
{...attrs}
className={classNames(
'p-0.5 hover:bg-gray-400 cursor-pointer w-7 h-7 md:w-6 md:h-6',
'block p-0.5 hover:bg-gray-400 cursor-pointer w-7 h-7 md:w-6 md:h-6',
{ rounded },
className
)}

View File

@ -14,6 +14,7 @@ import ShareModal from 'components/portal/share-modal'
import { SwipeableDrawer } from '@material-ui/core'
import SidebarMenu from 'components/portal/sidebar-menu/sidebar-menu'
import { NoteModel } from 'libs/shared/note'
import PreviewModal from 'components/portal/preview-modal'
const MainWrapper: FC = ({ children }) => {
const {
@ -101,6 +102,7 @@ const LayoutMain: FC<{
<SearchModal />
</SearchState.Provider>
<ShareModal />
<PreviewModal />
<SidebarMenu />
</NoteState.Provider>
</NoteTreeState.Provider>

View File

@ -1,17 +1,52 @@
import { NoteModel } from 'libs/shared/note'
import { PageMode } from 'libs/shared/page'
import { TreeModel } from 'libs/shared/tree'
import NoteState from 'libs/web/state/note'
import NoteTreeState from 'libs/web/state/tree'
import { FC } from 'react'
import { FC, useMemo } from 'react'
import Error from 'next/error'
import useI18n from 'libs/web/hooks/use-i18n'
import { NextSeo } from 'next-seo'
import { removeMarkdown } from 'libs/web/utils/markdown'
const LayoutPublic: FC<{
tree?: TreeModel
note?: NoteModel
}> = ({ children, note, tree }) => {
pageMode: PageMode
baseURL: string
}> = ({ children, note, tree, pageMode, baseURL }) => {
const { t } = useI18n()
const description = useMemo(
() => removeMarkdown(note?.content).slice(0, 100),
[note]
)
if (pageMode !== PageMode.PUBLIC) {
return <Error statusCode={404} title={t('Not a public page')}></Error>
}
return (
<NoteTreeState.Provider initialState={tree}>
<NoteState.Provider initialState={note}>{children}</NoteState.Provider>
</NoteTreeState.Provider>
<>
<NextSeo
title={note?.title}
titleTemplate="%s - Powered by Notea"
description={description}
openGraph={{
title: note?.title,
description,
url: `${baseURL}/${note?.id}`,
images: [{ url: note?.pic ?? `${baseURL}/logo_1280x640.png` }],
type: 'article',
article: {
publishedTime: note?.date,
},
}}
/>
<NoteTreeState.Provider initialState={tree}>
<NoteState.Provider initialState={note}>{children}</NoteState.Provider>
</NoteTreeState.Provider>
</>
)
}

View File

@ -0,0 +1,125 @@
import React, {
FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { Paper, Popper } from '@material-ui/core'
import PortalState from 'libs/web/state/portal'
import { useRouter } from 'next/router'
import { NoteCacheItem } from 'libs/web/cache'
import useNoteAPI from 'libs/web/api/note'
import { PostContainer } from 'components/container/post-container'
import { renderMarkdown } from 'libs/shared/markdown/render'
import IconButton from 'components/icon-button'
import HotkeyTooltip from 'components/hotkey-tooltip'
import useI18n from 'libs/web/hooks/use-i18n'
const LEAVE_DELAY = 200
const ENTER_DELAY = 500
const PreviewModal: FC = () => {
const { t } = useI18n()
const {
preview: { anchor, open, close, visible, data, setAnchor },
} = PortalState.useContainer()
const anchorRef = useRef<HTMLLinkElement | null>()
const router = useRouter()
const leaveTimer = useRef<number>()
const enterTimer = useRef<number>()
const { fetch: fetchNote } = useNoteAPI()
const [note, setNote] = useState<NoteCacheItem>()
const handleEnter = useCallback(() => {
clearTimeout(leaveTimer.current)
clearTimeout(enterTimer.current)
enterTimer.current = window.setTimeout(() => {
open()
}, ENTER_DELAY)
}, [open])
const handleLeave = useCallback(() => {
clearTimeout(leaveTimer.current)
clearTimeout(enterTimer.current)
leaveTimer.current = window.setTimeout(() => {
close()
}, LEAVE_DELAY)
}, [close])
useEffect(() => {
if (anchorRef.current) {
anchorRef.current.removeEventListener('mouseover', handleEnter)
anchorRef.current.removeEventListener('mouseleave', handleLeave)
}
if (anchor) {
anchorRef.current = anchor as HTMLLinkElement
anchorRef.current.addEventListener('mouseover', handleEnter)
anchorRef.current.addEventListener('mouseleave', handleLeave)
handleEnter()
}
return () => {
anchorRef.current?.addEventListener('mouseover', handleEnter)
anchorRef.current?.addEventListener('mouseleave', handleLeave)
close()
}
}, [handleEnter, handleLeave, anchor, close])
const findNote = useCallback(
async (id: string) => {
setNote(await fetchNote(id))
},
[fetchNote]
)
const gotoLink = useCallback(() => {
if (note?.id) {
router.push(note.id)
}
}, [note?.id, router])
const post = useMemo(() => {
return renderMarkdown(note?.content ?? '')
}, [note?.content])
useEffect(() => {
setAnchor(null)
close()
}, [router.query.id, close, setAnchor])
useEffect(() => {
if (data?.id) {
findNote(data?.id)
}
}, [data?.id, findNote])
if (!anchor) {
return null
}
return (
<Popper
onMouseOver={handleEnter}
onMouseLeave={handleLeave}
placement="bottom"
anchorEl={anchor}
open={visible}
>
<Paper className="relative bg-gray-50 text-gray-800 w-full h-96 md:w-96 dark:bg-gray-800">
<div className="absolute right-2 top-2">
<HotkeyTooltip text={t('Open link')}>
<IconButton onClick={gotoLink} icon="Link"></IconButton>
</HotkeyTooltip>
</div>
<div className="overflow-y-scroll h-full p-4">
<PostContainer small post={post}></PostContainer>
</div>
</Paper>
</Popper>
)
}
export default PreviewModal

View File

@ -1,5 +1,5 @@
import { SSRMiddeware } from '../connect'
import { renderMarkdown } from '../markdown/render'
import { renderMarkdown } from 'libs/shared/markdown/render'
export const applyPost: SSRMiddeware = async (req, _res, next) => {
req.props = {

View File

@ -57,6 +57,7 @@
"next-themes": "^0.0.14",
"notistack": "^1.0.7",
"outline-icons": "^1.27.0",
"prosemirror-inputrules": "^1.1.3",
"react": "^17.0.1",
"react-div-100vh": "^0.5.6",
"react-hotkeys-hook": "^3.3.1",
@ -81,6 +82,7 @@
"@types/md5": "^2.3.0",
"@types/minio": "^7.0.7",
"@types/node": "^12.12.21",
"@types/prosemirror-inputrules": "^1.0.4",
"@types/react": "^16.9.16",
"@types/react-dom": "^16.9.4",
"@types/ua-parser-js": "^0.7.35",

View File

@ -30,8 +30,8 @@ export default function EditNotePage({
}
return (
<LayoutPublic tree={tree} note={note}>
<PostContainer post={post} pageMode={pageMode} baseURL={baseURL} />
<LayoutPublic tree={tree} note={note} pageMode={pageMode} baseURL={baseURL}>
<PostContainer post={post} />
</LayoutPublic>
)
}

View File

@ -9,7 +9,7 @@ import { genId } from 'libs/shared/id'
import { ROOT_ID } from 'libs/shared/tree'
import { createNote } from 'libs/server/note'
import { NoteModel } from 'libs/shared/note'
import { parseMarkdownTitle } from 'libs/shared/markdown'
import { parseMarkdownTitle } from 'libs/shared/markdown/parse-markdown-title'
const MARKDOWN_EXT = [
'.markdown',

View File

@ -16,8 +16,8 @@ export default function SharePage({
post,
}: ServerProps) {
return (
<LayoutPublic tree={tree} note={note}>
<PostContainer post={post} pageMode={pageMode} baseURL={baseURL} />
<LayoutPublic tree={tree} note={note} pageMode={pageMode} baseURL={baseURL}>
<PostContainer post={post} />
</LayoutPublic>
)
}

View File

@ -2325,6 +2325,11 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.0.tgz#692dfdecd6c97f5380c42dd50f19261f9f604deb"
integrity sha512-0/41wHcurotvSOTHQUFkgL702c3pyWR1mToSrrX3pGPvGfpHTv3Ksx0M4UVuU5VJfjVb62Eyr1eKO1tWNUCg2Q==
"@types/orderedmap@*":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@types/orderedmap/-/orderedmap-1.0.0.tgz#807455a192bba52cbbb4517044bc82bdbfa8c596"
integrity sha512-dxKo80TqYx3YtBipHwA/SdFmMMyLCnP+5mkEqN0eMjcTBzHkiiX0ES118DsjDBjvD+zeSsSU9jULTZ+frog+Gw==
"@types/parse-json@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
@ -2335,6 +2340,46 @@
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7"
integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==
"@types/prosemirror-inputrules@^1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@types/prosemirror-inputrules/-/prosemirror-inputrules-1.0.4.tgz#4cb75054d954aa0f6f42099be05eb6c0e6958bae"
integrity sha512-lJIMpOjO47SYozQybUkpV6QmfuQt7GZKHtVrvS+mR5UekA8NMC5HRIVMyaIauJLWhKU6oaNjpVaXdw41kh165g==
dependencies:
"@types/prosemirror-model" "*"
"@types/prosemirror-state" "*"
"@types/prosemirror-model@*":
version "1.13.0"
resolved "https://registry.yarnpkg.com/@types/prosemirror-model/-/prosemirror-model-1.13.0.tgz#d05937e918c3cac2cf49630ccab04a65fc5fffd6"
integrity sha512-EIUr2R38Zh9n1eA8BQ1C3NX/XLV9U44DhNVk8x3Sth2RW+wa7jNA82XHMPOoapsOTfmpnh32xaHBOzREiBqdPQ==
dependencies:
"@types/orderedmap" "*"
"@types/prosemirror-state@*":
version "1.2.6"
resolved "https://registry.yarnpkg.com/@types/prosemirror-state/-/prosemirror-state-1.2.6.tgz#bb0169084239a8393b354c6fda5420fc347d6bab"
integrity sha512-tJo0wC+/cQvbrPDVx01Fnng9Fs41bAMVxgJY1KLOyIsUPN0otUN1KdoQurLMmHNHTvIna9ZXxjZD//xJKLYfJw==
dependencies:
"@types/prosemirror-model" "*"
"@types/prosemirror-transform" "*"
"@types/prosemirror-view" "*"
"@types/prosemirror-transform@*":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@types/prosemirror-transform/-/prosemirror-transform-1.1.3.tgz#cf30d275976978d1c0317d0659145426fc49ce6f"
integrity sha512-qtnd4jMoBgUAF2Vy2uRCVY4/LN3d069PP9XTIKrfk7mwWPYKonBYv1NsaBGTpK26sOPu0p7eJNZwaiNYmbfIwA==
dependencies:
"@types/prosemirror-model" "*"
"@types/prosemirror-view@*":
version "1.17.1"
resolved "https://registry.yarnpkg.com/@types/prosemirror-view/-/prosemirror-view-1.17.1.tgz#0895df5a57ae6e68d4f3f8020d9be4ef52192980"
integrity sha512-PNiGGc6BffxHQzMR09UUilsBR8xFPDsKiPIXb4K/g56voPIvqq1pqySnWFfSR50Vo4ZL0tss3VBLWiiiKzVahQ==
dependencies:
"@types/prosemirror-model" "*"
"@types/prosemirror-state" "*"
"@types/prosemirror-transform" "*"
"@types/react-dom@^16.9.4":
version "16.9.10"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.10.tgz#4485b0bec3d41f856181b717f45fd7831101156f"