feat: feedback (#156)

* minimal tiptap

* wip

* img edit block

* wip

* fix
This commit is contained in:
Aslam
2024-09-10 17:58:58 +07:00
committed by GitHub
parent 4ea3a179e0
commit 711fe35e1a
62 changed files with 2689 additions and 6 deletions

View File

@@ -10,4 +10,6 @@ NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_JAZZ_PEER_URL="wss://"
RONIN_TOKEN=
# IGNORE_BUILD_ERRORS=true

2
web/.gitignore vendored
View File

@@ -34,3 +34,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.ronin

2
web/.npmrc Normal file
View File

@@ -0,0 +1,2 @@
[install.scopes]
ronin = { url = "https://ronin.supply", token = "$RONIN_TOKEN" }

71
web/app/actions.ts Normal file
View File

@@ -0,0 +1,71 @@
"use server"
import { authedProcedure } from "@/lib/utils/auth-procedure"
import { create } from "ronin"
import { z } from "zod"
import { ZSAError } from "zsa"
const MAX_FILE_SIZE = 1 * 1024 * 1024
const ALLOWED_FILE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]
export const sendFeedback = authedProcedure
.input(
z.object({
content: z.string()
})
)
.handler(async ({ input, ctx }) => {
const { clerkUser } = ctx
const { content } = input
try {
await create.feedback.with({
message: content,
emailFrom: clerkUser?.emailAddresses[0].emailAddress
})
} catch (error) {
console.error(error)
throw new ZSAError("ERROR", "Failed to send feedback")
}
})
export const storeImage = authedProcedure
.input(
z.object({
file: z.custom<File>(file => {
if (!(file instanceof File)) {
throw new Error("Not a file")
}
if (!ALLOWED_FILE_TYPES.includes(file.type)) {
throw new Error("Invalid file type. Only JPEG, PNG, GIF, and WebP images are allowed.")
}
if (file.size > MAX_FILE_SIZE) {
throw new Error("File size exceeds the maximum limit of 1 MB.")
}
return true
})
}),
{ type: "formData" }
)
.handler(async ({ ctx, input }) => {
const { file } = input
const { clerkUser } = ctx
if (!clerkUser?.id) {
throw new ZSAError("NOT_AUTHORIZED", "You are not authorized to upload files")
}
try {
const fileModel = await create.image.with({
content: file,
name: file.name,
type: file.type,
size: file.size
})
return { fileModel }
} catch (error) {
console.error(error)
throw new ZSAError("ERROR", "Failed to store image")
}
})

View File

@@ -0,0 +1,137 @@
"use client"
import { Button, buttonVariants } from "@/components/ui/button"
import {
Dialog,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogPortal,
DialogOverlay,
DialogPrimitive
} from "@/components/ui/dialog"
import { LaIcon } from "@/components/custom/la-icon"
import { MinimalTiptapEditor, MinimalTiptapEditorRef } from "@/components/minimal-tiptap"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { useRef, useState } from "react"
import { cn } from "@/lib/utils"
import { sendFeedback } from "@/app/actions"
import { useServerAction } from "zsa-react"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { Spinner } from "@/components/custom/spinner"
const formSchema = z.object({
content: z.string().min(1, {
message: "Feedback cannot be empty"
})
})
export function Feedback() {
const [open, setOpen] = useState(false)
const editorRef = useRef<MinimalTiptapEditorRef>(null)
const { isPending, execute } = useServerAction(sendFeedback)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
content: ""
}
})
async function onSubmit(values: z.infer<typeof formSchema>) {
const [, err] = await execute(values)
if (err) {
toast.error("Failed to send feedback")
console.error(err)
return
}
form.reset({ content: "" })
editorRef.current?.editor?.commands.clearContent()
setOpen(false)
toast.success("Feedback sent")
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="icon" className="shrink-0" variant="ghost">
<LaIcon name="CircleHelp" />
</Button>
</DialogTrigger>
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
"flex flex-col p-4 sm:max-w-2xl"
)}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader className="mb-5">
<DialogTitle>Share feedback</DialogTitle>
<DialogDescription className="sr-only">
Your feedback helps us improve. Please share your thoughts, ideas, and suggestions
</DialogDescription>
</DialogHeader>
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel className="sr-only">Content</FormLabel>
<FormControl>
<MinimalTiptapEditor
{...field}
ref={editorRef}
throttleDelay={500}
className={cn(
"border-muted-foreground/40 focus-within:border-muted-foreground/80 min-h-52 rounded-lg",
{
"border-destructive focus-within:border-destructive": form.formState.errors.content
}
)}
editorContentClassName="p-4 overflow-auto flex grow"
output="html"
placeholder="Your feedback helps us improve. Please share your thoughts, ideas, and suggestions."
autofocus={true}
immediatelyRender={true}
editable={true}
injectCSS={true}
editorClassName="focus:outline-none"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="mt-4">
<DialogPrimitive.Close className={buttonVariants({ variant: "outline" })}>Cancel</DialogPrimitive.Close>
<Button type="submit">
{isPending ? (
<>
<Spinner className="mr-2" />
<span>Sending feedback...</span>
</>
) : (
"Send feedback"
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogPrimitive.Content>
</DialogPortal>
</Dialog>
)
}

View File

@@ -13,6 +13,7 @@ import Link from "next/link"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { usePathname } from "next/navigation"
import { Feedback } from "./feedback"
export const ProfileSection: React.FC = () => {
const { user, isSignedIn } = useUser()
@@ -105,6 +106,8 @@ export const ProfileSection: React.FC = () => {
</DropdownMenuContent>
</DropdownMenu>
</div>
<Feedback />
</div>
</div>
)

View File

@@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "@/lib/utils"
interface SpinnerProps extends React.SVGAttributes<SVGElement> {}
export const Spinner = React.forwardRef<SVGSVGElement, SpinnerProps>(({ className, ...props }, ref) => (
<svg ref={ref} className={cn("h-4 w-4 animate-spin", className)} viewBox="0 0 24 24" {...props}>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
))
Spinner.displayName = "Spinner"

View File

@@ -0,0 +1,39 @@
import type { Editor } from '@tiptap/react'
import { BubbleMenu } from '@tiptap/react'
import { ImagePopoverBlock } from '../image/image-popover-block'
import { ShouldShowProps } from '../../types'
const ImageBubbleMenu = ({ editor }: { editor: Editor }) => {
const shouldShow = ({ editor, from, to }: ShouldShowProps) => {
if (from === to) {
return false
}
const img = editor.getAttributes('image')
if (img.src) {
return true
}
return false
}
const unSetImage = () => {
editor.commands.deleteSelection()
}
return (
<BubbleMenu
editor={editor}
shouldShow={shouldShow}
tippyOptions={{
placement: 'bottom',
offset: [0, 8]
}}
>
<ImagePopoverBlock onRemove={unSetImage} />
</BubbleMenu>
)
}
export { ImageBubbleMenu }

View File

@@ -0,0 +1,106 @@
import React, { useState, useCallback } from 'react'
import { Editor } from '@tiptap/react'
import { BubbleMenu } from '@tiptap/react'
import { LinkEditBlock } from '../link/link-edit-block'
import { LinkPopoverBlock } from '../link/link-popover-block'
import { ShouldShowProps } from '../../types'
interface LinkBubbleMenuProps {
editor: Editor
}
interface LinkAttributes {
href: string
target: string
}
export const LinkBubbleMenu: React.FC<LinkBubbleMenuProps> = ({ editor }) => {
const [showEdit, setShowEdit] = useState(false)
const [linkAttrs, setLinkAttrs] = useState<LinkAttributes>({ href: '', target: '' })
const [selectedText, setSelectedText] = useState('')
const updateLinkState = useCallback(() => {
const { from, to } = editor.state.selection
const { href, target } = editor.getAttributes('link')
const text = editor.state.doc.textBetween(from, to, ' ')
setLinkAttrs({ href, target })
setSelectedText(text)
}, [editor])
const shouldShow = useCallback(
({ editor, from, to }: ShouldShowProps) => {
if (from === to) {
return false
}
const { href } = editor.getAttributes('link')
if (href) {
updateLinkState()
return true
}
return false
},
[updateLinkState]
)
const handleEdit = useCallback(() => {
setShowEdit(true)
}, [])
const onSetLink = useCallback(
(url: string, text?: string, openInNewTab?: boolean) => {
editor
.chain()
.focus()
.extendMarkRange('link')
.insertContent({
type: 'text',
text: text || url,
marks: [
{
type: 'link',
attrs: {
href: url,
target: openInNewTab ? '_blank' : ''
}
}
]
})
.setLink({ href: url, target: openInNewTab ? '_blank' : '' })
.run()
setShowEdit(false)
updateLinkState()
},
[editor, updateLinkState]
)
const onUnsetLink = useCallback(() => {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
setShowEdit(false)
updateLinkState()
}, [editor, updateLinkState])
return (
<BubbleMenu
editor={editor}
shouldShow={shouldShow}
tippyOptions={{
placement: 'bottom-start',
onHidden: () => setShowEdit(false)
}}
>
{showEdit ? (
<LinkEditBlock
defaultUrl={linkAttrs.href}
defaultText={selectedText}
defaultIsNewTab={linkAttrs.target === '_blank'}
onSave={onSetLink}
className="w-full min-w-80 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none"
/>
) : (
<LinkPopoverBlock onClear={onUnsetLink} url={linkAttrs.href} onEdit={handleEdit} />
)}
</BubbleMenu>
)
}

View File

@@ -0,0 +1,98 @@
import type { Editor } from "@tiptap/react"
import React, { useRef, useState } from "react"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { storeImage } from "@/app/actions"
interface ImageEditBlockProps extends React.HTMLAttributes<HTMLDivElement> {
editor: Editor
close: () => void
}
const ImageEditBlock = ({ editor, className, close, ...props }: ImageEditBlockProps) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const [link, setLink] = useState<string>("")
const [isUploading, setIsUploading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
fileInputRef.current?.click()
}
const handleLink = () => {
editor.chain().focus().setImage({ src: link }).run()
close()
}
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
setIsUploading(true)
setError(null)
const formData = new FormData()
formData.append("file", files[0])
try {
const [response, err] = await storeImage(formData)
if (response?.fileModel) {
editor.chain().setImage({ src: response.fileModel.content.src }).focus().run()
close()
} else {
throw new Error("Failed to upload image")
}
} catch (error) {
console.error("Error uploading file:", error)
setError(error instanceof Error ? error.message : "An unknown error occurred")
} finally {
setIsUploading(false)
}
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
handleLink()
}
return (
<form onSubmit={handleSubmit}>
<div className={cn("space-y-6", className)} {...props}>
<div className="space-y-1">
<Label>Attach an image link</Label>
<div className="flex">
<Input
type="url"
required
placeholder="https://example.com"
value={link}
className="grow"
onChange={e => setLink(e.target.value)}
/>
<Button type="submit" className="ml-2 inline-block">
Submit
</Button>
</div>
</div>
<Button className="w-full" onClick={handleClick} disabled={isUploading}>
{isUploading ? "Uploading..." : "Upload from your computer"}
</Button>
<input
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
ref={fileInputRef}
className="hidden"
onChange={handleFile}
/>
{error && <div className="text-destructive text-sm">{error}</div>}
</div>
</form>
)
}
export { ImageEditBlock }

View File

@@ -0,0 +1,48 @@
import type { Editor } from '@tiptap/react'
import { useState } from 'react'
import { ImageIcon } from '@radix-ui/react-icons'
import { ToolbarButton } from '../toolbar-button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogDescription,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
import { ImageEditBlock } from './image-edit-block'
import type { VariantProps } from 'class-variance-authority'
import type { toggleVariants } from '@/components/ui/toggle'
interface ImageEditDialogProps extends VariantProps<typeof toggleVariants> {
editor: Editor
}
const ImageEditDialog = ({ editor, size, variant }: ImageEditDialogProps) => {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<ToolbarButton
isActive={editor.isActive('image')}
tooltip="Image"
aria-label="Image"
size={size}
variant={variant}
>
<ImageIcon className="size-5" />
</ToolbarButton>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Select image</DialogTitle>
<DialogDescription className="sr-only">Upload an image from your computer</DialogDescription>
</DialogHeader>
<ImageEditBlock editor={editor} close={() => setOpen(false)} />
</DialogContent>
</Dialog>
)
}
export { ImageEditDialog }

View File

@@ -0,0 +1,21 @@
import { ToolbarButton } from '../toolbar-button'
import { TrashIcon } from '@radix-ui/react-icons'
const ImagePopoverBlock = ({ onRemove }: { onRemove: (e: React.MouseEvent<HTMLButtonElement>) => void }) => {
const handleRemove = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
onRemove(e)
}
return (
<div className="flex h-10 overflow-hidden rounded bg-background p-2 shadow-lg">
<div className="inline-flex items-center gap-1">
<ToolbarButton tooltip="Remove" onClick={handleRemove}>
<TrashIcon className="size-4" />
</ToolbarButton>
</div>
</div>
)
}
export { ImagePopoverBlock }

View File

@@ -0,0 +1,75 @@
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
export interface LinkEditorProps extends React.HTMLAttributes<HTMLDivElement> {
defaultUrl?: string
defaultText?: string
defaultIsNewTab?: boolean
onSave: (url: string, text?: string, isNewTab?: boolean) => void
}
export const LinkEditBlock = React.forwardRef<HTMLDivElement, LinkEditorProps>(
({ onSave, defaultIsNewTab, defaultUrl, defaultText, className }, ref) => {
const formRef = React.useRef<HTMLDivElement>(null)
const [url, setUrl] = React.useState(defaultUrl || '')
const [text, setText] = React.useState(defaultText || '')
const [isNewTab, setIsNewTab] = React.useState(defaultIsNewTab || false)
const handleSave = React.useCallback(
(e: React.FormEvent) => {
e.preventDefault()
if (formRef.current) {
const isValid = Array.from(formRef.current.querySelectorAll('input')).every(input => input.checkValidity())
if (isValid) {
onSave(url, text, isNewTab)
} else {
formRef.current.querySelectorAll('input').forEach(input => {
if (!input.checkValidity()) {
input.reportValidity()
}
})
}
}
},
[onSave, url, text, isNewTab]
)
React.useImperativeHandle(ref, () => formRef.current as HTMLDivElement)
return (
<div ref={formRef}>
<div className={cn('space-y-4', className)}>
<div className="space-y-1">
<Label>URL</Label>
<Input type="url" required placeholder="Enter URL" value={url} onChange={e => setUrl(e.target.value)} />
</div>
<div className="space-y-1">
<Label>Display Text (optional)</Label>
<Input type="text" placeholder="Enter display text" value={text} onChange={e => setText(e.target.value)} />
</div>
<div className="flex items-center space-x-2">
<Label>Open in New Tab</Label>
<Switch checked={isNewTab} onCheckedChange={setIsNewTab} />
</div>
<div className="flex justify-end space-x-2">
<Button type="button" onClick={handleSave}>
Save
</Button>
</div>
</div>
</div>
)
}
)
LinkEditBlock.displayName = 'LinkEditBlock'
export default LinkEditBlock

View File

@@ -0,0 +1,68 @@
import type { Editor } from '@tiptap/react'
import * as React from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Link2Icon } from '@radix-ui/react-icons'
import { ToolbarButton } from '../toolbar-button'
import { LinkEditBlock } from './link-edit-block'
import type { VariantProps } from 'class-variance-authority'
import type { toggleVariants } from '@/components/ui/toggle'
interface LinkEditPopoverProps extends VariantProps<typeof toggleVariants> {
editor: Editor
}
const LinkEditPopover = ({ editor, size, variant }: LinkEditPopoverProps) => {
const [open, setOpen] = React.useState(false)
const { from, to } = editor.state.selection
const text = editor.state.doc.textBetween(from, to, ' ')
const onSetLink = React.useCallback(
(url: string, text?: string, openInNewTab?: boolean) => {
editor
.chain()
.focus()
.extendMarkRange('link')
.insertContent({
type: 'text',
text: text || url,
marks: [
{
type: 'link',
attrs: {
href: url,
target: openInNewTab ? '_blank' : ''
}
}
]
})
.setLink({ href: url })
.run()
editor.commands.enter()
},
[editor]
)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<ToolbarButton
isActive={editor.isActive('link')}
tooltip="Link"
aria-label="Insert link"
disabled={editor.isActive('codeBlock')}
size={size}
variant={variant}
>
<Link2Icon className="size-5" />
</ToolbarButton>
</PopoverTrigger>
<PopoverContent className="w-full min-w-80" align="start" side="bottom">
<LinkEditBlock onSave={onSetLink} defaultText={text} />
</PopoverContent>
</Popover>
)
}
export { LinkEditPopover }

View File

@@ -0,0 +1,62 @@
import React, { useState, useCallback } from 'react'
import { Separator } from '@/components/ui/separator'
import { ToolbarButton } from '../toolbar-button'
import { CopyIcon, ExternalLinkIcon, LinkBreak2Icon } from '@radix-ui/react-icons'
interface LinkPopoverBlockProps {
url: string
onClear: () => void
onEdit: (e: React.MouseEvent<HTMLButtonElement>) => void
}
export const LinkPopoverBlock: React.FC<LinkPopoverBlockProps> = ({ url, onClear, onEdit }) => {
const [copyTitle, setCopyTitle] = useState<string>('Copy')
const handleCopy = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
navigator.clipboard
.writeText(url)
.then(() => {
setCopyTitle('Copied!')
setTimeout(() => setCopyTitle('Copy'), 1000)
})
.catch(console.error)
},
[url]
)
const handleOpenLink = useCallback(() => {
window.open(url, '_blank', 'noopener,noreferrer')
}, [url])
return (
<div className="flex h-10 overflow-hidden rounded bg-background p-2 shadow-lg">
<div className="inline-flex items-center gap-1">
<ToolbarButton tooltip="Edit link" onClick={onEdit} className="w-auto px-2">
Edit link
</ToolbarButton>
<Separator orientation="vertical" />
<ToolbarButton tooltip="Open link in a new tab" onClick={handleOpenLink}>
<ExternalLinkIcon className="size-4" />
</ToolbarButton>
<Separator orientation="vertical" />
<ToolbarButton tooltip="Clear link" onClick={onClear}>
<LinkBreak2Icon className="size-4" />
</ToolbarButton>
<Separator orientation="vertical" />
<ToolbarButton
tooltip={copyTitle}
onClick={handleCopy}
tooltipOptions={{
onPointerDownOutside: e => {
if (e.target === e.currentTarget) e.preventDefault()
}
}}
>
<CopyIcon className="size-4" />
</ToolbarButton>
</div>
</div>
)
}

View File

@@ -0,0 +1,84 @@
import * as React from 'react'
import type { Editor } from '@tiptap/react'
import { CaretDownIcon, CodeIcon, DividerHorizontalIcon, PlusIcon, QuoteIcon } from '@radix-ui/react-icons'
import { LinkEditPopover } from '../link/link-edit-popover'
import { ImageEditDialog } from '../image/image-edit-dialog'
import type { FormatAction } from '../../types'
import { ToolbarSection } from '../toolbar-section'
import type { toggleVariants } from '@/components/ui/toggle'
import type { VariantProps } from 'class-variance-authority'
type InsertElementAction = 'codeBlock' | 'blockquote' | 'horizontalRule'
interface InsertElement extends FormatAction {
value: InsertElementAction
}
const formatActions: InsertElement[] = [
{
value: 'codeBlock',
label: 'Code block',
icon: <CodeIcon className="size-5" />,
action: editor => editor.chain().focus().toggleCodeBlock().run(),
isActive: editor => editor.isActive('codeBlock'),
canExecute: editor => editor.can().chain().focus().toggleCodeBlock().run(),
shortcuts: ['mod', 'alt', 'C']
},
{
value: 'blockquote',
label: 'Blockquote',
icon: <QuoteIcon className="size-5" />,
action: editor => editor.chain().focus().toggleBlockquote().run(),
isActive: editor => editor.isActive('blockquote'),
canExecute: editor => editor.can().chain().focus().toggleBlockquote().run(),
shortcuts: ['mod', 'shift', 'B']
},
{
value: 'horizontalRule',
label: 'Divider',
icon: <DividerHorizontalIcon className="size-5" />,
action: editor => editor.chain().focus().setHorizontalRule().run(),
isActive: () => false,
canExecute: editor => editor.can().chain().focus().setHorizontalRule().run(),
shortcuts: ['mod', 'alt', '-']
}
]
interface SectionFiveProps extends VariantProps<typeof toggleVariants> {
editor: Editor
activeActions?: InsertElementAction[]
mainActionCount?: number
}
export const SectionFive: React.FC<SectionFiveProps> = ({
editor,
activeActions = formatActions.map(action => action.value),
mainActionCount = 0,
size,
variant
}) => {
return (
<>
<LinkEditPopover editor={editor} size={size} variant={variant} />
<ImageEditDialog editor={editor} size={size} variant={variant} />
<ToolbarSection
editor={editor}
actions={formatActions}
activeActions={activeActions}
mainActionCount={mainActionCount}
dropdownIcon={
<>
<PlusIcon className="size-5" />
<CaretDownIcon className="size-5" />
</>
}
dropdownTooltip="Insert elements"
size={size}
variant={variant}
/>
</>
)
}
SectionFive.displayName = 'SectionFive'
export default SectionFive

View File

@@ -0,0 +1,73 @@
import * as React from 'react'
import type { Editor } from '@tiptap/react'
import { CaretDownIcon, ListBulletIcon } from '@radix-ui/react-icons'
import type { FormatAction } from '../../types'
import { ToolbarSection } from '../toolbar-section'
import type { toggleVariants } from '@/components/ui/toggle'
import type { VariantProps } from 'class-variance-authority'
type ListItemAction = 'orderedList' | 'bulletList'
interface ListItem extends FormatAction {
value: ListItemAction
}
const formatActions: ListItem[] = [
{
value: 'orderedList',
label: 'Numbered list',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="currentColor">
<path d="M144-144v-48h96v-24h-48v-48h48v-24h-96v-48h120q10.2 0 17.1 6.9 6.9 6.9 6.9 17.1v48q0 10.2-6.9 17.1-6.9 6.9-17.1 6.9 10.2 0 17.1 6.9 6.9 6.9 6.9 17.1v48q0 10.2-6.9 17.1-6.9 6.9-17.1 6.9H144Zm0-240v-96q0-10.2 6.9-17.1 6.9-6.9 17.1-6.9h72v-24h-96v-48h120q10.2 0 17.1 6.9 6.9 6.9 6.9 17.1v72q0 10.2-6.9 17.1-6.9 6.9-17.1 6.9h-72v24h96v48H144Zm48-240v-144h-48v-48h96v192h-48Zm168 384v-72h456v72H360Zm0-204v-72h456v72H360Zm0-204v-72h456v72H360Z" />
</svg>
),
isActive: editor => editor.isActive('orderedList'),
action: editor => editor.chain().focus().toggleOrderedList().run(),
canExecute: editor => editor.can().chain().focus().toggleOrderedList().run(),
shortcuts: ['mod', 'shift', '7']
},
{
value: 'bulletList',
label: 'Bullet list',
icon: <ListBulletIcon className="size-5" />,
isActive: editor => editor.isActive('bulletList'),
action: editor => editor.chain().focus().toggleBulletList().run(),
canExecute: editor => editor.can().chain().focus().toggleBulletList().run(),
shortcuts: ['mod', 'shift', '8']
}
]
interface SectionFourProps extends VariantProps<typeof toggleVariants> {
editor: Editor
activeActions?: ListItemAction[]
mainActionCount?: number
}
export const SectionFour: React.FC<SectionFourProps> = ({
editor,
activeActions = formatActions.map(action => action.value),
mainActionCount = 0,
size,
variant
}) => {
return (
<ToolbarSection
editor={editor}
actions={formatActions}
activeActions={activeActions}
mainActionCount={mainActionCount}
dropdownIcon={
<>
<ListBulletIcon className="size-5" />
<CaretDownIcon className="size-5" />
</>
}
dropdownTooltip="Lists"
size={size}
variant={variant}
/>
)
}
SectionFour.displayName = 'SectionFour'
export default SectionFour

View File

@@ -0,0 +1,137 @@
import type { Editor } from '@tiptap/react'
import type { Level } from '@tiptap/extension-heading'
import { cn } from '@/lib/utils'
import { CaretDownIcon, LetterCaseCapitalizeIcon } from '@radix-ui/react-icons'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { ToolbarButton } from '../toolbar-button'
import { ShortcutKey } from '../shortcut-key'
import React, { useCallback, useMemo } from 'react'
import type { FormatAction } from '../../types'
import type { VariantProps } from 'class-variance-authority'
import type { toggleVariants } from '@/components/ui/toggle'
interface TextStyle extends Omit<FormatAction, 'value' | 'icon' | 'action' | 'isActive' | 'canExecute'> {
element: keyof JSX.IntrinsicElements
level?: Level
className: string
}
const formatActions: TextStyle[] = [
{
label: 'Normal Text',
element: 'span',
className: 'grow',
shortcuts: ['mod', 'alt', '0']
},
{
label: 'Heading 1',
element: 'h1',
level: 1,
className: 'm-0 grow text-3xl font-extrabold',
shortcuts: ['mod', 'alt', '1']
},
{
label: 'Heading 2',
element: 'h2',
level: 2,
className: 'm-0 grow text-xl font-bold',
shortcuts: ['mod', 'alt', '2']
},
{
label: 'Heading 3',
element: 'h3',
level: 3,
className: 'm-0 grow text-lg font-semibold',
shortcuts: ['mod', 'alt', '3']
},
{
label: 'Heading 4',
element: 'h4',
level: 4,
className: 'm-0 grow text-base font-semibold',
shortcuts: ['mod', 'alt', '4']
},
{
label: 'Heading 5',
element: 'h5',
level: 5,
className: 'm-0 grow text-sm font-normal',
shortcuts: ['mod', 'alt', '5']
},
{
label: 'Heading 6',
element: 'h6',
level: 6,
className: 'm-0 grow text-sm font-normal',
shortcuts: ['mod', 'alt', '6']
}
]
interface SectionOneProps extends VariantProps<typeof toggleVariants> {
editor: Editor
activeLevels?: Level[]
}
export const SectionOne: React.FC<SectionOneProps> = React.memo(
({ editor, activeLevels = [1, 2, 3, 4, 5, 6], size, variant }) => {
const filteredActions = useMemo(
() => formatActions.filter(action => !action.level || activeLevels.includes(action.level)),
[activeLevels]
)
const handleStyleChange = useCallback(
(level?: Level) => {
if (level) {
editor.chain().focus().toggleHeading({ level }).run()
} else {
editor.chain().focus().setParagraph().run()
}
},
[editor]
)
const renderMenuItem = useCallback(
({ label, element: Element, level, className, shortcuts }: TextStyle) => (
<DropdownMenuItem
key={label}
onClick={() => handleStyleChange(level)}
className={cn('flex flex-row items-center justify-between gap-4', {
'bg-accent': level ? editor.isActive('heading', { level }) : editor.isActive('paragraph')
})}
aria-label={label}
>
<Element className={className}>{label}</Element>
<ShortcutKey keys={shortcuts} />
</DropdownMenuItem>
),
[editor, handleStyleChange]
)
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ToolbarButton
isActive={editor.isActive('heading')}
tooltip="Text styles"
aria-label="Text styles"
pressed={editor.isActive('heading')}
className="w-12"
disabled={editor.isActive('codeBlock')}
size={size}
variant={variant}
>
<LetterCaseCapitalizeIcon className="size-5" />
<CaretDownIcon className="size-5" />
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-full">
{filteredActions.map(renderMenuItem)}
</DropdownMenuContent>
</DropdownMenu>
)
}
)
SectionOne.displayName = 'SectionOne'
export default SectionOne

View File

@@ -0,0 +1,191 @@
import * as React from 'react'
import type { Editor } from '@tiptap/react'
import { CaretDownIcon, CheckIcon } from '@radix-ui/react-icons'
import { ToolbarButton } from '../toolbar-button'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useTheme } from '../../hooks/use-theme'
import type { toggleVariants } from '@/components/ui/toggle'
import type { VariantProps } from 'class-variance-authority'
interface ColorItem {
cssVar: string
label: string
darkLabel?: string
}
interface ColorPalette {
label: string
colors: ColorItem[]
inverse: string
}
const COLORS: ColorPalette[] = [
{
label: 'Palette 1',
inverse: 'hsl(var(--background))',
colors: [
{ cssVar: 'hsl(var(--foreground))', label: 'Default' },
{ cssVar: 'var(--mt-accent-bold-blue)', label: 'Bold blue' },
{ cssVar: 'var(--mt-accent-bold-teal)', label: 'Bold teal' },
{ cssVar: 'var(--mt-accent-bold-green)', label: 'Bold green' },
{ cssVar: 'var(--mt-accent-bold-orange)', label: 'Bold orange' },
{ cssVar: 'var(--mt-accent-bold-red)', label: 'Bold red' },
{ cssVar: 'var(--mt-accent-bold-purple)', label: 'Bold purple' }
]
},
{
label: 'Palette 2',
inverse: 'hsl(var(--background))',
colors: [
{ cssVar: 'var(--mt-accent-gray)', label: 'Gray' },
{ cssVar: 'var(--mt-accent-blue)', label: 'Blue' },
{ cssVar: 'var(--mt-accent-teal)', label: 'Teal' },
{ cssVar: 'var(--mt-accent-green)', label: 'Green' },
{ cssVar: 'var(--mt-accent-orange)', label: 'Orange' },
{ cssVar: 'var(--mt-accent-red)', label: 'Red' },
{ cssVar: 'var(--mt-accent-purple)', label: 'Purple' }
]
},
{
label: 'Palette 3',
inverse: 'hsl(var(--foreground))',
colors: [
{ cssVar: 'hsl(var(--background))', label: 'White', darkLabel: 'Black' },
{ cssVar: 'var(--mt-accent-blue-subtler)', label: 'Blue subtle' },
{ cssVar: 'var(--mt-accent-teal-subtler)', label: 'Teal subtle' },
{ cssVar: 'var(--mt-accent-green-subtler)', label: 'Green subtle' },
{ cssVar: 'var(--mt-accent-yellow-subtler)', label: 'Yellow subtle' },
{ cssVar: 'var(--mt-accent-red-subtler)', label: 'Red subtle' },
{ cssVar: 'var(--mt-accent-purple-subtler)', label: 'Purple subtle' }
]
}
]
const MemoizedColorButton = React.memo<{
color: ColorItem
isSelected: boolean
inverse: string
onClick: (value: string) => void
}>(({ color, isSelected, inverse, onClick }) => {
const isDarkMode = useTheme()
const label = isDarkMode && color.darkLabel ? color.darkLabel : color.label
return (
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
className="relative size-7 rounded-md p-0"
value={color.cssVar}
aria-label={label}
style={{ backgroundColor: color.cssVar }}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
onClick(color.cssVar)
}}
>
{isSelected && <CheckIcon className="absolute inset-0 m-auto size-6" style={{ color: inverse }} />}
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{label}</p>
</TooltipContent>
</Tooltip>
)
})
MemoizedColorButton.displayName = 'MemoizedColorButton'
const MemoizedColorPicker = React.memo<{
palette: ColorPalette
selectedColor: string
inverse: string
onColorChange: (value: string) => void
}>(({ palette, selectedColor, inverse, onColorChange }) => (
<ToggleGroup
type="single"
value={selectedColor}
onValueChange={(value: string) => {
if (value) onColorChange(value)
}}
className="gap-1.5"
>
{palette.colors.map((color, index) => (
<MemoizedColorButton
key={index}
inverse={inverse}
color={color}
isSelected={selectedColor === color.cssVar}
onClick={onColorChange}
/>
))}
</ToggleGroup>
))
MemoizedColorPicker.displayName = 'MemoizedColorPicker'
interface SectionThreeProps extends VariantProps<typeof toggleVariants> {
editor: Editor
}
export const SectionThree: React.FC<SectionThreeProps> = ({ editor, size, variant }) => {
const color = editor.getAttributes('textStyle')?.color || 'hsl(var(--foreground))'
const [selectedColor, setSelectedColor] = React.useState(color)
const handleColorChange = React.useCallback(
(value: string) => {
setSelectedColor(value)
editor.chain().setColor(value).run()
},
[editor]
)
React.useEffect(() => {
setSelectedColor(color)
}, [color])
return (
<Popover>
<PopoverTrigger asChild>
<ToolbarButton tooltip="Text color" aria-label="Text color" className="w-12" size={size} variant={variant}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-5"
style={{ color: selectedColor }}
>
<path d="M4 20h16" />
<path d="m6 16 6-12 6 12" />
<path d="M8 12h8" />
</svg>
<CaretDownIcon className="size-5" />
</ToolbarButton>
</PopoverTrigger>
<PopoverContent align="start" className="w-full">
<div className="space-y-1.5">
{COLORS.map((palette, index) => (
<MemoizedColorPicker
key={index}
palette={palette}
inverse={palette.inverse}
selectedColor={selectedColor}
onColorChange={handleColorChange}
/>
))}
</div>
</PopoverContent>
</Popover>
)
}
SectionThree.displayName = 'SectionThree'
export default SectionThree

View File

@@ -0,0 +1,100 @@
import * as React from 'react'
import type { Editor } from '@tiptap/react'
import {
CodeIcon,
DotsHorizontalIcon,
FontBoldIcon,
FontItalicIcon,
StrikethroughIcon,
TextNoneIcon
} from '@radix-ui/react-icons'
import type { FormatAction } from '../../types'
import { ToolbarSection } from '../toolbar-section'
import type { toggleVariants } from '@/components/ui/toggle'
import type { VariantProps } from 'class-variance-authority'
type TextStyleAction = 'bold' | 'italic' | 'strikethrough' | 'code' | 'clearFormatting'
interface TextStyle extends FormatAction {
value: TextStyleAction
}
const formatActions: TextStyle[] = [
{
value: 'bold',
label: 'Bold',
icon: <FontBoldIcon className="size-5" />,
action: editor => editor.chain().focus().toggleBold().run(),
isActive: editor => editor.isActive('bold'),
canExecute: editor => editor.can().chain().focus().toggleBold().run() && !editor.isActive('codeBlock'),
shortcuts: ['mod', 'B']
},
{
value: 'italic',
label: 'Italic',
icon: <FontItalicIcon className="size-5" />,
action: editor => editor.chain().focus().toggleItalic().run(),
isActive: editor => editor.isActive('italic'),
canExecute: editor => editor.can().chain().focus().toggleItalic().run() && !editor.isActive('codeBlock'),
shortcuts: ['mod', 'I']
},
{
value: 'strikethrough',
label: 'Strikethrough',
icon: <StrikethroughIcon className="size-5" />,
action: editor => editor.chain().focus().toggleStrike().run(),
isActive: editor => editor.isActive('strike'),
canExecute: editor => editor.can().chain().focus().toggleStrike().run() && !editor.isActive('codeBlock'),
shortcuts: ['mod', 'shift', 'S']
},
{
value: 'code',
label: 'Code',
icon: <CodeIcon className="size-5" />,
action: editor => editor.chain().focus().toggleCode().run(),
isActive: editor => editor.isActive('code'),
canExecute: editor => editor.can().chain().focus().toggleCode().run() && !editor.isActive('codeBlock'),
shortcuts: ['mod', 'E']
},
{
value: 'clearFormatting',
label: 'Clear formatting',
icon: <TextNoneIcon className="size-5" />,
action: editor => editor.chain().focus().unsetAllMarks().run(),
isActive: () => false,
canExecute: editor => editor.can().chain().focus().unsetAllMarks().run() && !editor.isActive('codeBlock'),
shortcuts: ['mod', '\\']
}
]
interface SectionTwoProps extends VariantProps<typeof toggleVariants> {
editor: Editor
activeActions?: TextStyleAction[]
mainActionCount?: number
}
export const SectionTwo: React.FC<SectionTwoProps> = ({
editor,
activeActions = formatActions.map(action => action.value),
mainActionCount = 2,
size,
variant
}) => {
return (
<ToolbarSection
editor={editor}
actions={formatActions}
activeActions={activeActions}
mainActionCount={mainActionCount}
dropdownIcon={<DotsHorizontalIcon className="size-5" />}
dropdownTooltip="More formatting"
dropdownClassName="w-8"
size={size}
variant={variant}
/>
)
}
SectionTwo.displayName = 'SectionTwo'
export default SectionTwo

View File

@@ -0,0 +1,33 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
import { getShortcutKey } from '../utils'
export interface ShortcutKeyProps extends React.HTMLAttributes<HTMLSpanElement> {
keys: string[]
}
export const ShortcutKey = React.forwardRef<HTMLSpanElement, ShortcutKeyProps>(({ className, keys, ...props }, ref) => {
const modifiedKeys = keys.map(key => getShortcutKey(key))
const ariaLabel = modifiedKeys.map(shortcut => shortcut.readable).join(' + ')
return (
<span aria-label={ariaLabel} className={cn('inline-flex items-center gap-0.5', className)} {...props} ref={ref}>
{modifiedKeys.map(shortcut => (
<kbd
key={shortcut.symbol}
className={cn(
'inline-block min-w-2.5 text-center align-baseline font-sans text-xs font-medium capitalize text-[rgb(156,157,160)]',
className
)}
{...props}
ref={ref}
>
{shortcut.symbol}
</kbd>
))}
</span>
)
})
ShortcutKey.displayName = 'ShortcutKey'

View File

@@ -0,0 +1,38 @@
import * as React from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Toggle } from '@/components/ui/toggle'
import { cn } from '@/lib/utils'
import type { TooltipContentProps } from '@radix-ui/react-tooltip'
interface ToolbarButtonProps extends React.ComponentPropsWithoutRef<typeof Toggle> {
isActive?: boolean
tooltip?: string
tooltipOptions?: TooltipContentProps
}
export const ToolbarButton = React.forwardRef<HTMLButtonElement, ToolbarButtonProps>(
({ isActive, children, tooltip, className, tooltipOptions, ...props }, ref) => {
const toggleButton = (
<Toggle size="sm" ref={ref} className={cn('size-8 p-0', { 'bg-accent': isActive }, className)} {...props}>
{children}
</Toggle>
)
if (!tooltip) {
return toggleButton
}
return (
<Tooltip>
<TooltipTrigger asChild>{toggleButton}</TooltipTrigger>
<TooltipContent {...tooltipOptions}>
<div className="flex flex-col items-center text-center">{tooltip}</div>
</TooltipContent>
</Tooltip>
)
}
)
ToolbarButton.displayName = 'ToolbarButton'
export default ToolbarButton

View File

@@ -0,0 +1,112 @@
import * as React from 'react'
import type { Editor } from '@tiptap/react'
import { cn } from '@/lib/utils'
import { CaretDownIcon } from '@radix-ui/react-icons'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { ToolbarButton } from './toolbar-button'
import { ShortcutKey } from './shortcut-key'
import { getShortcutKey } from '../utils'
import type { FormatAction } from '../types'
import type { VariantProps } from 'class-variance-authority'
import type { toggleVariants } from '@/components/ui/toggle'
interface ToolbarSectionProps extends VariantProps<typeof toggleVariants> {
editor: Editor
actions: FormatAction[]
activeActions?: string[]
mainActionCount?: number
dropdownIcon?: React.ReactNode
dropdownTooltip?: string
dropdownClassName?: string
}
export const ToolbarSection: React.FC<ToolbarSectionProps> = ({
editor,
actions,
activeActions = actions.map(action => action.value),
mainActionCount = 0,
dropdownIcon,
dropdownTooltip = 'More options',
dropdownClassName = 'w-12',
size,
variant
}) => {
const { mainActions, dropdownActions } = React.useMemo(() => {
const sortedActions = actions
.filter(action => activeActions.includes(action.value))
.sort((a, b) => activeActions.indexOf(a.value) - activeActions.indexOf(b.value))
return {
mainActions: sortedActions.slice(0, mainActionCount),
dropdownActions: sortedActions.slice(mainActionCount)
}
}, [actions, activeActions, mainActionCount])
const renderToolbarButton = React.useCallback(
(action: FormatAction) => (
<ToolbarButton
key={action.label}
onClick={() => action.action(editor)}
disabled={!action.canExecute(editor)}
isActive={action.isActive(editor)}
tooltip={`${action.label} ${action.shortcuts.map(s => getShortcutKey(s).symbol).join(' ')}`}
aria-label={action.label}
size={size}
variant={variant}
>
{action.icon}
</ToolbarButton>
),
[editor, size, variant]
)
const renderDropdownMenuItem = React.useCallback(
(action: FormatAction) => (
<DropdownMenuItem
key={action.label}
onClick={() => action.action(editor)}
disabled={!action.canExecute(editor)}
className={cn('flex flex-row items-center justify-between gap-4', {
'bg-accent': action.isActive(editor)
})}
aria-label={action.label}
>
<span className="grow">{action.label}</span>
<ShortcutKey keys={action.shortcuts} />
</DropdownMenuItem>
),
[editor]
)
const isDropdownActive = React.useMemo(
() => dropdownActions.some(action => action.isActive(editor)),
[dropdownActions, editor]
)
return (
<>
{mainActions.map(renderToolbarButton)}
{dropdownActions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ToolbarButton
isActive={isDropdownActive}
tooltip={dropdownTooltip}
aria-label={dropdownTooltip}
className={cn(dropdownClassName)}
size={size}
variant={variant}
>
{dropdownIcon || <CaretDownIcon className="size-5" />}
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-full">
{dropdownActions.map(renderDropdownMenuItem)}
</DropdownMenuContent>
</DropdownMenu>
)}
</>
)
}
export default ToolbarSection

View File

@@ -0,0 +1,17 @@
import { CodeBlockLowlight as TiptapCodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'
import { common, createLowlight } from 'lowlight'
export const CodeBlockLowlight = TiptapCodeBlockLowlight.extend({
addOptions() {
return {
...this.parent?.(),
lowlight: createLowlight(common),
defaultLanguage: null,
HTMLAttributes: {
class: 'block-node'
}
}
}
})
export default CodeBlockLowlight

View File

@@ -0,0 +1 @@
export * from './code-block-lowlight'

View File

@@ -0,0 +1,20 @@
import { Color as TiptapColor } from '@tiptap/extension-color'
import { Plugin } from '@tiptap/pm/state'
export const Color = TiptapColor.extend({
addProseMirrorPlugins() {
return [
...(this.parent?.() || []),
new Plugin({
props: {
handleKeyDown: (_, event) => {
if (event.key === 'Enter') {
this.editor.commands.unsetColor()
}
return false
}
}
})
]
}
})

View File

@@ -0,0 +1 @@
export * from './color'

View File

@@ -0,0 +1,18 @@
/*
* Wrap the horizontal rule in a div element.
* Also add a keyboard shortcut to insert a horizontal rule.
*/
import { HorizontalRule as TiptapHorizontalRule } from '@tiptap/extension-horizontal-rule'
export const HorizontalRule = TiptapHorizontalRule.extend({
addKeyboardShortcuts() {
return {
'Mod-Alt--': () =>
this.editor.commands.insertContent({
type: this.name
})
}
}
})
export default HorizontalRule

View File

@@ -0,0 +1 @@
export * from './horizontal-rule'

View File

@@ -0,0 +1,45 @@
import { isNumber, NodeViewProps, NodeViewWrapper } from '@tiptap/react'
import { useMemo } from 'react'
import { useImageLoad } from '../../../hooks/use-image-load'
import { cn } from '@/lib/utils'
const ImageViewBlock = ({ editor, node, getPos }: NodeViewProps) => {
const imgSize = useImageLoad(node.attrs.src)
const paddingBottom = useMemo(() => {
if (!imgSize.width || !imgSize.height) {
return 0
}
return (imgSize.height / imgSize.width) * 100
}, [imgSize.width, imgSize.height])
return (
<NodeViewWrapper>
<div draggable data-drag-handle>
<figure>
<div className="relative w-full" style={{ paddingBottom: `${isNumber(paddingBottom) ? paddingBottom : 0}%` }}>
<div className="absolute h-full w-full">
<div
className={cn('relative h-full max-h-full w-full max-w-full rounded transition-all')}
style={{
boxShadow: editor.state.selection.from === getPos() ? '0 0 0 1px hsl(var(--primary))' : 'none'
}}
>
<div className="relative flex h-full max-h-full w-full max-w-full overflow-hidden">
<img
alt={node.attrs.alt}
src={node.attrs.src}
className="absolute left-2/4 top-2/4 m-0 h-full max-w-full -translate-x-2/4 -translate-y-2/4 transform object-contain"
/>
</div>
</div>
</div>
</div>
</figure>
</div>
</NodeViewWrapper>
)
}
export { ImageViewBlock }

View File

@@ -0,0 +1,9 @@
import { Image as TiptapImage } from '@tiptap/extension-image'
import { ReactNodeViewRenderer } from '@tiptap/react'
import { ImageViewBlock } from './components/image-view-block'
export const Image = TiptapImage.extend({
addNodeView() {
return ReactNodeViewRenderer(ImageViewBlock)
}
})

View File

@@ -0,0 +1 @@
export * from './image'

View File

@@ -0,0 +1,8 @@
export * from './code-block-lowlight'
export * from './color'
export * from './horizontal-rule'
export * from './image'
export * from './link'
export * from './selection'
export * from './unset-all-marks'
export * from './reset-marks-on-enter'

View File

@@ -0,0 +1 @@
export * from './link'

View File

@@ -0,0 +1,89 @@
import { mergeAttributes } from '@tiptap/core'
import TiptapLink from '@tiptap/extension-link'
import { EditorView } from '@tiptap/pm/view'
import { getMarkRange } from '@tiptap/core'
import { Plugin, TextSelection } from '@tiptap/pm/state'
export const Link = TiptapLink.extend({
/*
* Determines whether typing next to a link automatically becomes part of the link.
* In this case, we dont want any characters to be included as part of the link.
*/
inclusive: false,
/*
* Match all <a> elements that have an href attribute, except for:
* - <a> elements with a data-type attribute set to button
* - <a> elements with an href attribute that contains 'javascript:'
*/
parseHTML() {
return [{ tag: 'a[href]:not([data-type="button"]):not([href *= "javascript:" i])' }]
},
renderHTML({ HTMLAttributes }) {
return ['a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
},
addOptions() {
return {
...this.parent?.(),
openOnClick: false,
HTMLAttributes: {
class: 'link'
}
}
},
addProseMirrorPlugins() {
const { editor } = this
return [
...(this.parent?.() || []),
new Plugin({
props: {
handleKeyDown: (_: EditorView, event: KeyboardEvent) => {
const { selection } = editor.state
/*
* Handles the 'Escape' key press when there's a selection within the link.
* This will move the cursor to the end of the link.
*/
if (event.key === 'Escape' && selection.empty !== true) {
editor.commands.focus(selection.to, { scrollIntoView: false })
}
return false
},
handleClick(view, pos) {
/*
* Marks the entire link when the user clicks on it.
*/
const { schema, doc, tr } = view.state
const range = getMarkRange(doc.resolve(pos), schema.marks.link)
if (!range) {
return
}
const { from, to } = range
const start = Math.min(from, to)
const end = Math.max(from, to)
if (pos < start || pos > end) {
return
}
const $start = doc.resolve(start)
const $end = doc.resolve(end)
const transaction = tr.setSelection(new TextSelection($start, $end))
view.dispatch(transaction)
}
}
})
]
}
})
export default Link

View File

@@ -0,0 +1 @@
export * from './reset-marks-on-enter'

View File

@@ -0,0 +1,25 @@
import { Extension } from '@tiptap/core'
export const ResetMarksOnEnter = Extension.create({
name: 'resetMarksOnEnter',
addKeyboardShortcuts() {
return {
Enter: ({ editor }) => {
if (
editor.isActive('bold') ||
editor.isActive('italic') ||
editor.isActive('strike') ||
editor.isActive('underline') ||
editor.isActive('code')
) {
editor.commands.splitBlock({ keepMarks: false })
return true
}
return false
}
}
}
})

View File

@@ -0,0 +1 @@
export * from './selection'

View File

@@ -0,0 +1,36 @@
import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
export const Selection = Extension.create({
name: 'selection',
addProseMirrorPlugins() {
const { editor } = this
return [
new Plugin({
key: new PluginKey('selection'),
props: {
decorations(state) {
if (state.selection.empty) {
return null
}
if (editor.isFocused === true) {
return null
}
return DecorationSet.create(state.doc, [
Decoration.inline(state.selection.from, state.selection.to, {
class: 'selection'
})
])
}
}
})
]
}
})
export default Selection

View File

@@ -0,0 +1 @@
export * from './unset-all-marks'

View File

@@ -0,0 +1,9 @@
import { Extension } from '@tiptap/core'
export const UnsetAllMarks = Extension.create({
addKeyboardShortcuts() {
return {
'Mod-\\': () => this.editor.commands.unsetAllMarks()
}
}
})

View File

@@ -0,0 +1,15 @@
import * as React from 'react'
export const useImageLoad = (src: string) => {
const [imgSize, setImgSize] = React.useState({ width: 0, height: 0 })
React.useEffect(() => {
const img = new Image()
img.src = src
img.onload = () => {
setImgSize({ width: img.width, height: img.height })
}
}, [src])
return imgSize
}

View File

@@ -0,0 +1,107 @@
import * as React from "react"
import { StarterKit } from "@tiptap/starter-kit"
import type { Content, UseEditorOptions } from "@tiptap/react"
import { useEditor } from "@tiptap/react"
import type { Editor } from "@tiptap/core"
import { Typography } from "@tiptap/extension-typography"
import { Placeholder } from "@tiptap/extension-placeholder"
import { TextStyle } from "@tiptap/extension-text-style"
import {
Link,
Image,
HorizontalRule,
CodeBlockLowlight,
Selection,
Color,
UnsetAllMarks,
ResetMarksOnEnter
} from "../extensions"
import { cn } from "@/lib/utils"
import { getOutput } from "../utils"
import { useThrottle } from "../hooks/use-throttle"
export interface UseMinimalTiptapEditorProps extends UseEditorOptions {
value?: Content
output?: "html" | "json" | "text"
placeholder?: string
editorClassName?: string
throttleDelay?: number
onUpdate?: (content: Content) => void
onBlur?: (content: Content) => void
}
const createExtensions = (placeholder: string) => [
StarterKit.configure({
horizontalRule: false,
codeBlock: false,
paragraph: { HTMLAttributes: { class: "text-node" } },
heading: { HTMLAttributes: { class: "heading-node" } },
blockquote: { HTMLAttributes: { class: "block-node" } },
bulletList: { HTMLAttributes: { class: "list-node" } },
orderedList: { HTMLAttributes: { class: "list-node" } },
code: { HTMLAttributes: { class: "inline", spellcheck: "false" } },
dropcursor: { width: 2, class: "ProseMirror-dropcursor border" }
}),
Link,
Image,
Color,
TextStyle,
Selection,
Typography,
UnsetAllMarks,
HorizontalRule,
ResetMarksOnEnter,
CodeBlockLowlight,
Placeholder.configure({ placeholder: () => placeholder })
]
export const useMinimalTiptapEditor = ({
value,
output = "html",
placeholder = "",
editorClassName,
throttleDelay = 1000,
onUpdate,
onBlur,
...props
}: UseMinimalTiptapEditorProps) => {
const throttledSetValue = useThrottle((value: Content) => onUpdate?.(value), throttleDelay)
const handleUpdate = React.useCallback(
(editor: Editor) => {
throttledSetValue(getOutput(editor, output))
},
[output, throttledSetValue]
)
const handleCreate = React.useCallback(
(editor: Editor) => {
if (value && editor.isEmpty) {
editor.commands.setContent(value)
}
},
[value]
)
const handleBlur = React.useCallback((editor: Editor) => onBlur?.(getOutput(editor, output)), [output, onBlur])
const editor = useEditor({
extensions: createExtensions(placeholder!),
editorProps: {
attributes: {
autocomplete: "off",
autocorrect: "off",
autocapitalize: "off",
class: cn("focus:outline-none", editorClassName)
}
},
onUpdate: ({ editor }) => handleUpdate(editor),
onCreate: ({ editor }) => handleCreate(editor),
onBlur: ({ editor }) => handleBlur(editor),
...props
})
return editor
}
export default useMinimalTiptapEditor

View File

@@ -0,0 +1,25 @@
import * as React from 'react'
export const useTheme = () => {
const [isDarkMode, setIsDarkMode] = React.useState(false)
React.useEffect(() => {
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
setIsDarkMode(darkModeMediaQuery.matches)
const handleChange = (e: MediaQueryListEvent) => {
const newDarkMode = e.matches
setIsDarkMode(newDarkMode)
}
darkModeMediaQuery.addEventListener('change', handleChange)
return () => {
darkModeMediaQuery.removeEventListener('change', handleChange)
}
}, [])
return isDarkMode
}
export default useTheme

View File

@@ -0,0 +1,34 @@
import { useRef, useCallback } from 'react'
export function useThrottle<T extends (...args: any[]) => void>(
callback: T,
delay: number
): (...args: Parameters<T>) => void {
const lastRan = useRef(Date.now())
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
return useCallback(
(...args: Parameters<T>) => {
const handler = () => {
if (Date.now() - lastRan.current >= delay) {
callback(...args)
lastRan.current = Date.now()
} else {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = setTimeout(
() => {
callback(...args)
lastRan.current = Date.now()
},
delay - (Date.now() - lastRan.current)
)
}
}
handler()
},
[callback, delay]
)
}

View File

@@ -0,0 +1 @@
export * from './minimal-tiptap'

View File

@@ -0,0 +1,95 @@
import * as React from "react"
import "./styles/index.css"
import { EditorContent } from "@tiptap/react"
import type { Content, Editor } from "@tiptap/react"
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils"
import { SectionOne } from "./components/section/one"
import { SectionTwo } from "./components/section/two"
import { SectionThree } from "./components/section/three"
import { SectionFour } from "./components/section/four"
import { SectionFive } from "./components/section/five"
import { LinkBubbleMenu } from "./components/bubble-menu/link-bubble-menu"
import { ImageBubbleMenu } from "./components/bubble-menu/image-bubble-menu"
import type { UseMinimalTiptapEditorProps } from "./hooks/use-minimal-tiptap"
import { useMinimalTiptapEditor } from "./hooks/use-minimal-tiptap"
export interface MinimalTiptapProps extends Omit<UseMinimalTiptapEditorProps, "onUpdate"> {
value?: Content
onChange?: (value: Content) => void
className?: string
editorContentClassName?: string
}
const Toolbar = ({ editor }: { editor: Editor }) => (
<div className="border-border shrink-0 overflow-x-auto border-b p-2">
<div className="flex w-max items-center gap-px">
<SectionOne editor={editor} activeLevels={[1, 2, 3, 4, 5, 6]} />
<Separator orientation="vertical" className="mx-2 h-7" />
<SectionTwo
editor={editor}
activeActions={["bold", "italic", "strikethrough", "code", "clearFormatting"]}
mainActionCount={2}
/>
<Separator orientation="vertical" className="mx-2 h-7" />
<SectionThree editor={editor} />
<Separator orientation="vertical" className="mx-2 h-7" />
<SectionFour editor={editor} activeActions={["orderedList", "bulletList"]} mainActionCount={0} />
<Separator orientation="vertical" className="mx-2 h-7" />
<SectionFive editor={editor} activeActions={["codeBlock", "blockquote", "horizontalRule"]} mainActionCount={0} />
</div>
</div>
)
export type MinimalTiptapEditorRef = {
editor: Editor | null
}
export const MinimalTiptapEditor = React.forwardRef<MinimalTiptapEditorRef, MinimalTiptapProps>(
({ value, onChange, className, editorContentClassName, ...props }, ref) => {
const editor = useMinimalTiptapEditor({
value,
onUpdate: onChange,
...props
})
React.useImperativeHandle(
ref,
() => ({
editor: editor || null
}),
[editor]
)
if (!editor) {
return null
}
return (
<div
className={cn(
"border-input focus-within:border-primary flex h-auto min-h-72 w-full flex-col rounded-md border shadow-sm",
className
)}
>
<Toolbar editor={editor} />
<EditorContent editor={editor} className={cn("minimal-tiptap-editor", editorContentClassName)} />
<LinkBubbleMenu editor={editor} />
<ImageBubbleMenu editor={editor} />
</div>
)
}
)
MinimalTiptapEditor.displayName = "MinimalTiptapEditor"
export default MinimalTiptapEditor

View File

@@ -0,0 +1,182 @@
@import './partials/code.css';
@import './partials/placeholder.css';
@import './partials/lists.css';
@import './partials/typography.css';
:root {
--mt-font-size-regular: 0.9375rem;
--mt-code-background: #082b781f;
--mt-code-color: #d4d4d4;
--mt-secondary: #9d9d9f;
--mt-pre-background: #ececec;
--mt-pre-border: #e0e0e0;
--mt-pre-color: #2f2f31;
--mt-hr: #dcdcdc;
--mt-drag-handle-hover: #5c5c5e;
--mt-accent-bold-blue: #05c;
--mt-accent-bold-teal: #206a83;
--mt-accent-bold-green: #216e4e;
--mt-accent-bold-orange: #a54800;
--mt-accent-bold-red: #ae2e24;
--mt-accent-bold-purple: #5e4db2;
--mt-accent-gray: #758195;
--mt-accent-blue: #1d7afc;
--mt-accent-teal: #2898bd;
--mt-accent-green: #22a06b;
--mt-accent-orange: #fea362;
--mt-accent-red: #c9372c;
--mt-accent-purple: #8270db;
--mt-accent-blue-subtler: #cce0ff;
--mt-accent-teal-subtler: #c6edfb;
--mt-accent-green-subtler: #baf3db;
--mt-accent-yellow-subtler: #f8e6a0;
--mt-accent-red-subtler: #ffd5d2;
--mt-accent-purple-subtler: #dfd8fd;
--hljs-string: #aa430f;
--hljs-title: #b08836;
--hljs-comment: #999999;
--hljs-keyword: #0c5eb1;
--hljs-attr: #3a92bc;
--hljs-literal: #c82b0f;
--hljs-name: #259792;
--hljs-selector-tag: #c8500f;
--hljs-number: #3da067;
}
.dark {
--mt-font-size-regular: 0.9375rem;
--mt-code-background: #ffffff13;
--mt-code-color: #2c2e33;
--mt-secondary: #595a5c;
--mt-pre-background: #080808;
--mt-pre-border: #23252a;
--mt-pre-color: #e3e4e6;
--mt-hr: #26282d;
--mt-drag-handle-hover: #969799;
--mt-accent-bold-blue: #85b8ff;
--mt-accent-bold-teal: #9dd9ee;
--mt-accent-bold-green: #7ee2b8;
--mt-accent-bold-orange: #fec195;
--mt-accent-bold-red: #fd9891;
--mt-accent-bold-purple: #b8acf6;
--mt-accent-gray: #738496;
--mt-accent-blue: #388bff;
--mt-accent-teal: #42b2d7;
--mt-accent-green: #2abb7f;
--mt-accent-orange: #a54800;
--mt-accent-red: #e2483d;
--mt-accent-purple: #8f7ee7;
--mt-accent-blue-subtler: #09326c;
--mt-accent-teal-subtler: #164555;
--mt-accent-green-subtler: #164b35;
--mt-accent-yellow-subtler: #533f04;
--mt-accent-red-subtler: #5d1f1a;
--mt-accent-purple-subtler: #352c63;
--hljs-string: #da936b;
--hljs-title: #f1d59d;
--hljs-comment: #aaaaaa;
--hljs-keyword: #6699cc;
--hljs-attr: #90cae8;
--hljs-literal: #f2777a;
--hljs-name: #5fc0a0;
--hljs-selector-tag: #e8c785;
--hljs-number: #b6e7b6;
}
.minimal-tiptap-editor .ProseMirror {
@apply flex max-w-full flex-1 cursor-text flex-col;
@apply z-0 outline-0;
}
.minimal-tiptap-editor .ProseMirror > div.editor {
@apply block flex-1 whitespace-pre-wrap;
}
.minimal-tiptap-editor .ProseMirror .block-node:not(:last-child),
.minimal-tiptap-editor .ProseMirror .list-node:not(:last-child),
.minimal-tiptap-editor .ProseMirror .text-node:not(:last-child) {
@apply mb-2.5;
}
.minimal-tiptap-editor .ProseMirror ol,
.minimal-tiptap-editor .ProseMirror ul {
@apply pl-6;
}
.minimal-tiptap-editor .ProseMirror blockquote,
.minimal-tiptap-editor .ProseMirror dl,
.minimal-tiptap-editor .ProseMirror ol,
.minimal-tiptap-editor .ProseMirror p,
.minimal-tiptap-editor .ProseMirror pre,
.minimal-tiptap-editor .ProseMirror ul {
@apply m-0;
}
.minimal-tiptap-editor .ProseMirror li {
@apply leading-7;
}
.minimal-tiptap-editor .ProseMirror p {
@apply break-words;
}
.minimal-tiptap-editor .ProseMirror li .text-node:has(+ .list-node),
.minimal-tiptap-editor .ProseMirror li > .list-node,
.minimal-tiptap-editor .ProseMirror li > .text-node,
.minimal-tiptap-editor .ProseMirror li p {
@apply mb-0;
}
.minimal-tiptap-editor .ProseMirror blockquote {
@apply relative pl-3.5;
}
.minimal-tiptap-editor .ProseMirror blockquote::before,
.minimal-tiptap-editor .ProseMirror blockquote.is-empty::before {
@apply absolute bottom-0 left-0 top-0 h-full w-1 rounded-sm bg-accent-foreground/15 content-[''];
}
.minimal-tiptap-editor .ProseMirror hr {
@apply my-3 h-0.5 w-full border-none bg-[var(--mt-hr)];
}
.minimal-tiptap-editor .ProseMirror-focused hr.ProseMirror-selectednode {
@apply rounded-full outline outline-2 outline-offset-1 outline-muted-foreground;
}
.minimal-tiptap-editor .ProseMirror .ProseMirror-gapcursor {
@apply pointer-events-none absolute hidden;
}
.minimal-tiptap-editor .ProseMirror .ProseMirror-hideselection {
@apply caret-transparent;
}
.minimal-tiptap-editor .ProseMirror.resize-cursor {
@apply cursor-col-resize;
}
.minimal-tiptap-editor .ProseMirror .selection {
@apply inline-block;
}
.minimal-tiptap-editor .ProseMirror .selection,
.minimal-tiptap-editor .ProseMirror *::selection,
::selection {
@apply bg-primary/25;
}
/* Override native selection when custom selection is present */
.minimal-tiptap-editor .ProseMirror .selection::selection {
background: transparent;
}

View File

@@ -0,0 +1,86 @@
.minimal-tiptap-editor .ProseMirror code.inline {
@apply rounded border border-[var(--mt-code-color)] bg-[var(--mt-code-background)] px-1 py-0.5 text-sm;
}
.minimal-tiptap-editor .ProseMirror pre {
@apply relative overflow-auto rounded border font-mono text-sm;
@apply border-[var(--mt-pre-border)] bg-[var(--mt-pre-background)] text-[var(--mt-pre-color)];
@apply hyphens-none whitespace-pre text-left;
}
.minimal-tiptap-editor .ProseMirror code {
@apply break-words leading-[1.7em];
}
.minimal-tiptap-editor .ProseMirror pre code {
@apply block overflow-x-auto p-3.5;
}
.minimal-tiptap-editor .ProseMirror pre {
.hljs-keyword,
.hljs-operator,
.hljs-function,
.hljs-built_in,
.hljs-builtin-name {
color: var(--hljs-keyword);
}
.hljs-attr,
.hljs-symbol,
.hljs-property,
.hljs-attribute,
.hljs-variable,
.hljs-template-variable,
.hljs-params {
color: var(--hljs-attr);
}
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-type,
.hljs-addition {
color: var(--hljs-name);
}
.hljs-string,
.hljs-bullet {
color: var(--hljs-string);
}
.hljs-title,
.hljs-subst,
.hljs-section {
color: var(--hljs-title);
}
.hljs-literal,
.hljs-type,
.hljs-deletion {
color: var(--hljs-literal);
}
.hljs-selector-tag,
.hljs-selector-id,
.hljs-selector-class {
color: var(--hljs-selector-tag);
}
.hljs-number {
color: var(--hljs-number);
}
.hljs-comment,
.hljs-meta,
.hljs-quote {
color: var(--hljs-comment);
}
.hljs-emphasis {
@apply italic;
}
.hljs-strong {
@apply font-bold;
}
}

View File

@@ -0,0 +1,82 @@
.minimal-tiptap-editor div.tiptap p {
@apply text-[var(--mt-font-size-regular)];
}
.minimal-tiptap-editor .ProseMirror ol {
@apply list-decimal;
}
.minimal-tiptap-editor .ProseMirror ol ol {
list-style: lower-alpha;
}
.minimal-tiptap-editor .ProseMirror ol ol ol {
list-style: lower-roman;
}
.minimal-tiptap-editor .ProseMirror ul {
list-style: disc;
}
.minimal-tiptap-editor .ProseMirror ul ul {
list-style: circle;
}
.minimal-tiptap-editor .ProseMirror ul ul ul {
list-style: square;
}
.minimal-tiptap-editor .ProseMirror ul[data-type='taskList'] {
@apply list-none pl-1;
}
.minimal-tiptap-editor .ProseMirror ul[data-type='taskList'] p {
@apply m-0;
}
.minimal-tiptap-editor .ProseMirror ul[data-type='taskList'] li > label {
@apply mr-2 mt-0.5 flex-none select-none;
}
.minimal-tiptap-editor .ProseMirror li[data-type='taskItem'] {
@apply flex flex-row items-start;
}
.minimal-tiptap-editor .ProseMirror li[data-type='taskItem'] .taskItem-checkbox-container {
@apply relative pr-2;
}
.minimal-tiptap-editor .ProseMirror .taskItem-drag-handle {
@apply absolute -left-5 top-1.5 h-[18px] w-[18px] cursor-move pl-0.5 text-[var(--mt-secondary)] opacity-0;
}
.minimal-tiptap-editor
.ProseMirror
li[data-type='taskItem']:hover:not(:has(li:hover))
> .taskItem-checkbox-container
> .taskItem-drag-handle {
@apply opacity-100;
}
.minimal-tiptap-editor .ProseMirror .taskItem-drag-handle:hover {
@apply text-[var(--mt-drag-handle-hover)];
}
.minimal-tiptap-editor .ProseMirror .taskItem-checkbox {
fill-opacity: 0;
@apply h-3.5 w-3.5 flex-shrink-0 cursor-pointer select-none appearance-none rounded border border-solid border-[var(--mt-secondary)] bg-transparent bg-[1px_2px] p-0.5 align-middle transition-colors duration-75 ease-out;
}
.minimal-tiptap-editor .ProseMirror .taskItem-checkbox:checked {
@apply border-primary bg-primary bg-no-repeat;
background-image: url('data:image/svg+xml;utf8,%3Csvg%20width=%2210%22%20height=%229%22%20viewBox=%220%200%2010%208%22%20xmlns=%22http://www.w3.org/2000/svg%22%20fill=%22%23fbfbfb%22%3E%3Cpath%20d=%22M3.46975%205.70757L1.88358%204.1225C1.65832%203.8974%201.29423%203.8974%201.06897%204.1225C0.843675%204.34765%200.843675%204.7116%201.06897%204.93674L3.0648%206.93117C3.29006%207.15628%203.65414%207.15628%203.8794%206.93117L8.93103%201.88306C9.15633%201.65792%209.15633%201.29397%208.93103%201.06883C8.70578%200.843736%208.34172%200.843724%208.11646%201.06879C8.11645%201.0688%208.11643%201.06882%208.11642%201.06883L3.46975%205.70757Z%22%20stroke-width=%220.2%22%20/%3E%3C/svg%3E');
}
.minimal-tiptap-editor .ProseMirror .taskItem-content {
@apply min-w-0 flex-1;
}
.minimal-tiptap-editor .ProseMirror li[data-checked='true'] .taskItem-content > :not([data-type='taskList']),
.minimal-tiptap-editor .ProseMirror li[data-checked='true'] .taskItem-content .taskItem-checkbox {
@apply opacity-75;
}

View File

@@ -0,0 +1,4 @@
.minimal-tiptap-editor .ProseMirror > p.is-editor-empty::before {
content: attr(data-placeholder);
@apply pointer-events-none float-left h-0 text-[var(--mt-secondary)];
}

View File

@@ -0,0 +1,27 @@
.minimal-tiptap-editor .ProseMirror .heading-node {
@apply relative font-semibold;
}
.minimal-tiptap-editor .ProseMirror .heading-node:first-child {
@apply mt-0;
}
.minimal-tiptap-editor .ProseMirror h1 {
@apply mb-4 mt-[46px] text-[1.375rem] leading-7 tracking-[-0.004375rem];
}
.minimal-tiptap-editor .ProseMirror h2 {
@apply mb-3.5 mt-8 text-[1.1875rem] leading-7 tracking-[0.003125rem];
}
.minimal-tiptap-editor .ProseMirror h3 {
@apply mb-3 mt-6 text-[1.0625rem] leading-6 tracking-[0.00625rem];
}
.minimal-tiptap-editor .ProseMirror a.link {
@apply cursor-pointer text-primary;
}
.minimal-tiptap-editor .ProseMirror a.link:hover {
@apply underline;
}

View File

@@ -0,0 +1,28 @@
import type { Editor } from '@tiptap/core'
import type { EditorView } from '@tiptap/pm/view'
import type { EditorState } from '@tiptap/pm/state'
export interface LinkProps {
url: string
text?: string
openInNewTab?: boolean
}
export interface ShouldShowProps {
editor: Editor
view: EditorView
state: EditorState
oldState?: EditorState
from: number
to: number
}
export interface FormatAction {
label: string
icon?: React.ReactNode
action: (editor: Editor) => void
isActive: (editor: Editor) => boolean
canExecute: (editor: Editor) => boolean
shortcuts: string[]
value: string
}

View File

@@ -0,0 +1,81 @@
import type { Editor } from '@tiptap/core'
import type { MinimalTiptapProps } from './minimal-tiptap'
let isMac: boolean | undefined
interface Navigator {
userAgentData?: {
brands: { brand: string; version: string }[]
mobile: boolean
platform: string
getHighEntropyValues: (hints: string[]) => Promise<{
platform: string
platformVersion: string
uaFullVersion: string
}>
}
}
function getPlatform(): string {
const nav = navigator as Navigator
if (nav.userAgentData) {
if (nav.userAgentData.platform) {
return nav.userAgentData.platform
}
nav.userAgentData.getHighEntropyValues(['platform']).then(highEntropyValues => {
if (highEntropyValues.platform) {
return highEntropyValues.platform
}
})
}
if (typeof navigator.platform === 'string') {
return navigator.platform
}
return ''
}
export function isMacOS() {
if (isMac === undefined) {
isMac = getPlatform().toLowerCase().includes('mac')
}
return isMac
}
interface ShortcutKeyResult {
symbol: string
readable: string
}
export function getShortcutKey(key: string): ShortcutKeyResult {
const lowercaseKey = key.toLowerCase()
if (lowercaseKey === 'mod') {
return isMacOS() ? { symbol: '⌘', readable: 'Command' } : { symbol: 'Ctrl', readable: 'Control' }
} else if (lowercaseKey === 'alt') {
return isMacOS() ? { symbol: '⌥', readable: 'Option' } : { symbol: 'Alt', readable: 'Alt' }
} else if (lowercaseKey === 'shift') {
return isMacOS() ? { symbol: '⇧', readable: 'Shift' } : { symbol: 'Shift', readable: 'Shift' }
} else {
return { symbol: key, readable: key }
}
}
export function getShortcutKeys(keys: string[]): ShortcutKeyResult[] {
return keys.map(key => getShortcutKey(key))
}
export function getOutput(editor: Editor, format: MinimalTiptapProps['output']) {
if (format === 'json') {
return editor.getJSON()
}
if (format === 'html') {
return editor.getText() ? editor.getHTML() : ''
}
return editor.getText()
}

View File

@@ -135,7 +135,6 @@ const SidebarActions = ({ page, handleDelete }: { page: PersonalPage; handleDele
)
const DetailPageForm = ({ page }: { page: PersonalPage }) => {
const { me } = useAccount()
const titleEditorRef = useRef<Editor | null>(null)
const contentEditorRef = useRef<LAEditorRef>(null)
const isTitleInitialMount = useRef(true)

View File

@@ -93,5 +93,6 @@ export {
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription
DialogDescription,
DialogPrimitive
}

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,13 @@
import { currentUser } from "@clerk/nextjs/server"
import { createServerActionProcedure, ZSAError } from "zsa"
export const authedProcedure = createServerActionProcedure()
.handler(async () => {
try {
const clerkUser = await currentUser()
return { clerkUser }
} catch {
throw new ZSAError("NOT_AUTHORIZED", "User not authenticated")
}
})
.createServerAction()

View File

@@ -9,7 +9,8 @@ const ROUTE_PATTERNS = {
"/profile(.*)",
"/search(.*)",
"/settings(.*)",
"/tauri(.*)"
"/tauri(.*)",
"/onboarding(.*)"
]
}

View File

@@ -31,7 +31,9 @@
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@tanstack/react-virtual": "^3.10.7",
"@tiptap/core": "^2.6.6",
@@ -40,6 +42,7 @@
"@tiptap/extension-bullet-list": "^2.6.6",
"@tiptap/extension-code": "^2.6.6",
"@tiptap/extension-code-block-lowlight": "^2.6.6",
"@tiptap/extension-color": "^2.6.6",
"@tiptap/extension-document": "^2.6.6",
"@tiptap/extension-dropcursor": "^2.6.6",
"@tiptap/extension-focus": "^2.6.6",
@@ -48,6 +51,7 @@
"@tiptap/extension-heading": "^2.6.6",
"@tiptap/extension-history": "^2.6.6",
"@tiptap/extension-horizontal-rule": "^2.6.6",
"@tiptap/extension-image": "^2.6.6",
"@tiptap/extension-italic": "^2.6.6",
"@tiptap/extension-link": "^2.6.6",
"@tiptap/extension-list-item": "^2.6.6",
@@ -61,6 +65,7 @@
"@tiptap/extension-typography": "^2.6.6",
"@tiptap/pm": "^2.6.6",
"@tiptap/react": "^2.6.6",
"@tiptap/starter-kit": "^2.6.6",
"@tiptap/suggestion": "^2.6.6",
"axios": "^1.7.7",
"cheerio": "1.0.0",
@@ -86,6 +91,7 @@
"react-hook-form": "^7.53.0",
"react-textarea-autosize": "^8.5.3",
"react-use": "^17.5.1",
"ronin": "^4.3.1",
"slugify": "^1.6.6",
"sonner": "^1.5.0",
"streaming-markdown": "^0.0.14",
@@ -93,9 +99,11 @@
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.2",
"zod": "^3.23.8",
"zsa": "^0.6.0"
"zsa": "^0.6.0",
"zsa-react": "^0.2.2"
},
"devDependencies": {
"@ronin/learn-anything": "^0.0.0-3451915138150",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.1",
"@types/jest": "^29.5.12",
@@ -112,6 +120,6 @@
"tailwindcss": "^3.4.10",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.5.4"
"typescript": "^5.6.2"
}
}

View File

@@ -20,7 +20,8 @@
],
"paths": {
"@/*": ["./*"]
}
},
"types": ["@ronin/learn-anything"]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "types/**/*.d.ts"],
"exclude": ["node_modules"]