From 44f374912891ff6ff76df40670ae082d0f39fb7b Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 24 Aug 2023 20:00:26 +0100 Subject: [PATCH] feat: DualColumnEditor component --- .../[subdomain]/editor/post-editor.tsx | 239 +--------------- src/components/dashboard/DualColumnEditor.tsx | 264 ++++++++++++++++++ 2 files changed, 273 insertions(+), 230 deletions(-) create mode 100644 src/components/dashboard/DualColumnEditor.tsx diff --git a/src/app/dashboard/[subdomain]/editor/post-editor.tsx b/src/app/dashboard/[subdomain]/editor/post-editor.tsx index 5d4d7c03..35787d97 100644 --- a/src/app/dashboard/[subdomain]/editor/post-editor.tsx +++ b/src/app/dashboard/[subdomain]/editor/post-editor.tsx @@ -1,20 +1,8 @@ "use client" -import { useDebounceEffect } from "ahooks" -import type { Root } from "mdast" import { nanoid } from "nanoid" -import dynamic from "next/dynamic" import { useParams, useRouter, useSearchParams } from "next/navigation" -import { - ChangeEvent, - FC, - memo, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react" +import { ChangeEvent, FC, memo, useCallback, useEffect, useState } from "react" import toast from "react-hot-toast" import { shallow } from "zustand/shallow" @@ -23,6 +11,7 @@ import { DateInput } from "@mantine/dates" import { useQueryClient } from "@tanstack/react-query" import { DashboardMain } from "~/components/dashboard/DashboardMain" +import DualColumnEditor from "~/components/dashboard/DualColumnEditor" import { OptionsButton } from "~/components/dashboard/OptionsButton" import { PublishButton } from "~/components/dashboard/PublishButton" import PublishedModal from "~/components/dashboard/PublishedModal" @@ -35,8 +24,7 @@ import { useModalStack } from "~/components/ui/ModalStack" import { Switch } from "~/components/ui/Switch" import { TagInput } from "~/components/ui/TagInput" import { UniLink } from "~/components/ui/UniLink" -import { toolbarShortcuts, toolbars } from "~/editor" -import { editorUpload } from "~/editor/Multimedia" +import { toolbars } from "~/editor" import { Values, initialEditorState, @@ -45,7 +33,6 @@ import { import { useGetState } from "~/hooks/useGetState" import { useIsMobileLayout } from "~/hooks/useMobileLayout" import { useBeforeMounted } from "~/hooks/useSyncOnce" -import { useUploadFile } from "~/hooks/useUploadFile" import { showConfetti } from "~/lib/confetti" import { RESERVED_TAGS } from "~/lib/constants" import { getDefaultSlug } from "~/lib/default-slug" @@ -56,7 +43,6 @@ import { getPageVisibility } from "~/lib/page-helpers" import { delStorage, setStorage } from "~/lib/storage" import { ExpandedNote, NoteType, PageVisibilityEnum } from "~/lib/types" import { cn, pick } from "~/lib/utils" -import { Rendered, renderPageContent } from "~/markdown" import { checkPageSlug } from "~/models/page.model" import { useCreatePage, @@ -67,30 +53,6 @@ import { } from "~/queries/page" import { useGetSite } from "~/queries/site" -const DynamicCodeMirrorEditor = dynamic( - () => import("~/components/ui/CodeMirror"), - { - ssr: false, - loading: () => ( -
- Loading... -
- ), - }, -) - -const DynamicPageContent = dynamic( - () => import("~/components/common/PageContent"), - { - ssr: false, - loading: () => ( -
- Loading... -
- ), - }, -) - export default function PostEditor() { const router = useRouter() const queryClient = useQueryClient() @@ -160,8 +122,6 @@ export default function PostEditor() { } }, [page.isSuccess, page.data]) - const uploadFile = useUploadFile() - // reset editor state when page changes useBeforeMounted(() => { useEditorState.setState({ @@ -392,27 +352,7 @@ export default function PostEditor() { ) }, [page.data, subdomain, draftKey, site.data?.characterId]) - const [currentScrollArea, setCurrentScrollArea] = useState("") const [view, setView] = useState() - const [tree, setTree] = useState() - - // preview - - const [parsedContent, setParsedContent] = useState() - - useDebounceEffect( - () => { - const result = renderPageContent(values.content) - setTree(result.tree) - setParsedContent(result) - }, - [values.content], - { - wait: 500, - }, - ) - - const previewRef = useRef(null) // editor const onCreateEditor = useCallback( @@ -429,115 +369,6 @@ export default function PostEditor() { [updateValue], ) - const handleDropFile = useCallback( - async (file: File) => { - if (view) { - editorUpload(file, view) - } - }, - [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" - ) { - if (child.position.start.line > view.state.doc.lines) return - 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 selfPosition - let targetElement - let targetPosition - if (area === "preview") { - selfElement = previewRef.current.parentElement - selfPosition = position.previewElementList - targetElement = view.scrollDOM - targetPosition = position.editorElementList - } else { - selfElement = view.scrollDOM - selfPosition = position.editorElementList - targetElement = previewRef.current.parentElement - targetPosition = position.previewElementList - } - - let scrollElementIndex = 0 - for (let i = 0; i < selfPosition.length; i++) { - if (scrollTop < selfPosition[i]) { - scrollElementIndex = i - 1 - break - } - } - - // scroll to bottom - if (scrollTop >= selfElement.scrollHeight - selfElement.clientHeight) { - targetElement.scrollTop = - targetElement.scrollHeight - targetElement.clientHeight - return - } - - // scroll to position - if (scrollElementIndex >= 0) { - let ratio = - (scrollTop - selfPosition[scrollElementIndex]) / - (selfPosition[scrollElementIndex + 1] - - selfPosition[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], - ) - const onPreviewButtonClick = useCallback(() => { window.open( `/site/${subdomain}/preview/${draftKey.replace( @@ -568,18 +399,6 @@ export default function PostEditor() { } }, [draftKey, site.data?.characterId]) - const cmStyle = useMemo( - () => ({ - ".cm-scroller": { - padding: "0 1.25rem", - }, - ".cm-content": { - paddingBottom: "600px", - }, - }), - [], - ) - return ( <> @@ -686,52 +505,12 @@ export default function PostEditor() { placeholder={t("Title goes here...") || ""} /> -
- {!(isMobileLayout && isRendering) && ( - { - setCurrentScrollArea("editor") - }} - className={cn( - "h-full flex-1", - isRendering ? "border-r" : "", - )} - shortcuts={toolbarShortcuts} - /> - )} - {!isMobileLayout && ( -
-
setIsRendering(!isRendering)} - > - {isRendering ? ( - - ) : ( - - )} -
-
- )} - {isRendering && ( - { - setCurrentScrollArea("preview") - }} - /> - )} +
+
{!isMobileLayout && ( diff --git a/src/components/dashboard/DualColumnEditor.tsx b/src/components/dashboard/DualColumnEditor.tsx new file mode 100644 index 00000000..af39273a --- /dev/null +++ b/src/components/dashboard/DualColumnEditor.tsx @@ -0,0 +1,264 @@ +import { useDebounceEffect } from "ahooks" +import type { Root } from "mdast" +import dynamic from "next/dynamic" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" + +import { EditorView } from "@codemirror/view" + +import { toolbarShortcuts } from "~/editor" +import { editorUpload } from "~/editor/Multimedia" +import { useIsMobileLayout } from "~/hooks/useMobileLayout" +import { useUploadFile } from "~/hooks/useUploadFile" +import { useTranslation } from "~/lib/i18n/client" +import { cn } from "~/lib/utils" +import { Rendered, renderPageContent } from "~/markdown" + +const DynamicCodeMirrorEditor = dynamic( + () => import("~/components/ui/CodeMirror"), + { + ssr: false, + loading: () => ( +
+ Loading... +
+ ), + }, +) + +const DynamicPageContent = dynamic( + () => import("~/components/common/PageContent"), + { + ssr: false, + loading: () => ( +
+ Loading... +
+ ), + }, +) + +export default function DualColumnEditor({ + initialContent, + onChange, + onCreateEditor, +}: { + initialContent: string + onChange: (value: string) => void + onCreateEditor: (view: EditorView) => void +}) { + const isMobileLayout = useIsMobileLayout() + const [isRendering, setIsRendering] = useState(!isMobileLayout) + const { t } = useTranslation("dashboard") + const uploadFile = useUploadFile() + + const [currentScrollArea, setCurrentScrollArea] = useState("") + const [view, setView] = useState() + const [tree, setTree] = useState() + const [values, setValues] = useState("") + + // preview + const [parsedContent, setParsedContent] = useState() + + useEffect(() => { + setValues(initialContent) + }, [initialContent]) + + useDebounceEffect( + () => { + const result = renderPageContent(values) + setTree(result.tree) + setParsedContent(result) + }, + [values], + { + wait: 500, + }, + ) + + const previewRef = useRef(null) + + // editor + const onCreateEditorInside = useCallback( + (view: EditorView) => { + setView?.(view) + onCreateEditor?.(view) + }, + [setView], + ) + + const handleDropFile = useCallback( + async (file: File) => { + if (view) { + editorUpload(file, view) + } + }, + [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" + ) { + if (child.position.start.line > view.state.doc.lines) return + 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 selfPosition + let targetElement + let targetPosition + if (area === "preview") { + selfElement = previewRef.current.parentElement + selfPosition = position.previewElementList + targetElement = view.scrollDOM + targetPosition = position.editorElementList + } else { + selfElement = view.scrollDOM + selfPosition = position.editorElementList + targetElement = previewRef.current.parentElement + targetPosition = position.previewElementList + } + + let scrollElementIndex = 0 + for (let i = 0; i < selfPosition.length; i++) { + if (scrollTop < selfPosition[i]) { + scrollElementIndex = i - 1 + break + } + } + + // scroll to bottom + if (scrollTop >= selfElement.scrollHeight - selfElement.clientHeight) { + targetElement.scrollTop = + targetElement.scrollHeight - targetElement.clientHeight + return + } + + // scroll to position + if (scrollElementIndex >= 0) { + let ratio = + (scrollTop - selfPosition[scrollElementIndex]) / + (selfPosition[scrollElementIndex + 1] - + selfPosition[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], + ) + + const cmStyle = useMemo( + () => ({ + ".cm-scroller": { + padding: "0 1.25rem", + }, + ".cm-content": { + paddingBottom: "600px", + }, + }), + [], + ) + + const onChangeInside = useCallback( + (value: string) => { + setValues(value) + onChange(value) + }, + [setValues, onChange], + ) + + return ( +
+ {!(isMobileLayout && isRendering) && ( + { + setCurrentScrollArea("editor") + }} + className={cn("h-full flex-1", isRendering ? "border-r" : "")} + shortcuts={toolbarShortcuts} + /> + )} + {!isMobileLayout && ( +
+
setIsRendering(!isRendering)} + > + {isRendering ? ( + + ) : ( + + )} +
+
+ )} + {isRendering && ( + { + setCurrentScrollArea("preview") + }} + /> + )} +
+ ) +}