refactor: reduce rerendering in editor edit (#389)
This commit is contained in:
parent
e253860842
commit
5c615c4372
|
|
@ -0,0 +1,11 @@
|
|||
import { useRef } from "react"
|
||||
|
||||
/**
|
||||
* @description `useSyncOnce` is a hook that will run a function once and only once, before the useEffect called, it means before `onMounted`.
|
||||
*/
|
||||
export const useSyncOnce = (fn: () => any) => {
|
||||
const onceRef = useRef(false)
|
||||
if (onceRef.current) return
|
||||
fn()
|
||||
onceRef.current = true
|
||||
}
|
||||
|
|
@ -14,3 +14,12 @@ export function stripHTML(html: string) {
|
|||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
|
||||
return keys.reduce((acc, key) => {
|
||||
if (obj && Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
acc[key] = obj[key]
|
||||
}
|
||||
return acc
|
||||
}, {} as Pick<T, K>)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,14 +7,17 @@ import { useRouter } from "next/router"
|
|||
import NodeID3 from "node-id3"
|
||||
import {
|
||||
ChangeEvent,
|
||||
FC,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import type { ReactElement } from "react"
|
||||
import toast from "react-hot-toast"
|
||||
import { create } from "zustand"
|
||||
import { shallow } from "zustand/shallow"
|
||||
|
||||
import type { EditorView } from "@codemirror/view"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
|
|
@ -33,7 +36,9 @@ import { Modal } from "~/components/ui/Modal"
|
|||
import { UniLink } from "~/components/ui/UniLink"
|
||||
import { toolbars } from "~/editor"
|
||||
import { useDate } from "~/hooks/useDate"
|
||||
import { useGetState } from "~/hooks/useGetState"
|
||||
import { useIsMobileLayout } from "~/hooks/useMobileLayout"
|
||||
import { useSyncOnce } from "~/hooks/useSyncOnce"
|
||||
import { useUploadFile } from "~/hooks/useUploadFile"
|
||||
import { showConfetti } from "~/lib/confetti"
|
||||
import { getDefaultSlug } from "~/lib/default-slug"
|
||||
|
|
@ -43,7 +48,7 @@ import { getPageVisibility } from "~/lib/page-helpers"
|
|||
import { serverSidePropsHandler } from "~/lib/server-side-props"
|
||||
import { delStorage, setStorage } from "~/lib/storage"
|
||||
import { PageVisibilityEnum } from "~/lib/types"
|
||||
import { cn } from "~/lib/utils"
|
||||
import { cn, pick } from "~/lib/utils"
|
||||
import { Rendered, renderPageContent } from "~/markdown"
|
||||
import { checkPageSlug } from "~/models/page.model"
|
||||
import { useCreateOrUpdatePage, useGetPage } from "~/queries/page"
|
||||
|
|
@ -66,10 +71,30 @@ const getInputDatetimeValue = (date: Date | string, dayjs: any) => {
|
|||
return str.substring(0, ((str.indexOf("T") | 0) + 6) | 0)
|
||||
}
|
||||
|
||||
const initialEditorState = {
|
||||
title: "",
|
||||
publishedAt: new Date().toISOString(),
|
||||
published: false,
|
||||
excerpt: "",
|
||||
slug: "",
|
||||
tags: "",
|
||||
content: "",
|
||||
}
|
||||
|
||||
const useEditorState = create<
|
||||
typeof initialEditorState & {
|
||||
setValues: (values: Partial<typeof initialEditorState>) => void
|
||||
}
|
||||
>((set) => ({
|
||||
...initialEditorState,
|
||||
setValues: (values: any) => set(values),
|
||||
}))
|
||||
|
||||
type Values = typeof initialEditorState
|
||||
|
||||
export default function SubdomainEditor() {
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const date = useDate()
|
||||
const { t } = useTranslation("dashboard")
|
||||
|
||||
let pageId = router.query.id as string | undefined
|
||||
|
|
@ -119,25 +144,27 @@ export default function SubdomainEditor() {
|
|||
|
||||
const uploadFile = useUploadFile()
|
||||
|
||||
const [values, setValues] = useState({
|
||||
title: "",
|
||||
publishedAt: new Date().toISOString(),
|
||||
published: false,
|
||||
excerpt: "",
|
||||
slug: "",
|
||||
tags: "",
|
||||
content: "",
|
||||
// reset editor state when page changes
|
||||
useSyncOnce(() => {
|
||||
useEditorState.setState(initialEditorState)
|
||||
})
|
||||
|
||||
const values = useEditorState()
|
||||
|
||||
const [initialContent, setInitialContent] = useState("")
|
||||
const [defaultSlug, setDefaultSlug] = useState("")
|
||||
|
||||
type Values = typeof values
|
||||
const getValues = useGetState(values)
|
||||
const getDraftKey = useGetState(draftKey)
|
||||
|
||||
const updateValue = useCallback(
|
||||
<K extends keyof Values>(key: K, value: Values[K]) => {
|
||||
if (visibility === PageVisibilityEnum.Published) {
|
||||
setVisibility(PageVisibilityEnum.Modified)
|
||||
}
|
||||
|
||||
const values = getValues()
|
||||
const draftKey = getDraftKey()
|
||||
if (key === "title") {
|
||||
setDefaultSlug(
|
||||
getDefaultSlug(
|
||||
|
|
@ -163,9 +190,9 @@ export default function SubdomainEditor() {
|
|||
})
|
||||
queryClient.invalidateQueries(["getPagesBySite", subdomain])
|
||||
}
|
||||
setValues(newValues)
|
||||
useEditorState.setState(newValues)
|
||||
},
|
||||
[setValues, values, draftKey, isPost, queryClient, subdomain, t],
|
||||
[isPost, queryClient, subdomain],
|
||||
)
|
||||
|
||||
const createOrUpdatePage = useCreateOrUpdatePage()
|
||||
|
|
@ -202,113 +229,6 @@ export default function SubdomainEditor() {
|
|||
|
||||
const [isCheersOpen, setIsCheersOpen] = useState(false)
|
||||
|
||||
const extraProperties = useMemo(
|
||||
() => (
|
||||
<div className="h-full overflow-auto flex-shrink-0 w-[280px] border-l bg-zinc-50 p-5 space-y-5">
|
||||
<div>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
label={t("Publish at") || ""}
|
||||
isBlock
|
||||
name="publishAt"
|
||||
id="publishAt"
|
||||
value={getInputDatetimeValue(values.publishedAt, date.dayjs)}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
try {
|
||||
const value = date.inLocalTimezone(e.target.value).toISOString()
|
||||
updateValue("publishedAt", value)
|
||||
} catch (error) {}
|
||||
}}
|
||||
help={t(
|
||||
`This ${
|
||||
isPost ? "post" : "page"
|
||||
} will be accessible from this time`,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
name="slug"
|
||||
value={values.slug}
|
||||
placeholder={defaultSlug}
|
||||
label={t(`${isPost ? "Post" : "Page"} slug`) || ""}
|
||||
id="slug"
|
||||
isBlock
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
updateValue("slug", e.target.value)
|
||||
}
|
||||
help={
|
||||
<>
|
||||
{(values.slug || defaultSlug) && (
|
||||
<>
|
||||
{t(
|
||||
`This ${isPost ? "post" : "page"} will be accessible at`,
|
||||
)}{" "}
|
||||
<UniLink
|
||||
href={`${getSiteLink({
|
||||
subdomain,
|
||||
domain: site.data?.custom_domain,
|
||||
})}/${encodeURIComponent(values.slug || defaultSlug)}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{getSiteLink({
|
||||
subdomain,
|
||||
domain: site.data?.custom_domain,
|
||||
noProtocol: true,
|
||||
})}
|
||||
/{encodeURIComponent(values.slug || defaultSlug)}
|
||||
</UniLink>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
name="tags"
|
||||
value={values.tags}
|
||||
label={t("Tags") || ""}
|
||||
id="tags"
|
||||
isBlock
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
updateValue("tags", e.target.value)
|
||||
}
|
||||
help={t("Separate multiple tags with English commas") + ` ","`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
label={t("Excerpt") || ""}
|
||||
isBlock
|
||||
name="excerpt"
|
||||
id="excerpt"
|
||||
value={values.excerpt}
|
||||
multiline
|
||||
rows={5}
|
||||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
updateValue("excerpt", e.target.value)
|
||||
}}
|
||||
help={t("Leave it blank to use auto-generated excerpt")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
[
|
||||
date,
|
||||
defaultSlug,
|
||||
isPost,
|
||||
site.data?.custom_domain,
|
||||
subdomain,
|
||||
t,
|
||||
updateValue,
|
||||
values.excerpt,
|
||||
values.publishedAt,
|
||||
values.slug,
|
||||
values.tags,
|
||||
],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (createOrUpdatePage.isSuccess) {
|
||||
if (createOrUpdatePage.data?.code === 0) {
|
||||
|
|
@ -340,7 +260,7 @@ export default function SubdomainEditor() {
|
|||
useEffect(() => {
|
||||
if (!page.data || !draftKey) return
|
||||
setInitialContent(page.data.body?.content || "")
|
||||
setValues({
|
||||
useEditorState.setState({
|
||||
title: page.data.title || "",
|
||||
publishedAt: page.data.date_published,
|
||||
published: !!page.data.id,
|
||||
|
|
@ -567,6 +487,14 @@ export default function SubdomainEditor() {
|
|||
)}`,
|
||||
)
|
||||
}, [draftKey, subdomain])
|
||||
const extraProperties = (
|
||||
<EditorExtraProperties
|
||||
defaultSlug={defaultSlug}
|
||||
updateValue={updateValue}
|
||||
isPost={isPost}
|
||||
subdomain={subdomain}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -591,40 +519,6 @@ export default function SubdomainEditor() {
|
|||
</div>
|
||||
{isMobileLayout ? (
|
||||
<div className="flex items-center space-x-3 w-auto pl-5">
|
||||
{/* <span
|
||||
className={cn(
|
||||
`text-sm capitalize`,
|
||||
visibility === PageVisibilityEnum.Draft
|
||||
? `text-zinc-300`
|
||||
: visibility === PageVisibilityEnum.Modified
|
||||
? "text-orange-600"
|
||||
: "text-green-600",
|
||||
)}
|
||||
>
|
||||
{t(visibility as string)}
|
||||
</span> */}
|
||||
{/* <Button
|
||||
isAutoWidth
|
||||
onClick={() => {
|
||||
window.open(
|
||||
`/_site/${subdomain}/preview/${draftKey.replace(
|
||||
`draft-${subdomain}-`,
|
||||
"",
|
||||
)}`,
|
||||
)
|
||||
}}
|
||||
>
|
||||
{t("Preview")}
|
||||
</Button> */}
|
||||
{/* <PublishButton
|
||||
save={savePage}
|
||||
published={visibility !== PageVisibilityEnum.Draft}
|
||||
isSaving={createOrUpdatePage.isLoading}
|
||||
isDisabled={
|
||||
visibility !== PageVisibilityEnum.Modified &&
|
||||
visibility !== PageVisibilityEnum.Draft
|
||||
}
|
||||
/> */}
|
||||
<OptionsButton
|
||||
visibility={visibility}
|
||||
savePage={savePage}
|
||||
|
|
@ -754,7 +648,14 @@ export default function SubdomainEditor() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isMobileLayout && extraProperties}
|
||||
{!isMobileLayout && (
|
||||
<EditorExtraProperties
|
||||
defaultSlug={defaultSlug}
|
||||
updateValue={updateValue}
|
||||
isPost={isPost}
|
||||
subdomain={subdomain}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -825,3 +726,111 @@ export default function SubdomainEditor() {
|
|||
SubdomainEditor.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout title="Editor">{page}</DashboardLayout>
|
||||
}
|
||||
|
||||
const EditorExtraProperties: FC<{
|
||||
updateValue: <K extends keyof Values>(key: K, value: Values[K]) => void
|
||||
isPost: boolean
|
||||
|
||||
subdomain: string
|
||||
defaultSlug: string
|
||||
}> = memo(({ isPost, updateValue, subdomain, defaultSlug }) => {
|
||||
const values = useEditorState(
|
||||
(state) => pick(state, ["publishedAt", "slug", "excerpt", "tags"]),
|
||||
shallow,
|
||||
)
|
||||
const date = useDate()
|
||||
const { t } = useTranslation("dashboard")
|
||||
const site = useGetSite(subdomain)
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto flex-shrink-0 w-[280px] border-l bg-zinc-50 p-5 space-y-5">
|
||||
<div>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
label={t("Publish at") || ""}
|
||||
isBlock
|
||||
name="publishAt"
|
||||
id="publishAt"
|
||||
value={getInputDatetimeValue(values.publishedAt, date.dayjs)}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
try {
|
||||
const value = date.inLocalTimezone(e.target.value).toISOString()
|
||||
updateValue("publishedAt", value)
|
||||
} catch (error) {}
|
||||
}}
|
||||
help={t(
|
||||
`This ${
|
||||
isPost ? "post" : "page"
|
||||
} will be accessible from this time`,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
name="slug"
|
||||
value={values.slug}
|
||||
placeholder={defaultSlug}
|
||||
label={t(`${isPost ? "Post" : "Page"} slug`) || ""}
|
||||
id="slug"
|
||||
isBlock
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
updateValue("slug", e.target.value)
|
||||
}
|
||||
help={
|
||||
<>
|
||||
{(values.slug || defaultSlug) && (
|
||||
<>
|
||||
{t(`This ${isPost ? "post" : "page"} will be accessible at`)}{" "}
|
||||
<UniLink
|
||||
href={`${getSiteLink({
|
||||
subdomain,
|
||||
domain: site.data?.custom_domain,
|
||||
})}/${encodeURIComponent(values.slug || defaultSlug)}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{getSiteLink({
|
||||
subdomain,
|
||||
domain: site.data?.custom_domain,
|
||||
noProtocol: true,
|
||||
})}
|
||||
/{encodeURIComponent(values.slug || defaultSlug)}
|
||||
</UniLink>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
name="tags"
|
||||
value={values.tags}
|
||||
label={t("Tags") || ""}
|
||||
id="tags"
|
||||
isBlock
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
updateValue("tags", e.target.value)
|
||||
}
|
||||
help={t("Separate multiple tags with English commas") + ` ","`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
label={t("Excerpt") || ""}
|
||||
isBlock
|
||||
name="excerpt"
|
||||
id="excerpt"
|
||||
value={values.excerpt}
|
||||
multiline
|
||||
rows={5}
|
||||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
updateValue("excerpt", e.target.value)
|
||||
}}
|
||||
help={t("Leave it blank to use auto-generated excerpt")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
EditorExtraProperties.displayName = "EditorExtraProperties"
|
||||
|
|
|
|||
Loading…
Reference in New Issue