feat: sync scroll between editor and preview

This commit is contained in:
DIYgod 2022-10-19 20:25:53 +01:00
parent 4b7ebbb36e
commit 87c6f368ef
No known key found for this signature in database
GPG Key ID: D159328F47A80DCA
6 changed files with 331 additions and 91 deletions

View File

@ -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",

View File

@ -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:

View File

@ -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>
)
}

View File

@ -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],
)
}

View File

@ -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,
}
}

View File

@ -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![${file.name.replace(/\.\w+$/, "")}](${key})\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![${file.name.replace(/\.\w+$/, "")}](${key})\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>