This commit is contained in:
EGOIST 2022-06-01 00:58:02 +08:00
parent a78eae899a
commit 8084981a29
35 changed files with 376 additions and 276 deletions

View File

@ -20,6 +20,7 @@
"@codemirror/language": "^0.20.0",
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.3",
"@floating-ui/react-dom-interactions": "^0.6.3",
"@headlessui/react": "^1.6.0",
"@prisma/client": "^3.14.0",
"@proselog/jwt": "^0.1.1",

View File

@ -6,6 +6,7 @@ specifiers:
'@codemirror/language': ^0.20.0
'@codemirror/state': ^0.20.0
'@codemirror/view': ^0.20.3
'@floating-ui/react-dom-interactions': ^0.6.3
'@headlessui/react': ^1.6.0
'@iconify-json/bi': ^1.1.3
'@iconify-json/bxs': ^1.1.2
@ -93,6 +94,7 @@ dependencies:
'@codemirror/language': 0.20.0
'@codemirror/state': 0.20.0
'@codemirror/view': 0.20.4
'@floating-ui/react-dom-interactions': 0.6.3_ohobp6rpsmerwlq5ipwfh5yigy
'@headlessui/react': 1.6.1_ef5jwxihqo6n7gxfmzogljlgcm
'@prisma/client': 3.14.0_prisma@3.14.0
'@proselog/jwt': 0.1.1
@ -480,6 +482,45 @@ packages:
- supports-color
dev: true
/@floating-ui/core/0.7.2:
resolution: {integrity: sha512-FRVAkSNU/vGXLIsgbggcs70GkXKEOXgBBbNpYPNHSaKsCAMMd00NrjbtKTesxkdv9xm9N3+XiDlcFGY6WnatBg==}
dev: false
/@floating-ui/dom/0.5.2:
resolution: {integrity: sha512-z1DnEa7F3d8Fm/eXSbii8UEGpcjZGkQaYYUI0WpEVgD3vBfebDW8j/3ysusxonuMexoigA+A3b/fYH7sEqiwyg==}
dependencies:
'@floating-ui/core': 0.7.2
dev: false
/@floating-ui/react-dom-interactions/0.6.3_ohobp6rpsmerwlq5ipwfh5yigy:
resolution: {integrity: sha512-xvbGEtBtA7JaEngnHQjROArv2onRp3oJIpb4+bEN5EGJf0hBYDY0vD8vFGPz/5TQwN++hb6icOB1QwdOnffMzw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
dependencies:
'@floating-ui/react-dom': 0.7.1_ohobp6rpsmerwlq5ipwfh5yigy
aria-hidden: 1.1.3
react: 18.1.0
react-dom: 18.1.0_react@18.1.0
use-isomorphic-layout-effect: 1.1.2_7cpxmzzodpxnolj5zcc5cr63ji
transitivePeerDependencies:
- '@types/react'
dev: false
/@floating-ui/react-dom/0.7.1_ohobp6rpsmerwlq5ipwfh5yigy:
resolution: {integrity: sha512-Dd7e8AupUjzcjeGf1g3EItf/QRtEWKF5GGyEs5WA5n3zlHvEgZ4XrZM6ANhUnzgE3pUQAaXkcXLnibgFp1YBRw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
dependencies:
'@floating-ui/dom': 0.5.2
react: 18.1.0
react-dom: 18.1.0_react@18.1.0
use-isomorphic-layout-effect: 1.1.2_7cpxmzzodpxnolj5zcc5cr63ji
transitivePeerDependencies:
- '@types/react'
dev: false
/@headlessui/react/1.6.1_ef5jwxihqo6n7gxfmzogljlgcm:
resolution: {integrity: sha512-gMd6uIs1U4Oz718Z5gFoV0o/vD43/4zvbyiJN9Dt7PK9Ubxn+TmJwTmYwyNJc5KxxU1t0CmgTNgwZX9+4NjCnQ==}
engines: {node: '>=10'}
@ -1266,6 +1307,13 @@ packages:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
dev: true
/aria-hidden/1.1.3:
resolution: {integrity: sha512-RhVWFtKH5BiGMycI72q2RAFMLQi8JP9bLuQXgR5a8Znp7P5KOIADSJeyfI8PCVxLEp067B2HbP5JIiI/PXIZeA==}
engines: {node: '>=8.5.0'}
dependencies:
tslib: 1.14.1
dev: false
/aria-query/4.2.2:
resolution: {integrity: sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==}
engines: {node: '>=6.0'}
@ -4676,7 +4724,6 @@ packages:
/tslib/1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
dev: true
/tsutils/3.21.0_typescript@4.6.4:
resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==}

View File

@ -1,21 +0,0 @@
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

@ -1,14 +0,0 @@
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

@ -1,13 +0,0 @@
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

@ -1,16 +0,0 @@
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

@ -1,13 +0,0 @@
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

@ -1,14 +0,0 @@
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

@ -1,14 +0,0 @@
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

@ -1,13 +0,0 @@
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

@ -1,13 +0,0 @@
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

@ -1,13 +0,0 @@
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

@ -1,19 +0,0 @@
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

@ -1,13 +0,0 @@
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,16 @@
import clsx from "clsx"
import { useCodeCopy } from "~/hooks/useCodeCopy"
export const PageContent: React.FC<{
contentHTML: string
className?: string
}> = ({ contentHTML, className }) => {
useCodeCopy()
return (
<div
className={clsx(`prose`, className)}
dangerouslySetInnerHTML={{ __html: contentHTML }}
></div>
)
}

View File

@ -1,7 +1,7 @@
import type { PageType } from "~/lib/db.server"
import { Rendered } from "~/markdown"
import { PageContent } from "../common/PageContent"
import { PostMeta } from "./PostMeta"
import { useCodeCopy } from "~/hooks/useCodeCopy"
export const SitePage: React.FC<{
page: {
@ -12,8 +12,6 @@ export const SitePage: React.FC<{
authors?: { id: string; name: string; avatar: string | null }[]
}
}> = ({ page }) => {
useCodeCopy()
return (
<>
<div className="">
@ -29,10 +27,10 @@ export const SitePage: React.FC<{
/>
)}
</div>
<div
className="my-8 prose"
dangerouslySetInnerHTML={{ __html: page.rendered?.contentHTML || "" }}
></div>
<PageContent
contentHTML={page.rendered?.contentHTML || ""}
className="my-8"
/>
</>
)
}

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { EditorState } from "@codemirror/state"
import {
EditorView,
@ -49,56 +49,67 @@ export const useEditor = ({
placeholder?: string
onDropFile?: (file: File, view: EditorView) => void
}) => {
const editorRef = useRef<HTMLDivElement | null>(null)
const [node, setNode] = useState<HTMLDivElement | null>(null)
const [view, setView] = useState<EditorView | null>(null)
const initEditor = useCallback(
(node: HTMLDivElement) => {
const updateListener = EditorView.updateListener.of((vu: ViewUpdate) => {
if (vu.docChanged && typeof onChange === "function") {
const doc = vu.state.doc
const value = doc.toString()
onChange(value)
}
})
const view = new EditorView({
state: EditorState.create({
doc: "",
extensions: [
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]),
history(),
dropCursor(),
drawSelection(),
indentOnInput(),
crosshairCursor(),
EditorState.allowMultipleSelections.of(true),
updateListener,
EditorView.lineWrapping,
markdown(),
placeholderExtension(placeholder || ""),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
theme,
EditorView.domEventHandlers({
drop(e) {
const file = e.dataTransfer?.items[0]?.getAsFile()
if (!file) return
onDropFile?.(file, view)
},
}),
],
}),
parent: node,
})
view.focus()
return view
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[onChange, placeholder],
)
useEffect(() => {
const updateListener = EditorView.updateListener.of((vu: ViewUpdate) => {
if (vu.docChanged && typeof onChange === "function") {
const doc = vu.state.doc
const value = doc.toString()
onChange(value)
}
})
const view = new EditorView({
state: EditorState.create({
doc: "",
extensions: [
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]),
history(),
dropCursor(),
drawSelection(),
indentOnInput(),
crosshairCursor(),
EditorState.allowMultipleSelections.of(true),
updateListener,
EditorView.lineWrapping,
markdown(),
placeholderExtension(placeholder || ""),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
theme,
EditorView.domEventHandlers({
drop(e) {
const file = e.dataTransfer?.items[0]?.getAsFile()
if (!file) return
onDropFile?.(file, view)
},
}),
],
}),
parent: editorRef.current!,
})
setView(view)
const view = node && initEditor(node)
if (view) {
setView(view)
}
return () => {
view.destroy()
setView(null)
view?.destroy()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [placeholder, onDropFile])
}, [node])
// Update view state when `value` changed
useEffect(() => {
@ -115,6 +126,8 @@ export const useEditor = ({
return {
view,
editorRef,
editorRef(node: HTMLDivElement) {
setNode(node)
},
}
}

View File

@ -1,17 +1,15 @@
import clsx from "clsx"
import { FC, useCallback, useEffect, useState } from "react"
import { renderPageContent } from "~/markdown"
import { PageContent } from "../common/PageContent"
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) => {
@ -21,14 +19,5 @@ export const EditorPreview: FC<EditorPreviewProps> = ({
useEffect(() => {
renderMarkdown(content)
}, [content, renderMarkdown])
return (
<div
className={clsx(
"prose border-l border-gray-100 overflow-auto",
className,
{ hidden: !previewVisible },
)}
dangerouslySetInnerHTML={{ __html: html ?? "" }}
></div>
)
return <PageContent className={className} contentHTML={html ?? ""} />
}

View File

@ -1,7 +1,8 @@
import { EditorView } from "@codemirror/view"
import clsx from "clsx"
import { Dispatch, FC, SetStateAction } from "react"
import { ICommand } from "../command"
import { ICommand } from "~/editor"
import { Tooltip } from "./Tooltip"
export interface EditorToolbarProps {
view: EditorView | null
@ -26,30 +27,42 @@ export const EditorToolbar: FC<EditorToolbarProps> = ({
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>
({ name, icon, label, execute }: ICommand) => {
const isPreviewModeCommand = mode === ToolbarMode.Preview
const active = isPreviewModeCommand && previewVisible
const disabled = !isPreviewModeCommand && previewVisible
return (
<Tooltip label={label} placement="bottom">
<button
key={name}
type="button"
disabled={disabled}
className={clsx(
"w-7 h-7 transition-colors text-lg border border-transparent rounded flex items-center justify-center",
active
? `bg-indigo-400 text-white border-indigo-400`
: disabled
? `text-zinc-400 cursor-not-allowed`
: `text-zinc-400 group-hover:text-zinc-600 hover:text-zinc-500 hover:border-zinc-300 hover:bg-zinc-100`,
)}
onClick={() => {
view && execute(view, { setPreviewVisible, container: view.dom })
}}
>
<span className={icon}></span>
</button>
</Tooltip>
)
}
return (
<div className="flex px-1 py-1 bg-gray-100 border rounded border-gray-200">
<div className="flex-1">
<div className="flex group">
<div className="flex-1 flex space-x-1">
{toolbars?.map(renderToolbar(ToolbarMode.Normal))}
</div>
<div>{modeToolbars?.map(renderToolbar(ToolbarMode.Preview))}</div>
<div className="ml-1">
{modeToolbars?.map(renderToolbar(ToolbarMode.Preview))}
</div>
</div>
)
}

View File

@ -0,0 +1,63 @@
import { cloneElement, useState } from "react"
import {
Placement,
offset,
flip,
shift,
autoUpdate,
useFloating,
useInteractions,
useHover,
useFocus,
useRole,
useDismiss,
} from "@floating-ui/react-dom-interactions"
interface Props {
label: string
placement?: Placement
children: JSX.Element
}
export const Tooltip = ({ children, label, placement = "top" }: Props) => {
const [open, setOpen] = useState(false)
const { x, y, reference, floating, strategy, context } = useFloating({
placement,
open,
onOpenChange: setOpen,
middleware: [offset(5), flip(), shift({ padding: 8 })],
whileElementsMounted: autoUpdate,
})
const { getReferenceProps, getFloatingProps } = useInteractions([
useHover(context),
useFocus(context),
useRole(context, { role: "tooltip" }),
useDismiss(context),
])
return (
<>
{cloneElement(
children,
getReferenceProps({ ref: reference, ...children.props }),
)}
{open && (
<div
{...getFloatingProps({
ref: floating,
className: "bg-black/75 text-white rounded-lg shadow-lg px-3 py-2",
style: {
position: strategy,
top: y ?? "",
left: x ?? "",
},
})}
>
{label}
</div>
)}
</>
)
}

11
src/editor/Bold.tsx Normal file
View File

@ -0,0 +1,11 @@
import { EditorSelection } from "@codemirror/state"
import { ICommand, wrapExecute } from "."
export const Bold: ICommand = {
name: "bold",
label: "Bold",
icon: "i-bi:type-bold",
execute: (view) => {
wrapExecute({ view, prepend: "**", append: "**" })
},
}

11
src/editor/Code.tsx Normal file
View File

@ -0,0 +1,11 @@
import { EditorSelection } from "@codemirror/state"
import { ICommand, wrapExecute } from "."
export const Code: ICommand = {
name: "code",
label: "Inline Code",
icon: "i-bi:code",
execute: (view) => {
wrapExecute({ view, prepend: "`", append: "`" })
},
}

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

@ -0,0 +1,10 @@
import { ICommand, wrapExecute } from "."
export const CodeBlock: ICommand = {
name: "codeblock",
label: "Code Block",
icon: "i-bi:body-text",
execute: (view) => {
wrapExecute({ view, prepend: "```\n", append: "\n```" })
},
}

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

@ -0,0 +1,10 @@
import { ICommand, prependExecute } from "."
export const Heading: ICommand = {
name: "heading",
label: "Heading",
icon: "i-bi:type-h3",
execute: (view) => {
prependExecute({ view, prepend: "### " })
},
}

11
src/editor/Image.tsx Normal file
View File

@ -0,0 +1,11 @@
import { EditorSelection } from "@codemirror/state"
import { ICommand, prependExecute, wrapExecute } from "."
export const Image: ICommand = {
name: "image",
label: "Image",
icon: "i-bi:image",
execute: (view) => {
wrapExecute({ view, prepend: "![", append: "]()" })
},
}

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

@ -0,0 +1,10 @@
import { ICommand, wrapExecute } from "."
export const Italic: ICommand = {
name: "italic",
label: "Italic",
icon: "i-bi:type-italic",
execute: (view) => {
wrapExecute({ view, prepend: "_", append: "_" })
},
}

11
src/editor/Link.tsx Normal file
View File

@ -0,0 +1,11 @@
import { EditorSelection } from "@codemirror/state"
import { ICommand, prependExecute, wrapExecute } from "."
export const Link: ICommand = {
name: "link",
label: "Link",
icon: "i-bi:link",
execute: (view) => {
wrapExecute({ view, prepend: "[", append: "]()" })
},
}

View File

@ -0,0 +1,11 @@
import { EditorSelection } from "@codemirror/state"
import { ICommand, wrapExecute } from "."
export const ListOrdered: ICommand = {
name: "list-ordered",
label: "Ordered List",
icon: "i-bi:list-ol",
execute: (view) => {
wrapExecute({ view, prepend: "1. ", append: "\n" })
},
}

View File

@ -0,0 +1,10 @@
import { ICommand, wrapExecute } from "."
export const ListUnordered: ICommand = {
name: "list-unordered",
label: "Unordered List",
icon: "i-bi:list-ul",
execute: (view) => {
wrapExecute({ view, prepend: "- ", append: "\n" })
},
}

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

@ -0,0 +1,10 @@
import { ICommand } from "."
export const Preview: ICommand = {
name: "preview",
label: "Toggle Preview",
icon: "i-bi:eye",
execute: (_, options) => {
options?.setPreviewVisible?.((visible) => !visible)
},
}

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

@ -0,0 +1,10 @@
import { ICommand, wrapExecute } from "."
export const Quote: ICommand = {
name: "quote",
label: "Quote",
icon: "i-bi:quote",
execute: (view) => {
wrapExecute({ view, prepend: "> ", append: "\n" })
},
}

View File

@ -0,0 +1,10 @@
import { ICommand, wrapExecute } from "."
export const Strikethrough: ICommand = {
name: "strikethrough",
label: "Strikethrough",
icon: "i-bi:type-strikethrough",
execute: (view) => {
wrapExecute({ view, prepend: "~~", append: "~~" })
},
}

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

@ -0,0 +1,10 @@
import { ICommand, wrapExecute } from "."
export const Underline: ICommand = {
name: "underline",
label: "Underline",
icon: "i-bi:type-underline",
execute: (view) => {
wrapExecute({ view, prepend: "<u>", append: "</u>" })
},
}

View File

@ -3,6 +3,7 @@ import { EditorView } from "@codemirror/view"
import { Dispatch, SetStateAction } from "react"
import { Bold } from "./Bold"
import { Code } from "./Code"
import { CodeBlock } from "./CodeBlock"
import { Heading } from "./Heading"
import { Image } from "./Image"
import { Italic } from "./Italic"
@ -12,10 +13,12 @@ import { ListUnordered } from "./ListUnordered"
import { Preview } from "./Preview"
import { Quote } from "./Quote"
import { Strikethrough } from "./Strikethrough"
import { Underline } from "./Underline"
export type ICommand = {
icon: React.ReactElement
icon: string
name: string
label: string
execute: (
view: EditorView,
options?: {
@ -117,8 +120,10 @@ export const toolbars: ICommand[] = [
Bold,
Italic,
Strikethrough,
Underline,
Quote,
Code,
CodeBlock,
ListUnordered,
ListOrdered,
Link,

View File

@ -4,7 +4,7 @@ import dayjs from "dayjs"
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 { modeToolbars, toolbars } from "~/editor"
import { DashboardLayout } from "~/components/dashboard/DashboardLayout"
import { DashboardMain } from "~/components/dashboard/DashboardMain"
import { PublishButton } from "~/components/dashboard/PublishButton"
@ -124,6 +124,7 @@ export default function SubdomainEditor() {
value: content,
onChange: handleEditorChange,
onDropFile: handleDropFile,
placeholder: `Start writing...`,
})
useEffect(() => {
@ -205,7 +206,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="mx-auto py-5 px-5">
<div className="max-w-screen-md mx-auto py-5 px-5">
<div>
<input
type="text"
@ -222,16 +223,11 @@ export default function SubdomainEditor() {
/>
</div>
<div className="mt-5">
<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>
{previewVisible ? (
<EditorPreview content={content}></EditorPreview>
) : (
<div ref={editorRef}></div>
)}
</div>
</div>
</div>