feat: more editor shortcuts (#338)

This commit is contained in:
Xuezhou Dai 2023-04-15 04:41:50 +08:00 committed by GitHub
parent eaaaa193fd
commit 68cee2aaef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 192 additions and 23 deletions

View File

@ -87,6 +87,7 @@
"react-i18next": "^12.2.0",
"react-infinite-scroller": "1.2.6",
"react-parallax-tilt": "^1.7.127",
"react-popper": "^2.3.0",
"react-scroll": "1.8.9",
"react-sortablejs": "6.1.4",
"react-string-replace": "^1.1.0",

View File

@ -190,6 +190,9 @@ dependencies:
react-parallax-tilt:
specifier: ^1.7.127
version: 1.7.127(react-dom@18.2.0)(react@18.2.0)
react-popper:
specifier: ^2.3.0
version: 2.3.0(@popperjs/core@2.11.7)(react-dom@18.2.0)(react@18.2.0)
react-scroll:
specifier: 1.8.9
version: 1.8.9(react-dom@18.2.0)(react@18.2.0)
@ -3195,6 +3198,7 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
requiresBuild: true
optional: true
@ -3203,6 +3207,7 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
requiresBuild: true
optional: true
@ -3211,6 +3216,7 @@ packages:
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
requiresBuild: true
optional: true
@ -3219,6 +3225,7 @@ packages:
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
requiresBuild: true
optional: true
@ -3317,6 +3324,7 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
requiresBuild: true
dev: false
optional: true
@ -3326,6 +3334,7 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
requiresBuild: true
dev: false
optional: true
@ -3335,6 +3344,7 @@ packages:
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
requiresBuild: true
dev: false
optional: true
@ -3344,6 +3354,7 @@ packages:
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
requiresBuild: true
dev: false
optional: true
@ -3445,6 +3456,10 @@ packages:
resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==}
dev: true
/@popperjs/core@2.11.7:
resolution: {integrity: sha512-Cr4OjIkipTtcXKjAsm8agyleBuDHvxzeBoa1v543lbv1YaIwQjESsVcmjiWiPEbC1FIeHOG/Op9kdCmAmiS3Kw==}
dev: false
/@prisma/client@4.12.0(prisma@4.12.0):
resolution: {integrity: sha512-j9/ighfWwux97J2dS15nqhl60tYoH8V0IuSsgZDb6bCFcQD3fXbXmxjYC8GHhIgOk3lB7Pq+8CwElz2MiDpsSg==}
engines: {node: '>=14.17'}
@ -11153,6 +11168,10 @@ packages:
react: 18.2.0
dev: false
/react-fast-compare@3.2.1:
resolution: {integrity: sha512-xTYf9zFim2pEif/Fw16dBiXpe0hoy5PxcD8+OwBnTtNLfIm3g6WxhKNurY+6OmdH1u6Ta/W/Vl6vjbYP1MFnDg==}
dev: false
/react-google-recaptcha@2.1.0(react@18.2.0):
resolution: {integrity: sha512-K9jr7e0CWFigi8KxC3WPvNqZZ47df2RrMAta6KmRoE4RUi7Ys6NmNjytpXpg4HI/svmQJLKR+PncEPaNJ98DqQ==}
peerDependencies:
@ -11240,6 +11259,20 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/react-popper@2.3.0(@popperjs/core@2.11.7)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==}
peerDependencies:
'@popperjs/core': ^2.0.0
react: ^16.8.0 || ^17 || ^18
react-dom: ^16.8.0 || ^17 || ^18
dependencies:
'@popperjs/core': 2.11.7
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-fast-compare: 3.2.1
warning: 4.0.3
dev: false
/react-refresh@0.14.0:
resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==}
engines: {node: '>=0.10.0'}
@ -13474,6 +13507,12 @@ packages:
- zod
dev: false
/warning@4.0.3:
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
dependencies:
loose-envify: 1.4.0
dev: false
/watchpack@2.4.0:
resolution: {integrity: sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==}
engines: {node: '>=10.13.0'}

View File

@ -59,6 +59,7 @@
"Link": "链接",
"Image": "图片",
"Upload Image": "上传图片",
"Mention": "提及",
"Tip: xLog Flavored Markdown": "提示xLog 风格的 Markdown",
"Preview": "预览",
"Publish": "发布",

View File

@ -1,23 +1,36 @@
import { useEffect, useRef } from "react"
import { useTranslation } from "react-i18next"
import { useMediaStore } from "~/hooks/useDarkMode"
export const EmojiPicker: React.FC<{
onEmojiSelect: (e: any) => void
}> = ({ onEmojiSelect }) => {
const ref = useRef<any>()
const isDark = useMediaStore((state) => state.isDark)
useEffect(() => {
import("emoji-mart").then(async (EmojiMart) => {
const response = await fetch(
"https://cdn.jsdelivr.net/npm/@emoji-mart/data",
"https://fastly.jsdelivr.net/npm/@emoji-mart/data",
)
const data = await response.json()
new EmojiMart.Picker({
onEmojiSelect,
data,
ref,
theme: isDark ? "dark" : "light",
})
})
}, [])
return <div ref={ref}></div>
const { t } = useTranslation("common")
return (
<div
ref={ref}
className="min-w-[352px] min-h-[170px] bg-white border-border border rounded-[10px] flex justify-center items-center"
>
<span>{t("Loading")}</span>
</div>
)
}

View File

@ -1,9 +1,11 @@
import { EditorView } from "@codemirror/view"
import { cn } from "~/lib/utils"
import { Dispatch, FC, SetStateAction } from "react"
import { FC, useState } from "react"
import { ICommand } from "~/editor"
import { Tooltip } from "./Tooltip"
import { useTranslation } from "next-i18next"
import { Popover } from "@headlessui/react"
import { usePopper } from "react-popper"
export interface EditorToolbarProps {
view?: EditorView
@ -15,22 +17,97 @@ enum ToolbarMode {
Preview,
}
type IToolbarItemProps = ICommand<any> & { view?: EditorView }
const ToolbarItemWithPopover: FC<IToolbarItemProps> = ({
name,
icon,
label,
execute,
ui,
view,
}) => {
const { t } = useTranslation("dashboard")
let [popoverButtonRef, setPopoverButtonRef] =
useState<HTMLButtonElement | null>(null)
let [popoverPanelRef, setPopoverPanelRef] = useState<HTMLDivElement | null>(
null,
)
let { styles: popStyles, attributes: popAttributes } = usePopper(
popoverButtonRef,
popoverPanelRef,
)
return (
<Popover key={name}>
{({ open }: { open: boolean }) => (
<>
<Tooltip key={name} label={t(label)} placement="bottom">
<Popover.Button
key={name}
title={name}
className="w-9 h-9 transition-colors text-lg border border-transparent rounded flex items-center justify-center text-zinc-500 group-hover:text-zinc-600 hover:text-zinc-500 hover:border-zinc-300 hover:bg-zinc-100"
ref={setPopoverButtonRef}
>
<span className={icon}></span>
</Popover.Button>
</Tooltip>
<Popover.Panel
ref={setPopoverPanelRef}
className="z-10"
style={popStyles.popper}
{...popAttributes.popper}
>
{ui &&
view &&
ui({
transferPayload: (payload) => {
view &&
execute({
view,
options: { container: view.dom },
payload,
})
},
view,
})}
</Popover.Panel>
</>
)}
</Popover>
)
}
export const EditorToolbar: FC<EditorToolbarProps> = ({ view, toolbars }) => {
const { t } = useTranslation("dashboard")
const renderToolbar =
(mode: ToolbarMode) =>
// eslint-disable-next-line react/display-name
({ name, icon, label, execute }: ICommand) => {
return (
({ name, icon, label, execute, ui }: ICommand) => {
return ui ? (
<ToolbarItemWithPopover
key={name}
name={name}
icon={icon}
label={label}
execute={execute}
ui={ui}
view={view}
/>
) : (
<Tooltip key={name} label={t(label)} placement="bottom">
<button
key={name}
title={name}
type="button"
className={
"w-9 h-9 transition-colors text-lg border border-transparent rounded flex items-center justify-center text-zinc-500 group-hover:text-zinc-600 hover:text-zinc-500 hover:border-zinc-300 hover:bg-zinc-100"
}
onClick={() => {
view && execute(view, { container: view.dom })
view &&
execute({
view,
options: { container: view.dom },
})
}}
>
<span className={icon}></span>

View File

@ -5,7 +5,7 @@ export const Bold: ICommand = {
name: "bold",
label: "Bold",
icon: "i-mingcute:bold-line",
execute: (view) => {
execute: ({ view }) => {
wrapExecute({ view, prepend: "**", append: "**" })
},
}

View File

@ -5,7 +5,7 @@ export const Code: ICommand = {
name: "code",
label: "Inline Code",
icon: "i-mingcute:code-line",
execute: (view) => {
execute: ({ view }) => {
wrapExecute({ view, prepend: "`", append: "`" })
},
}

View File

@ -4,7 +4,7 @@ export const CodeBlock: ICommand = {
name: "codeblock",
label: "Code Block",
icon: "i-mingcute:web-line",
execute: (view) => {
execute: ({ view }) => {
wrapExecute({ view, prepend: "```\n", append: "\n```" })
},
}

14
src/editor/Emoji.tsx Normal file
View File

@ -0,0 +1,14 @@
import { EmojiPicker } from "~/components/common/EmojiPicker"
import { ICommand, prependExecute, wrapExecute } from "."
export const Emoji: ICommand<string> = {
name: "emoji",
label: "Emoji",
icon: "i-mingcute:emoji-2-line",
execute: ({ view, payload }) => {
wrapExecute({ view, prepend: payload || "", append: "" })
},
ui: ({ transferPayload }) => (
<EmojiPicker onEmojiSelect={(e: any) => transferPayload(e.native)} />
),
}

View File

@ -4,7 +4,7 @@ export const Heading: ICommand = {
name: "heading",
label: "Heading",
icon: "i-mingcute:heading-2-line",
execute: (view) => {
execute: ({ view }) => {
prependExecute({ view, prepend: "## " })
},
}

View File

@ -7,7 +7,7 @@ export const Image: ICommand = {
name: "upload-image",
label: "Upload Image",
icon: "i-mingcute:photo-album-line",
execute: (view) => {
execute: ({ view }) => {
const input = document.createElement("input")
input.type = "file"
input.addEventListener("change", async (e: any) => {
@ -25,6 +25,8 @@ export const Image: ICommand = {
if (error instanceof Error) {
toast.error(error.message, { id: toastId })
}
} finally {
input.remove()
}
})
input.click()

View File

@ -4,7 +4,7 @@ export const Italic: ICommand = {
name: "italic",
label: "Italic",
icon: "i-mingcute:italic-line",
execute: (view) => {
execute: ({ view }) => {
wrapExecute({ view, prepend: "_", append: "_" })
},
}

View File

@ -5,7 +5,7 @@ export const Link: ICommand = {
name: "link",
label: "Link",
icon: "i-mingcute:link-2-line",
execute: (view) => {
execute: ({ view }) => {
wrapExecute({ view, prepend: "[", append: "]()" })
},
}

View File

@ -5,7 +5,7 @@ export const ListOrdered: ICommand = {
name: "list-ordered",
label: "Ordered List",
icon: "i-mingcute:list-ordered-line",
execute: (view) => {
execute: ({ view }) => {
wrapExecute({ view, prepend: "1. ", append: "\n" })
},
}

View File

@ -4,7 +4,7 @@ export const ListUnordered: ICommand = {
name: "list-unordered",
label: "Unordered List",
icon: "i-mingcute:list-check-line",
execute: (view) => {
execute: ({ view }) => {
wrapExecute({ view, prepend: "- ", append: "\n" })
},
}

10
src/editor/Mention.tsx Normal file
View File

@ -0,0 +1,10 @@
import { ICommand, wrapExecute } from "."
export const Mention: ICommand = {
name: "mention",
label: "Mention",
icon: "i-mingcute:at-line",
execute: ({ view }) => {
wrapExecute({ view, prepend: "::mention{#", append: "}" })
},
}

View File

@ -4,7 +4,7 @@ export const Quote: ICommand = {
name: "quote",
label: "Quote",
icon: "i-mingcute:quote-left-fill",
execute: (view) => {
execute: ({ view }) => {
wrapExecute({ view, prepend: "> ", append: "\n" })
},
}

View File

@ -4,7 +4,7 @@ export const Strikethrough: ICommand = {
name: "strikethrough",
label: "Strikethrough",
icon: "i-mingcute:strikethrough-line",
execute: (view) => {
execute: ({ view }) => {
wrapExecute({ view, prepend: "~~", append: "~~" })
},
}

View File

@ -4,7 +4,7 @@ export const Underline: ICommand = {
name: "underline",
label: "Underline",
icon: "i-mingcute:underline-line",
execute: (view) => {
execute: ({ view }) => {
wrapExecute({ view, prepend: "<u>", append: "</u>" })
},
}

View File

@ -13,18 +13,28 @@ import { ListUnordered } from "./ListUnordered"
import { Quote } from "./Quote"
import { Strikethrough } from "./Strikethrough"
import { Underline } from "./Underline"
import { Mention } from "./Mention"
import { Emoji } from "./Emoji"
export type ICommand = {
export type ICommand<P = any> = {
icon: string
name: string
label: string
execute: (
view: EditorView,
execute: (_: {
view: EditorView
options?: {
setPreviewVisible?: Dispatch<SetStateAction<boolean>>
container?: HTMLElement | null
},
) => void
}
payload?: P
}) => void
// While the `ui` field exists, the ui component will be rendered in a popover first,
// then you can call `transferPayload` to transfer the payload to the `execute` function.
// You can find a sample usage in `./Emoji.tsx`.
ui?: React.FC<{
transferPayload: (payload: P) => void
view: EditorView
}>
}
export type IPrependExecute = {
@ -127,4 +137,6 @@ export const toolbars: ICommand[] = [
ListOrdered,
Link,
Image,
Mention,
Emoji,
]