diff --git a/package.json b/package.json index 11d1d5de..ec7c54ef 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1743e124..ef6e2a6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/components/common/PageContent.tsx b/src/components/common/PageContent.tsx index 9e261334..e610ad4f 100644 --- a/src/components/common/PageContent.tsx +++ b/src/components/common/PageContent.tsx @@ -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 + onScroll?: (scrollTop: number) => void + onMouseEnter?: () => void + parsedContent?: ReturnType +}> = ({ + className, + content, + toc, + inputRef, + onScroll, + onMouseEnter, + parsedContent, +}) => { useCodeCopy() - let parsedContent: ReturnType | 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 ( -
-
{parsedContent?.element}
- {toc && parsedContent?.toc && } +
onScroll?.((e.target as any)?.scrollTop)} + > +
+ {inParsedContent?.element} +
+ {toc && inParsedContent?.toc && }
) } diff --git a/src/components/ui/Editor.tsx b/src/components/ui/Editor.tsx new file mode 100644 index 00000000..39d842b4 --- /dev/null +++ b/src/components/ui/Editor.tsx @@ -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([]) + 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( + () => ( + + ), + [value, onChange, extensions, onUpdate, onCreateEditor, onMouseEnter], + ) +} diff --git a/src/markdown/index.ts b/src/markdown/index.ts index 13774ca1..bf3e0d35 100644 --- a/src/markdown/index.ts +++ b/src/markdown/index.ts @@ -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 cover: string toc: TocResult | null + tree: Root | null } export type Rendered = { @@ -42,6 +44,7 @@ export type Rendered = { frontMatter: Record 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, } } diff --git a/src/pages/dashboard/[subdomain]/editor.tsx b/src/pages/dashboard/[subdomain]/editor.tsx index c18aeb47..9216bf7e 100644 --- a/src/pages/dashboard/[subdomain]/editor.tsx +++ b/src/pages/dashboard/[subdomain]/editor.tsx @@ -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("") const [view, setView] = useState() + const [tree, setTree] = useState() - 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(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() { />
- 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..." /> { + setCurrentScrollArea("preview") + }} >