feat: more editor shortcuts (#338)
This commit is contained in:
parent
eaaaa193fd
commit
68cee2aaef
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@
|
|||
"Link": "链接",
|
||||
"Image": "图片",
|
||||
"Upload Image": "上传图片",
|
||||
"Mention": "提及",
|
||||
"Tip: xLog Flavored Markdown": "提示:xLog 风格的 Markdown",
|
||||
"Preview": "预览",
|
||||
"Publish": "发布",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: "**" })
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: "`" })
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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```" })
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
),
|
||||
}
|
||||
|
|
@ -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: "## " })
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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: "_" })
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: "]()" })
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: "}" })
|
||||
},
|
||||
}
|
||||
|
|
@ -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" })
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: "~~" })
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>" })
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in New Issue