diff --git a/src/components/command/Bold.tsx b/src/components/command/Bold.tsx new file mode 100644 index 00000000..624d82ef --- /dev/null +++ b/src/components/command/Bold.tsx @@ -0,0 +1,21 @@ +import { EditorSelection } from "@codemirror/state" +import { ICommand, wrapExecute } from "." + +export const Bold: ICommand = { + name: "bold", + icon: ( + + + + ), + execute: (view) => { + wrapExecute({ view, prepend: "**", append: "**" }) + }, +} diff --git a/src/components/command/Code.tsx b/src/components/command/Code.tsx new file mode 100644 index 00000000..ba52f317 --- /dev/null +++ b/src/components/command/Code.tsx @@ -0,0 +1,14 @@ +import { EditorSelection } from "@codemirror/state" +import { ICommand, wrapExecute } from "." + +export const Code: ICommand = { + name: "code", + icon: ( + + + + ), + execute: (view) => { + wrapExecute({ view, prepend: "`", append: "`" }) + }, +} diff --git a/src/components/command/Heading.tsx b/src/components/command/Heading.tsx new file mode 100644 index 00000000..3290586c --- /dev/null +++ b/src/components/command/Heading.tsx @@ -0,0 +1,13 @@ +import { ICommand, prependExecute } from "." + +export const Heading: ICommand = { + name: "heading", + icon: ( + + + + ), + execute: (view) => { + prependExecute({ view, prepend: "### " }) + }, +} diff --git a/src/components/command/Image.tsx b/src/components/command/Image.tsx new file mode 100644 index 00000000..3eb378e1 --- /dev/null +++ b/src/components/command/Image.tsx @@ -0,0 +1,16 @@ +import { EditorSelection } from "@codemirror/state" +import { ICommand, prependExecute, wrapExecute } from "." + +export const Image: ICommand = { + name: "image", + icon: ( + + + + + + ), + execute: (view) => { + wrapExecute({ view, prepend: "![", append: "]()" }) + }, +} diff --git a/src/components/command/Italic.tsx b/src/components/command/Italic.tsx new file mode 100644 index 00000000..7b211495 --- /dev/null +++ b/src/components/command/Italic.tsx @@ -0,0 +1,13 @@ +import { ICommand, wrapExecute } from "." + +export const Italic: ICommand = { + name: "italic", + icon: ( + + + + ), + execute: (view) => { + wrapExecute({ view, prepend: "_", append: "_" }) + }, +} diff --git a/src/components/command/Link.tsx b/src/components/command/Link.tsx new file mode 100644 index 00000000..fccecd05 --- /dev/null +++ b/src/components/command/Link.tsx @@ -0,0 +1,14 @@ +import { EditorSelection } from "@codemirror/state" +import { ICommand, prependExecute, wrapExecute } from "." + +export const Link: ICommand = { + name: "link", + icon: ( + + + + ), + execute: (view) => { + wrapExecute({ view, prepend: "[", append: "]()" }) + }, +} diff --git a/src/components/command/ListOrdered.tsx b/src/components/command/ListOrdered.tsx new file mode 100644 index 00000000..85148a36 --- /dev/null +++ b/src/components/command/ListOrdered.tsx @@ -0,0 +1,14 @@ +import { EditorSelection } from "@codemirror/state" +import { ICommand, wrapExecute } from "." + +export const ListOrdered: ICommand = { + name: "list-ordered", + icon: ( + + + + ), + execute: (view) => { + wrapExecute({ view, prepend: "1. ", append: "\n" }) + }, +} diff --git a/src/components/command/ListUnordered.tsx b/src/components/command/ListUnordered.tsx new file mode 100644 index 00000000..26a67599 --- /dev/null +++ b/src/components/command/ListUnordered.tsx @@ -0,0 +1,13 @@ +import { ICommand, wrapExecute } from "." + +export const ListUnordered: ICommand = { + name: "list-unordered", + icon: ( + + + + ), + execute: (view) => { + wrapExecute({ view, prepend: "- ", append: "\n" }) + }, +} diff --git a/src/components/command/Preview.tsx b/src/components/command/Preview.tsx new file mode 100644 index 00000000..08c93333 --- /dev/null +++ b/src/components/command/Preview.tsx @@ -0,0 +1,13 @@ +import { ICommand } from "." + +export const Preview: ICommand = { + name: "preview", + icon: ( + + + + ), + execute: (_, options) => { + options?.setPreviewVisible?.((visible) => !visible) + }, +} diff --git a/src/components/command/Quote.tsx b/src/components/command/Quote.tsx new file mode 100644 index 00000000..e87d5df0 --- /dev/null +++ b/src/components/command/Quote.tsx @@ -0,0 +1,13 @@ +import { ICommand, wrapExecute } from "." + +export const Quote: ICommand = { + name: "quote", + icon: ( + + + + ), + execute: (view) => { + wrapExecute({ view, prepend: "> ", append: "\n" }) + }, +} diff --git a/src/components/command/Strikethrough.tsx b/src/components/command/Strikethrough.tsx new file mode 100644 index 00000000..b4f96b02 --- /dev/null +++ b/src/components/command/Strikethrough.tsx @@ -0,0 +1,19 @@ +import { ICommand, wrapExecute } from "." + +export const Strikethrough: ICommand = { + name: "strikethrough", + icon: ( + + + + ), + execute: (view) => { + wrapExecute({ view, prepend: "~~", append: "~~" }) + }, +} diff --git a/src/components/command/Underline.tsx b/src/components/command/Underline.tsx new file mode 100644 index 00000000..aaf3d04a --- /dev/null +++ b/src/components/command/Underline.tsx @@ -0,0 +1,13 @@ +import { ICommand, wrapExecute } from "." + +export const Underline: ICommand = { + name: "underline", + icon: ( + + + + ), + execute: (view) => { + wrapExecute({ view, prepend: "", append: "" }) + }, +} diff --git a/src/components/command/index.tsx b/src/components/command/index.tsx new file mode 100644 index 00000000..07b3b132 --- /dev/null +++ b/src/components/command/index.tsx @@ -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> + 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] diff --git a/src/components/ui/EditorPreview.tsx b/src/components/ui/EditorPreview.tsx new file mode 100644 index 00000000..dba969ab --- /dev/null +++ b/src/components/ui/EditorPreview.tsx @@ -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 = ({ + 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 ( +
+ ) +} diff --git a/src/components/ui/EditorToolbar.tsx b/src/components/ui/EditorToolbar.tsx new file mode 100644 index 00000000..2b2e8a0f --- /dev/null +++ b/src/components/ui/EditorToolbar.tsx @@ -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> +} + +enum ToolbarMode { + Normal, + Preview, +} + +export const EditorToolbar: FC = ({ + view, + toolbars, + modeToolbars, + previewVisible, + setPreviewVisible, +}) => { + const renderToolbar = + (mode: ToolbarMode) => + // eslint-disable-next-line react/display-name + ({ name, icon, execute }: ICommand) => + ( + + ) + + return ( +
+
+ {toolbars?.map(renderToolbar(ToolbarMode.Normal))} +
+
{modeToolbars?.map(renderToolbar(ToolbarMode.Preview))}
+
+ ) +} diff --git a/src/pages/dashboard/[subdomain]/editor.tsx b/src/pages/dashboard/[subdomain]/editor.tsx index d4148dbc..6188333e 100644 --- a/src/pages/dashboard/[subdomain]/editor.tsx +++ b/src/pages/dashboard/[subdomain]/editor.tsx @@ -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() {
-
+
-
+
-
-
+
+
+
+ +