mirror of
https://github.com/QingWei-Li/notea.git
synced 2024-11-29 12:53:00 +03:00
feat: note preview popup
This commit is contained in:
parent
4656f10796
commit
9f73c21aca
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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>{`
|
||||
|
15
components/editor/tooltip.tsx
Normal file
15
components/editor/tooltip.tsx
Normal 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
|
@ -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
|
||||
)}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
125
components/portal/preview-modal.tsx
Normal file
125
components/portal/preview-modal.tsx
Normal 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
|
@ -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 = {
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
45
yarn.lock
45
yarn.lock
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user