feat: sync scroll between editor and preview
This commit is contained in:
parent
4b7ebbb36e
commit
87c6f368ef
|
|
@ -41,6 +41,7 @@
|
|||
"@trpc/next": "^9.27.2",
|
||||
"@trpc/react": "^9.27.2",
|
||||
"@trpc/server": "^9.27.2",
|
||||
"@uiw/codemirror-extensions-events": "^4.12.4",
|
||||
"@uiw/react-codemirror": "^4.12.4",
|
||||
"@urql/core": "^3.0.3",
|
||||
"@web-std/form-data": "^3.0.2",
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ specifiers:
|
|||
'@types/sortablejs': ^1.15.0
|
||||
'@types/ua-parser-js': ^0.7.36
|
||||
'@types/uuid': ^8.3.4
|
||||
'@uiw/codemirror-extensions-events': ^4.12.4
|
||||
'@uiw/react-codemirror': ^4.12.4
|
||||
'@unocss/cli': ^0.45.25
|
||||
'@unocss/preset-icons': ^0.45.25
|
||||
|
|
@ -156,6 +157,7 @@ dependencies:
|
|||
'@trpc/next': 9.27.2_dyodlyrl4jlccmeg4rab7q5slm
|
||||
'@trpc/react': 9.27.2_r3nohbkvzkycnbsdj5gzhfx2rm
|
||||
'@trpc/server': 9.27.2
|
||||
'@uiw/codemirror-extensions-events': 4.12.4_@codemirror+view@6.2.5
|
||||
'@uiw/react-codemirror': 4.12.4_s3us5uspwddq2sobjfryl7muay
|
||||
'@urql/core': 3.0.3
|
||||
'@web-std/form-data': 3.0.2
|
||||
|
|
@ -420,6 +422,7 @@ packages:
|
|||
'@codemirror/language': ^6.0.0
|
||||
'@codemirror/state': ^6.0.0
|
||||
'@codemirror/view': ^6.0.0
|
||||
'@lezer/common': ^1.0.0
|
||||
dependencies:
|
||||
'@codemirror/language': 6.2.1
|
||||
'@codemirror/state': 6.1.2
|
||||
|
|
@ -2660,6 +2663,16 @@ packages:
|
|||
'@codemirror/search': 6.2.1
|
||||
'@codemirror/state': 6.1.2
|
||||
'@codemirror/view': 6.2.5
|
||||
transitivePeerDependencies:
|
||||
- '@lezer/common'
|
||||
dev: false
|
||||
|
||||
/@uiw/codemirror-extensions-events/4.12.4_@codemirror+view@6.2.5:
|
||||
resolution: {integrity: sha512-wbALZgg+NvTwwbeo3eHA4DVQCNGn88aYydrZe9w8ddFtZOsPx3j5hvPqETcEXQybgbpjoTdAKH/oNFD07MTgKQ==}
|
||||
peerDependencies:
|
||||
'@codemirror/view': '>=6.0.0'
|
||||
dependencies:
|
||||
'@codemirror/view': 6.2.5
|
||||
dev: false
|
||||
|
||||
/@uiw/react-codemirror/4.12.4_s3us5uspwddq2sobjfryl7muay:
|
||||
|
|
@ -2681,6 +2694,7 @@ packages:
|
|||
react-dom: 18.2.0_react@18.2.0
|
||||
transitivePeerDependencies:
|
||||
- '@codemirror/language'
|
||||
- '@lezer/common'
|
||||
dev: false
|
||||
|
||||
/@unocss/astro/0.45.25:
|
||||
|
|
@ -4165,6 +4179,8 @@ packages:
|
|||
'@codemirror/search': 6.2.1
|
||||
'@codemirror/state': 6.1.2
|
||||
'@codemirror/view': 6.2.5
|
||||
transitivePeerDependencies:
|
||||
- '@lezer/common'
|
||||
dev: false
|
||||
|
||||
/color-convert/1.9.3:
|
||||
|
|
|
|||
|
|
@ -2,23 +2,48 @@ import clsx from "clsx"
|
|||
import { useCodeCopy } from "~/hooks/useCodeCopy"
|
||||
import { renderPageContent } from "~/markdown"
|
||||
import { PostToc } from "~/components/site/PostToc"
|
||||
import { MutableRefObject, useMemo } from "react"
|
||||
|
||||
export const PageContent: React.FC<{
|
||||
content?: string
|
||||
className?: string
|
||||
toc?: boolean
|
||||
}> = ({ className, content, toc }) => {
|
||||
inputRef?: MutableRefObject<HTMLDivElement | null>
|
||||
onScroll?: (scrollTop: number) => void
|
||||
onMouseEnter?: () => void
|
||||
parsedContent?: ReturnType<typeof renderPageContent>
|
||||
}> = ({
|
||||
className,
|
||||
content,
|
||||
toc,
|
||||
inputRef,
|
||||
onScroll,
|
||||
onMouseEnter,
|
||||
parsedContent,
|
||||
}) => {
|
||||
useCodeCopy()
|
||||
|
||||
let parsedContent: ReturnType<typeof renderPageContent> | null = null
|
||||
if (content) {
|
||||
parsedContent = renderPageContent(content)
|
||||
}
|
||||
const inParsedContent = useMemo(() => {
|
||||
if (parsedContent) {
|
||||
return parsedContent
|
||||
} else if (content) {
|
||||
const result = renderPageContent(content)
|
||||
return result
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}, [content, parsedContent])
|
||||
|
||||
return (
|
||||
<div className={clsx("relative", className)}>
|
||||
<div className="xlog-post-content prose">{parsedContent?.element}</div>
|
||||
{toc && parsedContent?.toc && <PostToc data={parsedContent?.toc} />}
|
||||
<div
|
||||
className={clsx("relative", className)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onScroll={(e) => onScroll?.((e.target as any)?.scrollTop)}
|
||||
>
|
||||
<div className="xlog-post-content prose" ref={inputRef}>
|
||||
{inParsedContent?.element}
|
||||
</div>
|
||||
{toc && inParsedContent?.toc && <PostToc data={inParsedContent?.toc} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
import CodeMirror from "@uiw/react-codemirror"
|
||||
import { EditorView, ViewUpdate } from "@codemirror/view"
|
||||
import { markdown } from "@codemirror/lang-markdown"
|
||||
import { scroll } from "@uiw/codemirror-extensions-events"
|
||||
import type { EditorState } from "@codemirror/state"
|
||||
import { useCallback, useState, useEffect, useMemo } from "react"
|
||||
|
||||
export const Editor: React.FC<{
|
||||
value: string
|
||||
onChange?: (value: string, viewUpdate: ViewUpdate) => void
|
||||
handleDropFile?: (file: File) => void
|
||||
onScroll?: (scrollTop: number) => void
|
||||
onUpdate?: (update: ViewUpdate) => void
|
||||
onCreateEditor?: (view: EditorView, state: EditorState) => void
|
||||
onMouseEnter?: () => void
|
||||
}> = ({
|
||||
value,
|
||||
onChange,
|
||||
handleDropFile,
|
||||
onScroll,
|
||||
onUpdate,
|
||||
onCreateEditor,
|
||||
onMouseEnter,
|
||||
}) => {
|
||||
const [extensions, setExtensions] = useState<any>([])
|
||||
useEffect(() => {
|
||||
setExtensions([
|
||||
markdown(),
|
||||
EditorView.theme({
|
||||
".cm-scroller": {
|
||||
fontFamily: "var(--font-sans)",
|
||||
fontSize: "1rem",
|
||||
overflow: "auto",
|
||||
height: "100%",
|
||||
},
|
||||
".cm-content": {
|
||||
paddingBottom: "600px",
|
||||
},
|
||||
"&.cm-editor.cm-focused": {
|
||||
outline: "none",
|
||||
},
|
||||
"&.cm-editor": {
|
||||
height: "100%",
|
||||
},
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
drop(e) {
|
||||
const file = e.dataTransfer?.items[0]?.getAsFile()
|
||||
if (file) {
|
||||
handleDropFile?.(file)
|
||||
}
|
||||
},
|
||||
paste(e) {
|
||||
const file = e.clipboardData?.files[0]
|
||||
if (file) {
|
||||
handleDropFile?.(file)
|
||||
}
|
||||
},
|
||||
}),
|
||||
scroll({
|
||||
scroll: (evn) => {
|
||||
onScroll?.((evn.target as any)?.scrollTop)
|
||||
},
|
||||
}),
|
||||
EditorView.lineWrapping,
|
||||
])
|
||||
}, [onScroll, handleDropFile])
|
||||
|
||||
return useMemo(
|
||||
() => (
|
||||
<CodeMirror
|
||||
className="px-5 h-full border-r w-1/2"
|
||||
value={value}
|
||||
extensions={extensions}
|
||||
onCreateEditor={onCreateEditor}
|
||||
basicSetup={{
|
||||
lineNumbers: false,
|
||||
foldGutter: false,
|
||||
highlightActiveLine: false,
|
||||
history: true,
|
||||
defaultKeymap: true,
|
||||
historyKeymap: true,
|
||||
dropCursor: true,
|
||||
drawSelection: true,
|
||||
indentOnInput: true,
|
||||
crosshairCursor: true,
|
||||
allowMultipleSelections: true,
|
||||
syntaxHighlighting: true,
|
||||
}}
|
||||
indentWithTab={true}
|
||||
onChange={onChange}
|
||||
placeholder="Start writing..."
|
||||
onUpdate={onUpdate}
|
||||
onMouseEnter={onMouseEnter}
|
||||
/>
|
||||
),
|
||||
[value, onChange, extensions, onUpdate, onCreateEditor, onMouseEnter],
|
||||
)
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ import rehypeSlug from "rehype-slug"
|
|||
import rehypeAutolinkHeadings from "rehype-autolink-headings"
|
||||
import { toc, Result as TocResult } from "mdast-util-toc"
|
||||
import { Element } from "react-scroll"
|
||||
import type { Root } from "hast"
|
||||
|
||||
export type MarkdownEnv = {
|
||||
excerpt: string
|
||||
|
|
@ -33,6 +34,7 @@ export type MarkdownEnv = {
|
|||
__internal: Record<string, any>
|
||||
cover: string
|
||||
toc: TocResult | null
|
||||
tree: Root | null
|
||||
}
|
||||
|
||||
export type Rendered = {
|
||||
|
|
@ -42,6 +44,7 @@ export type Rendered = {
|
|||
frontMatter: Record<string, any>
|
||||
cover: string
|
||||
toc: TocResult | null
|
||||
tree: Root | null
|
||||
}
|
||||
|
||||
refractor.alias("html", ["svelte", "vue"])
|
||||
|
|
@ -59,6 +62,7 @@ export const renderPageContent = (
|
|||
frontMatter: {},
|
||||
cover: "",
|
||||
toc: null,
|
||||
tree: null,
|
||||
}
|
||||
|
||||
let contentHTML = ""
|
||||
|
|
@ -134,6 +138,9 @@ export const renderPageContent = (
|
|||
anchor: Element,
|
||||
} as any,
|
||||
})
|
||||
.use(() => (tree) => {
|
||||
env.tree = tree
|
||||
})
|
||||
.processSync(content)
|
||||
|
||||
contentHTML = result.toString()
|
||||
|
|
@ -145,5 +152,6 @@ export const renderPageContent = (
|
|||
frontMatter: env.frontMatter,
|
||||
cover: env.cover,
|
||||
toc: env.toc,
|
||||
tree: env.tree,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
import clsx from "clsx"
|
||||
import dayjs from "dayjs"
|
||||
import { useRouter } from "next/router"
|
||||
import { ChangeEvent, useCallback, useEffect, useState, useRef } from "react"
|
||||
import {
|
||||
ChangeEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
useRef,
|
||||
useMemo,
|
||||
} from "react"
|
||||
import toast from "react-hot-toast"
|
||||
import { toolbars } from "~/editor"
|
||||
import { DashboardLayout } from "~/components/dashboard/DashboardLayout"
|
||||
|
|
@ -24,9 +31,9 @@ import { PageContent } from "~/components/common/PageContent"
|
|||
import pinyin from "pinyin"
|
||||
import { GITHUB_LINK } from "~/lib/env"
|
||||
import type { Root } from "hast"
|
||||
import CodeMirror from "@uiw/react-codemirror"
|
||||
import { EditorView } from "@codemirror/view"
|
||||
import { markdown } from "@codemirror/lang-markdown"
|
||||
import type { EditorView } from "@codemirror/view"
|
||||
import { Editor } from "~/components/ui/Editor"
|
||||
import { renderPageContent } from "~/markdown"
|
||||
|
||||
const getInputDatetimeValue = (date: Date | string) => {
|
||||
const str = dayjs(date).format()
|
||||
|
|
@ -151,32 +158,6 @@ export default function SubdomainEditor() {
|
|||
}
|
||||
}, [createOrUpdatePage.isSuccess])
|
||||
|
||||
const handleDropFile = useCallback(
|
||||
async (file: File, view?: EditorView | null) => {
|
||||
const toastId = toast.loading("Uploading...")
|
||||
try {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
throw new Error("You can only upload images")
|
||||
}
|
||||
|
||||
const { key } = await uploadFile(file)
|
||||
toast.success("Uploaded!", {
|
||||
id: toastId,
|
||||
})
|
||||
view?.dispatch(
|
||||
view.state.replaceSelection(
|
||||
`\n\n`,
|
||||
),
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
toast.error(error.message, { id: toastId })
|
||||
}
|
||||
}
|
||||
},
|
||||
[uploadFile],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!page.data || !draftKey) return
|
||||
|
||||
|
|
@ -201,42 +182,159 @@ export default function SubdomainEditor() {
|
|||
}
|
||||
}, [page.data, subdomain, draftKey])
|
||||
|
||||
const [currentScrollArea, setCurrentScrollArea] = useState<string>("")
|
||||
const [view, setView] = useState<EditorView>()
|
||||
const [tree, setTree] = useState<Root | null>()
|
||||
|
||||
const extensions = [
|
||||
markdown(),
|
||||
EditorView.theme({
|
||||
".cm-scroller": {
|
||||
fontFamily: "var(--font-sans)",
|
||||
fontSize: "1rem",
|
||||
overflow: "auto",
|
||||
height: "100%",
|
||||
},
|
||||
".cm-content": {
|
||||
paddingBottom: "600px",
|
||||
},
|
||||
"&.cm-editor.cm-focused": {
|
||||
outline: "none",
|
||||
},
|
||||
"&.cm-editor": {
|
||||
height: "100%",
|
||||
},
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
drop(e) {
|
||||
const file = e.dataTransfer?.items[0]?.getAsFile()
|
||||
if (!file) return
|
||||
// preview
|
||||
const parsedContent = useMemo(() => {
|
||||
if (values.content) {
|
||||
const result = renderPageContent(values.content)
|
||||
setTree(result.tree)
|
||||
return result
|
||||
}
|
||||
}, [values.content])
|
||||
|
||||
handleDropFile(file, view)
|
||||
},
|
||||
paste(e) {
|
||||
const file = e.clipboardData?.files[0]
|
||||
if (!file) return
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
handleDropFile(file, view)
|
||||
},
|
||||
}),
|
||||
]
|
||||
// editor
|
||||
const onCreateEditor = useCallback(
|
||||
(view: EditorView) => {
|
||||
setView?.(view)
|
||||
},
|
||||
[setView],
|
||||
)
|
||||
const onChange = useCallback(
|
||||
(value: string) => {
|
||||
updateValue("content", value)
|
||||
},
|
||||
[updateValue],
|
||||
)
|
||||
|
||||
const handleDropFile = useCallback(
|
||||
async (file: File) => {
|
||||
const toastId = toast.loading("Uploading...")
|
||||
try {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
throw new Error("You can only upload images")
|
||||
}
|
||||
|
||||
const { key } = await uploadFile(file)
|
||||
toast.success("Uploaded!", {
|
||||
id: toastId,
|
||||
})
|
||||
view?.dispatch(
|
||||
view.state.replaceSelection(
|
||||
`\n\n`,
|
||||
),
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
toast.error(error.message, { id: toastId })
|
||||
}
|
||||
}
|
||||
},
|
||||
[uploadFile, view],
|
||||
)
|
||||
|
||||
const computedPosition = useCallback(() => {
|
||||
let previewChildNodes = previewRef.current?.childNodes[0]?.childNodes
|
||||
const editorElementList: number[] = []
|
||||
const previewElementList: number[] = []
|
||||
if (view?.state && previewChildNodes) {
|
||||
tree?.children.forEach((child, index) => {
|
||||
if (
|
||||
child.position &&
|
||||
previewChildNodes?.[index] &&
|
||||
(child as any).tagName !== "style"
|
||||
) {
|
||||
const line = view.state?.doc.line(child.position.start.line)
|
||||
const block = view.lineBlockAt(line.from)
|
||||
if (block) {
|
||||
editorElementList.push(block.top)
|
||||
previewElementList.push(
|
||||
(previewChildNodes[index] as HTMLElement).offsetTop,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
return {
|
||||
editorElementList,
|
||||
previewElementList,
|
||||
}
|
||||
}, [view, tree])
|
||||
|
||||
const onScroll = useCallback(
|
||||
(scrollTop: number, area: string) => {
|
||||
if (
|
||||
currentScrollArea === area &&
|
||||
previewRef.current?.parentElement &&
|
||||
view
|
||||
) {
|
||||
const position = computedPosition()
|
||||
|
||||
let selfElement
|
||||
let selfPostion
|
||||
let targetElement
|
||||
let targetPosition
|
||||
if (area === "preview") {
|
||||
selfElement = previewRef.current.parentElement
|
||||
selfPostion = position.previewElementList
|
||||
targetElement = view.scrollDOM
|
||||
targetPosition = position.editorElementList
|
||||
} else {
|
||||
selfElement = view.scrollDOM
|
||||
selfPostion = position.editorElementList
|
||||
targetElement = previewRef.current.parentElement
|
||||
targetPosition = position.previewElementList
|
||||
}
|
||||
|
||||
let scrollElementIndex = 0
|
||||
for (let i = 0; i < selfPostion.length; i++) {
|
||||
if (scrollTop < selfPostion[i]) {
|
||||
scrollElementIndex = i - 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// scroll to buttom
|
||||
if (scrollTop >= selfElement.scrollHeight - selfElement.clientHeight) {
|
||||
targetElement.scrollTop =
|
||||
targetElement.scrollHeight - targetElement.clientHeight
|
||||
return
|
||||
}
|
||||
|
||||
// scroll to position
|
||||
if (scrollElementIndex >= 0) {
|
||||
let ratio =
|
||||
(scrollTop - selfPostion[scrollElementIndex]) /
|
||||
(selfPostion[scrollElementIndex + 1] -
|
||||
selfPostion[scrollElementIndex])
|
||||
targetElement.scrollTop =
|
||||
ratio *
|
||||
(targetPosition[scrollElementIndex + 1] -
|
||||
targetPosition[scrollElementIndex]) +
|
||||
targetPosition[scrollElementIndex]
|
||||
}
|
||||
}
|
||||
},
|
||||
[view, computedPosition, currentScrollArea],
|
||||
)
|
||||
|
||||
const onEditorScroll = useCallback(
|
||||
(scrollTop: number) => {
|
||||
onScroll(scrollTop, "editor")
|
||||
},
|
||||
[onScroll],
|
||||
)
|
||||
|
||||
const onPreviewScroll = useCallback(
|
||||
(scrollTop: number) => {
|
||||
onScroll(scrollTop, "preview")
|
||||
},
|
||||
[onScroll],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -295,32 +393,25 @@ export default function SubdomainEditor() {
|
|||
/>
|
||||
</div>
|
||||
<div className="mt-5 flex-1 flex overflow-hidden">
|
||||
<CodeMirror
|
||||
className="px-5 h-full border-r w-1/2"
|
||||
<Editor
|
||||
value={values.content}
|
||||
extensions={extensions}
|
||||
onCreateEditor={(view) => setView(view)}
|
||||
basicSetup={{
|
||||
lineNumbers: false,
|
||||
foldGutter: false,
|
||||
highlightActiveLine: false,
|
||||
history: true,
|
||||
defaultKeymap: true,
|
||||
historyKeymap: true,
|
||||
dropCursor: true,
|
||||
drawSelection: true,
|
||||
indentOnInput: true,
|
||||
crosshairCursor: true,
|
||||
allowMultipleSelections: true,
|
||||
syntaxHighlighting: true,
|
||||
onChange={onChange}
|
||||
handleDropFile={handleDropFile}
|
||||
onScroll={onEditorScroll}
|
||||
// onUpdate={onUpdate}
|
||||
onCreateEditor={onCreateEditor}
|
||||
onMouseEnter={() => {
|
||||
setCurrentScrollArea("editor")
|
||||
}}
|
||||
indentWithTab={true}
|
||||
onChange={(value) => updateValue("content", value)}
|
||||
placeholder="Start writing..."
|
||||
/>
|
||||
<PageContent
|
||||
className="px-5 w-1/2 overflow-scroll pb-[200px]"
|
||||
content={values.content}
|
||||
parsedContent={parsedContent}
|
||||
inputRef={previewRef}
|
||||
onScroll={onPreviewScroll}
|
||||
onMouseEnter={() => {
|
||||
setCurrentScrollArea("preview")
|
||||
}}
|
||||
></PageContent>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue