feat: add EditorToolbar (#73)

* feat: add EditorToolbar

* feat: add github icon

* feat: add wrapExecute, prependExecute

* feat: add preview mode

* feat: move the position of Toolbar

* feat: remove max width

* feat: add preview button active style

* refactor: preview visible
This commit is contained in:
Edward Elric 2022-05-31 23:34:58 +08:00 committed by GitHub
parent cfc41fe2fd
commit a78eae899a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 426 additions and 15 deletions

View File

@ -0,0 +1,21 @@
import { EditorSelection } from "@codemirror/state"
import { ICommand, wrapExecute } from "."
export const Bold: ICommand = {
name: "bold",
icon: (
<svg
height="16"
width="16"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M4 2a1 1 0 00-1 1v10a1 1 0 001 1h5.5a3.5 3.5 0 001.852-6.47A3.5 3.5 0 008.5 2H4zm4.5 5a1.5 1.5 0 100-3H5v3h3.5zM5 9v3h4.5a1.5 1.5 0 000-3H5z"
></path>
</svg>
),
execute: (view) => {
wrapExecute({ view, prepend: "**", append: "**" })
},
}

View File

@ -0,0 +1,14 @@
import { EditorSelection } from "@codemirror/state"
import { ICommand, wrapExecute } from "."
export const Code: ICommand = {
name: "code",
icon: (
<svg height="16" viewBox="0 0 16 16" width="16">
<path d="M4.72 3.22a.75.75 0 011.06 1.06L2.06 8l3.72 3.72a.75.75 0 11-1.06 1.06L.47 8.53a.75.75 0 010-1.06l4.25-4.25zm6.56 0a.75.75 0 10-1.06 1.06L13.94 8l-3.72 3.72a.75.75 0 101.06 1.06l4.25-4.25a.75.75 0 000-1.06l-4.25-4.25z"></path>
</svg>
),
execute: (view) => {
wrapExecute({ view, prepend: "`", append: "`" })
},
}

View File

@ -0,0 +1,13 @@
import { ICommand, prependExecute } from "."
export const Heading: ICommand = {
name: "heading",
icon: (
<svg height="16" width="16" viewBox="0 0 16 16">
<path d="M3.75 2a.75.75 0 01.75.75V7h7V2.75a.75.75 0 011.5 0v10.5a.75.75 0 01-1.5 0V8.5h-7v4.75a.75.75 0 01-1.5 0V2.75A.75.75 0 013.75 2z"></path>
</svg>
),
execute: (view) => {
prependExecute({ view, prepend: "### " })
},
}

View File

@ -0,0 +1,16 @@
import { EditorSelection } from "@codemirror/state"
import { ICommand, prependExecute, wrapExecute } from "."
export const Image: ICommand = {
name: "image",
icon: (
<svg viewBox="0 0 1024 1024" width="16" height="16">
<path d="M204.8 204.8a51.2 51.2 0 0 0-51.2 51.2v563.2a51.2 51.2 0 0 0 51.2 51.2h614.4a51.2 51.2 0 0 0 51.2-51.2V256a51.2 51.2 0 0 0-51.2-51.2H204.8z m0-51.2h614.4a102.4 102.4 0 0 1 102.4 102.4v563.2a102.4 102.4 0 0 1-102.4 102.4H204.8a102.4 102.4 0 0 1-102.4-102.4V256a102.4 102.4 0 0 1 102.4-102.4z"></path>
<path d="M142.6432 738.816l-36.352-36.0448 205.1584-206.8992 144.3328 146.2784-36.4544 35.9424-107.9808-109.4144zM358.4 435.2a76.8 76.8 0 1 1 0-153.6 76.8 76.8 0 0 1 0 153.6z m0-51.2a25.6 25.6 0 1 0 0-51.2 25.6 25.6 0 0 0 0 51.2z"></path>
<path d="M673.3312 466.7392l-433.664 453.9392-37.0688-35.3792 469.504-491.4176 236.544 231.5776-35.7888 36.608z"></path>
</svg>
),
execute: (view) => {
wrapExecute({ view, prepend: "![", append: "]()" })
},
}

View File

@ -0,0 +1,13 @@
import { ICommand, wrapExecute } from "."
export const Italic: ICommand = {
name: "italic",
icon: (
<svg height="16" viewBox="0 0 16 16" width="16">
<path d="M6 2.75A.75.75 0 016.75 2h6.5a.75.75 0 010 1.5h-2.505l-3.858 9H9.25a.75.75 0 010 1.5h-6.5a.75.75 0 010-1.5h2.505l3.858-9H6.75A.75.75 0 016 2.75z"></path>
</svg>
),
execute: (view) => {
wrapExecute({ view, prepend: "_", append: "_" })
},
}

View File

@ -0,0 +1,14 @@
import { EditorSelection } from "@codemirror/state"
import { ICommand, prependExecute, wrapExecute } from "."
export const Link: ICommand = {
name: "link",
icon: (
<svg height="16" viewBox="0 0 16 16" width="16">
<path d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z"></path>
</svg>
),
execute: (view) => {
wrapExecute({ view, prepend: "[", append: "]()" })
},
}

View File

@ -0,0 +1,14 @@
import { EditorSelection } from "@codemirror/state"
import { ICommand, wrapExecute } from "."
export const ListOrdered: ICommand = {
name: "list-ordered",
icon: (
<svg height="16" viewBox="0 0 16 16" width="16">
<path d="M2.003 2.5a.5.5 0 00-.723-.447l-1.003.5a.5.5 0 00.446.895l.28-.14V6H.5a.5.5 0 000 1h2.006a.5.5 0 100-1h-.503V2.5zM5 3.25a.75.75 0 01.75-.75h8.5a.75.75 0 010 1.5h-8.5A.75.75 0 015 3.25zm0 5a.75.75 0 01.75-.75h8.5a.75.75 0 010 1.5h-8.5A.75.75 0 015 8.25zm0 5a.75.75 0 01.75-.75h8.5a.75.75 0 010 1.5h-8.5a.75.75 0 01-.75-.75zM.924 10.32l.003-.004a.851.851 0 01.144-.153A.66.66 0 011.5 10c.195 0 .306.068.374.146a.57.57 0 01.128.376c0 .453-.269.682-.8 1.078l-.035.025C.692 11.98 0 12.495 0 13.5a.5.5 0 00.5.5h2.003a.5.5 0 000-1H1.146c.132-.197.351-.372.654-.597l.047-.035c.47-.35 1.156-.858 1.156-1.845 0-.365-.118-.744-.377-1.038-.268-.303-.658-.484-1.126-.484-.48 0-.84.202-1.068.392a1.858 1.858 0 00-.348.384l-.007.011-.002.004-.001.002-.001.001a.5.5 0 00.851.525zM.5 10.055l-.427-.26.427.26z"></path>
</svg>
),
execute: (view) => {
wrapExecute({ view, prepend: "1. ", append: "\n" })
},
}

View File

@ -0,0 +1,13 @@
import { ICommand, wrapExecute } from "."
export const ListUnordered: ICommand = {
name: "list-unordered",
icon: (
<svg height="16" viewBox="0 0 16 16" width="16">
<path d="M2 4a1 1 0 100-2 1 1 0 000 2zm3.75-1.5a.75.75 0 000 1.5h8.5a.75.75 0 000-1.5h-8.5zm0 5a.75.75 0 000 1.5h8.5a.75.75 0 000-1.5h-8.5zm0 5a.75.75 0 000 1.5h8.5a.75.75 0 000-1.5h-8.5zM3 8a1 1 0 11-2 0 1 1 0 012 0zm-1 6a1 1 0 100-2 1 1 0 000 2z"></path>
</svg>
),
execute: (view) => {
wrapExecute({ view, prepend: "- ", append: "\n" })
},
}

View File

@ -0,0 +1,13 @@
import { ICommand } from "."
export const Preview: ICommand = {
name: "preview",
icon: (
<svg width="16" height="16" viewBox="0 0 32 32">
<path d="M0 16c3.037-5.864 9.058-9.802 16-9.802s12.963 3.938 15.953 9.703l0.047 0.1c-3.037 5.864-9.058 9.802-16 9.802s-12.963-3.938-15.953-9.703l-0.047-0.1zM16 22.531c3.607 0 6.531-2.924 6.531-6.531s-2.924-6.531-6.531-6.531v0c-3.607 0-6.531 2.924-6.531 6.531s2.924 6.531 6.531 6.531v0zM16 19.265c-1.804 0-3.265-1.461-3.265-3.265s1.461-3.265 3.265-3.265v0c1.804 0 3.265 1.461 3.265 3.265s-1.461 3.265-3.265 3.265v0z"></path>
</svg>
),
execute: (_, options) => {
options?.setPreviewVisible?.((visible) => !visible)
},
}

View File

@ -0,0 +1,13 @@
import { ICommand, wrapExecute } from "."
export const Quote: ICommand = {
name: "quote",
icon: (
<svg height="16" viewBox="0 0 16 16" width="16">
<path d="M1.75 2.5a.75.75 0 000 1.5h10.5a.75.75 0 000-1.5H1.75zm4 5a.75.75 0 000 1.5h8.5a.75.75 0 000-1.5h-8.5zm0 5a.75.75 0 000 1.5h8.5a.75.75 0 000-1.5h-8.5zM2.5 7.75a.75.75 0 00-1.5 0v6a.75.75 0 001.5 0v-6z"></path>
</svg>
),
execute: (view) => {
wrapExecute({ view, prepend: "> ", append: "\n" })
},
}

View File

@ -0,0 +1,19 @@
import { ICommand, wrapExecute } from "."
export const Strikethrough: ICommand = {
name: "strikethrough",
icon: (
<svg
viewBox="0 0 1024 1024"
width="16"
height="16"
>
<path
d="M665.088 512H921.6v51.2h-199.6288c17.0496 24.832 25.6512 53.9136 25.6512 87.3472 0 56.2176-23.9616 101.376-70.9632 134.5536-45.1584 31.3344-105.0624 47.0016-179.712 47.0016-75.5712 0-135.4752-17.5104-178.7904-51.6096-49.7664-39.6288-77.4144-99.5328-82.944-178.7904h55.296c6.4512 61.7472 27.648 107.8272 64.512 138.24 33.1776 26.7264 80.1792 40.5504 141.0048 40.5504 58.0608 0 105.0624-11.9808 141.0048-34.0992 35.0208-23.04 53.4528-54.3744 53.4528-93.0816 0-36.7616-15.2576-66.7648-44.8-90.112H102.4v-51.2h426.3936c-15.1552-4.608-31.8976-9.472-50.2784-14.4384-78.336-22.1184-129.024-39.6288-151.1424-52.5312-50.688-28.5696-75.5712-70.0416-75.5712-123.4944 0-57.1392 23.04-100.4544 70.9632-130.8672 42.3936-28.5696 97.6896-42.3936 164.0448-42.3936 71.8848 0 129.024 17.5104 171.4176 52.5312 43.3152 35.0208 68.1984 86.6304 73.728 154.8288h-56.2176c-7.3728-51.6096-26.7264-90.3168-58.0608-117.0432-31.3344-26.7264-75.5712-39.6288-132.7104-39.6288-53.4528 0-94.9248 9.216-125.3376 27.648-35.0208 20.2752-51.6096 51.6096-51.6096 93.0816 0 36.864 20.2752 65.4336 61.7472 87.552 19.3536 9.216 61.7472 23.9616 126.2592 42.3936 83.4048 23.808 139.4688 44.032 169.0624 62.3616z"
></path>
</svg>
),
execute: (view) => {
wrapExecute({ view, prepend: "~~", append: "~~" })
},
}

View File

@ -0,0 +1,13 @@
import { ICommand, wrapExecute } from "."
export const Underline: ICommand = {
name: "underline",
icon: (
<svg viewBox="0 0 1024 1024" width="16" height="16">
<path d="M204.8 819.2h614.4v51.2H204.8zM268.3392 146.5344h53.0944v381.2352c0 66.1504 13.9264 116.6336 43.52 150.5792 31.3344 35.6864 79.2064 53.9648 145.3568 53.9648s114.0224-18.2784 145.3568-53.9648c28.7232-33.9456 43.52-84.4288 43.52-150.5792V146.5344h53.0944v381.2352c0 80.9472-20.0192 142.7456-58.3168 185.3952-40.9088 44.3904-101.8368 67.0208-183.6544 67.0208s-142.7456-22.6304-182.784-66.1504c-40.0384-43.52-59.1872-105.3184-59.1872-186.2656V146.5344z"></path>
</svg>
),
execute: (view) => {
wrapExecute({ view, prepend: "<u>", append: "</u>" })
},
}

View File

@ -0,0 +1,128 @@
import { EditorSelection } from "@codemirror/state"
import { EditorView } from "@codemirror/view"
import { Dispatch, SetStateAction } from "react"
import { Bold } from "./Bold"
import { Code } from "./Code"
import { Heading } from "./Heading"
import { Image } from "./Image"
import { Italic } from "./Italic"
import { Link } from "./Link"
import { ListOrdered } from "./ListOrdered"
import { ListUnordered } from "./ListUnordered"
import { Preview } from "./Preview"
import { Quote } from "./Quote"
import { Strikethrough } from "./Strikethrough"
export type ICommand = {
icon: React.ReactElement
name: string
execute: (
view: EditorView,
options?: {
setPreviewVisible?: Dispatch<SetStateAction<boolean>>
container?: HTMLElement | null
},
) => void
}
export type IPrependExecute = {
view: EditorView
prepend: string
}
export type IWrapExecute = {
view: EditorView
prepend: string
append: string
}
export const prependExecute = ({ view, prepend }: IPrependExecute) => {
const range = view.state.selection.ranges[0]
const selection = view.state.sliceDoc(range.from - prepend.length, range.to)
if (selection.startsWith(prepend)) {
view.dispatch(
view.state.changeByRange((range) => ({
changes: [
{
from: range.from - prepend.length,
to: range.to,
insert: view.state.sliceDoc(range.from, range.to),
},
],
range: EditorSelection.range(
range.from - prepend.length,
range.to - prepend.length,
),
})),
)
view.focus()
return
}
view.dispatch(
view.state.changeByRange((range) => {
return {
changes: [{ from: range.from, insert: prepend }],
range: EditorSelection.range(
range.from + prepend.length,
range.to + prepend.length,
),
}
}),
)
view.focus()
}
export const wrapExecute = ({ view, prepend, append }: IWrapExecute) => {
const range = view.state.selection.ranges[0]
const selection = view.state.sliceDoc(
range.from - prepend.length,
range.to + append.length,
)
if (selection.startsWith(prepend) && selection.endsWith(append)) {
view.dispatch(
view.state.changeByRange((range) => ({
changes: [
{
from: range.from - prepend.length,
to: range.to + append.length,
insert: view.state.sliceDoc(range.from, range.to),
},
],
range: EditorSelection.range(
range.from - prepend.length,
range.to - prepend.length,
),
})),
)
view.focus()
return
}
view.dispatch(
view.state.changeByRange((range) => ({
changes: [
{ from: range.from, insert: prepend },
{ from: range.to, insert: append },
],
range: EditorSelection.range(
range.from + prepend.length,
range.to + prepend.length,
),
})),
)
view.focus()
}
export const toolbars: ICommand[] = [
Heading,
Bold,
Italic,
Strikethrough,
Quote,
Code,
ListUnordered,
ListOrdered,
Link,
Image,
]
export const modeToolbars: ICommand[] = [Preview]

View File

@ -0,0 +1,34 @@
import clsx from "clsx"
import { FC, useCallback, useEffect, useState } from "react"
import { renderPageContent } from "~/markdown"
export interface EditorPreviewProps {
className?: string
content: string
previewVisible: boolean
}
export const EditorPreview: FC<EditorPreviewProps> = ({
className,
content,
previewVisible,
}) => {
const [html, setHtml] = useState("")
const renderMarkdown = useCallback(async (doc: string) => {
const pageContent = await renderPageContent(doc)
setHtml(pageContent.contentHTML)
}, [])
useEffect(() => {
renderMarkdown(content)
}, [content, renderMarkdown])
return (
<div
className={clsx(
"prose border-l border-gray-100 overflow-auto",
className,
{ hidden: !previewVisible },
)}
dangerouslySetInnerHTML={{ __html: html ?? "" }}
></div>
)
}

View File

@ -0,0 +1,55 @@
import { EditorView } from "@codemirror/view"
import clsx from "clsx"
import { Dispatch, FC, SetStateAction } from "react"
import { ICommand } from "../command"
export interface EditorToolbarProps {
view: EditorView | null
toolbars: ICommand[]
modeToolbars: ICommand[]
previewVisible: boolean
setPreviewVisible: Dispatch<SetStateAction<boolean>>
}
enum ToolbarMode {
Normal,
Preview,
}
export const EditorToolbar: FC<EditorToolbarProps> = ({
view,
toolbars,
modeToolbars,
previewVisible,
setPreviewVisible,
}) => {
const renderToolbar =
(mode: ToolbarMode) =>
// eslint-disable-next-line react/display-name
({ name, icon, execute }: ICommand) =>
(
<button
key={name}
className={clsx(
"px-1 py-1 mr-1 fill-gray-700 transition-colors hover:fill-blue-500",
{
"fill-blue-500": mode === ToolbarMode.Preview && previewVisible,
},
)}
onClick={() => {
view && execute(view, { setPreviewVisible, container: view.dom })
}}
>
{icon}
</button>
)
return (
<div className="flex px-1 py-1 bg-gray-100 border rounded border-gray-200">
<div className="flex-1">
{toolbars?.map(renderToolbar(ToolbarMode.Normal))}
</div>
<div>{modeToolbars?.map(renderToolbar(ToolbarMode.Preview))}</div>
</div>
)
}

View File

@ -1,21 +1,24 @@
import { type EditorView } from "@codemirror/view"
import clsx from "clsx"
import dayjs from "dayjs"
import { ChangeEvent, useCallback, useEffect, useState } from "react"
import { DashboardMain } from "~/components/dashboard/DashboardMain"
import { getPageVisibility } from "~/lib/page-helpers"
import { PageVisibilityEnum } from "~/lib/types"
import toast from "react-hot-toast"
import { Input } from "~/components/ui/Input"
import { getSiteLink } from "~/lib/helpers"
import { useEditor } from "~/components/ui/Editor"
import { type EditorView } from "@codemirror/view"
import { trpc } from "~/lib/trpc"
import { useRouter } from "next/router"
import { ChangeEvent, useCallback, useEffect, useState } from "react"
import toast from "react-hot-toast"
import { modeToolbars, toolbars } from "~/components/command"
import { DashboardLayout } from "~/components/dashboard/DashboardLayout"
import { DashboardMain } from "~/components/dashboard/DashboardMain"
import { PublishButton } from "~/components/dashboard/PublishButton"
import { useEditor } from "~/components/ui/Editor"
import { EditorPreview } from "~/components/ui/EditorPreview"
import { EditorToolbar } from "~/components/ui/EditorToolbar"
import { Input } from "~/components/ui/Input"
import { UniLink } from "~/components/ui/UniLink"
import { useUploadFile } from "~/hooks/useUploadFile"
import { PublishButton } from "~/components/dashboard/PublishButton"
import { inLocalTimezone } from "~/lib/date"
import { getSiteLink } from "~/lib/helpers"
import { getPageVisibility } from "~/lib/page-helpers"
import { trpc } from "~/lib/trpc"
import { PageVisibilityEnum } from "~/lib/types"
const getInputDatetimeValue = (date: Date | string) => {
const str = dayjs(date).format()
@ -62,6 +65,8 @@ export default function SubdomainEditor() {
})
const [content, setContent] = useState("")
const [previewVisible, setPreviewVisible] = useState(false)
type Values = typeof values
const updateValue = useCallback(
@ -171,7 +176,13 @@ export default function SubdomainEditor() {
<DashboardLayout title="Editor">
<DashboardMain fullWidth>
<header className="flex justify-between absolute top-0 left-0 right-0 z-10 px-5 h-14 border-b items-center text-sm">
<div></div>
<EditorToolbar
view={view}
toolbars={toolbars}
modeToolbars={modeToolbars}
previewVisible={previewVisible}
setPreviewVisible={setPreviewVisible}
></EditorToolbar>
<div className="flex items-center space-x-3">
<span
className={clsx(
@ -194,7 +205,7 @@ export default function SubdomainEditor() {
</header>
<div className="h-screen pt-14 flex w-full">
<div className="h-full overflow-auto w-full">
<div className="max-w-screen-md mx-auto py-5 px-5">
<div className="mx-auto py-5 px-5">
<div>
<input
type="text"
@ -211,8 +222,15 @@ export default function SubdomainEditor() {
/>
</div>
<div className="mt-5">
<div className="">
<div ref={editorRef}></div>
<div className="border border-gray-200 rounded">
<div className="flex">
<div className="flex-1" ref={editorRef}></div>
<EditorPreview
className="flex-1"
previewVisible={previewVisible}
content={content}
></EditorPreview>
</div>
</div>
</div>
</div>