mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
feat: feedback (#156)
* minimal tiptap * wip * img edit block * wip * fix
This commit is contained in:
@@ -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
2
web/.gitignore
vendored
@@ -34,3 +34,5 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
.ronin
|
||||
2
web/.npmrc
Normal file
2
web/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
[install.scopes]
|
||||
ronin = { url = "https://ronin.supply", token = "$RONIN_TOKEN" }
|
||||
71
web/app/actions.ts
Normal file
71
web/app/actions.ts
Normal 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")
|
||||
}
|
||||
})
|
||||
137
web/components/custom/sidebar/partial/feedback.tsx
Normal file
137
web/components/custom/sidebar/partial/feedback.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
17
web/components/custom/spinner.tsx
Normal file
17
web/components/custom/spinner.tsx
Normal 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"
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
84
web/components/minimal-tiptap/components/section/five.tsx
Normal file
84
web/components/minimal-tiptap/components/section/five.tsx
Normal 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
|
||||
73
web/components/minimal-tiptap/components/section/four.tsx
Normal file
73
web/components/minimal-tiptap/components/section/four.tsx
Normal 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
|
||||
137
web/components/minimal-tiptap/components/section/one.tsx
Normal file
137
web/components/minimal-tiptap/components/section/one.tsx
Normal 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
|
||||
191
web/components/minimal-tiptap/components/section/three.tsx
Normal file
191
web/components/minimal-tiptap/components/section/three.tsx
Normal 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
|
||||
100
web/components/minimal-tiptap/components/section/two.tsx
Normal file
100
web/components/minimal-tiptap/components/section/two.tsx
Normal 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
|
||||
33
web/components/minimal-tiptap/components/shortcut-key.tsx
Normal file
33
web/components/minimal-tiptap/components/shortcut-key.tsx
Normal 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'
|
||||
38
web/components/minimal-tiptap/components/toolbar-button.tsx
Normal file
38
web/components/minimal-tiptap/components/toolbar-button.tsx
Normal 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
|
||||
112
web/components/minimal-tiptap/components/toolbar-section.tsx
Normal file
112
web/components/minimal-tiptap/components/toolbar-section.tsx
Normal 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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
export * from './code-block-lowlight'
|
||||
20
web/components/minimal-tiptap/extensions/color/color.ts
Normal file
20
web/components/minimal-tiptap/extensions/color/color.ts
Normal 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
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
1
web/components/minimal-tiptap/extensions/color/index.ts
Normal file
1
web/components/minimal-tiptap/extensions/color/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './color'
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
export * from './horizontal-rule'
|
||||
@@ -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 }
|
||||
9
web/components/minimal-tiptap/extensions/image/image.ts
Normal file
9
web/components/minimal-tiptap/extensions/image/image.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
1
web/components/minimal-tiptap/extensions/image/index.ts
Normal file
1
web/components/minimal-tiptap/extensions/image/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './image'
|
||||
8
web/components/minimal-tiptap/extensions/index.ts
Normal file
8
web/components/minimal-tiptap/extensions/index.ts
Normal 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'
|
||||
1
web/components/minimal-tiptap/extensions/link/index.ts
Normal file
1
web/components/minimal-tiptap/extensions/link/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './link'
|
||||
89
web/components/minimal-tiptap/extensions/link/link.ts
Normal file
89
web/components/minimal-tiptap/extensions/link/link.ts
Normal 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
|
||||
@@ -0,0 +1 @@
|
||||
export * from './reset-marks-on-enter'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export * from './selection'
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
export * from './unset-all-marks'
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
|
||||
export const UnsetAllMarks = Extension.create({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
'Mod-\\': () => this.editor.commands.unsetAllMarks()
|
||||
}
|
||||
}
|
||||
})
|
||||
15
web/components/minimal-tiptap/hooks/use-image-load.ts
Normal file
15
web/components/minimal-tiptap/hooks/use-image-load.ts
Normal 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
|
||||
}
|
||||
107
web/components/minimal-tiptap/hooks/use-minimal-tiptap.ts
Normal file
107
web/components/minimal-tiptap/hooks/use-minimal-tiptap.ts
Normal 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
|
||||
25
web/components/minimal-tiptap/hooks/use-theme.ts
Normal file
25
web/components/minimal-tiptap/hooks/use-theme.ts
Normal 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
|
||||
34
web/components/minimal-tiptap/hooks/use-throttle.ts
Normal file
34
web/components/minimal-tiptap/hooks/use-throttle.ts
Normal 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]
|
||||
)
|
||||
}
|
||||
1
web/components/minimal-tiptap/index.ts
Normal file
1
web/components/minimal-tiptap/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './minimal-tiptap'
|
||||
95
web/components/minimal-tiptap/minimal-tiptap.tsx
Normal file
95
web/components/minimal-tiptap/minimal-tiptap.tsx
Normal 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
|
||||
182
web/components/minimal-tiptap/styles/index.css
Normal file
182
web/components/minimal-tiptap/styles/index.css
Normal 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;
|
||||
}
|
||||
86
web/components/minimal-tiptap/styles/partials/code.css
Normal file
86
web/components/minimal-tiptap/styles/partials/code.css
Normal 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;
|
||||
}
|
||||
}
|
||||
82
web/components/minimal-tiptap/styles/partials/lists.css
Normal file
82
web/components/minimal-tiptap/styles/partials/lists.css
Normal 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;
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
27
web/components/minimal-tiptap/styles/partials/typography.css
Normal file
27
web/components/minimal-tiptap/styles/partials/typography.css
Normal 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;
|
||||
}
|
||||
28
web/components/minimal-tiptap/types.ts
Normal file
28
web/components/minimal-tiptap/types.ts
Normal 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
|
||||
}
|
||||
81
web/components/minimal-tiptap/utils.ts
Normal file
81
web/components/minimal-tiptap/utils.ts
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -93,5 +93,6 @@ export {
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription
|
||||
DialogDescription,
|
||||
DialogPrimitive
|
||||
}
|
||||
|
||||
29
web/components/ui/switch.tsx
Normal file
29
web/components/ui/switch.tsx
Normal 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 }
|
||||
61
web/components/ui/toggle-group.tsx
Normal file
61
web/components/ui/toggle-group.tsx
Normal 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 }
|
||||
13
web/lib/utils/auth-procedure.ts
Normal file
13
web/lib/utils/auth-procedure.ts
Normal 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()
|
||||
@@ -9,7 +9,8 @@ const ROUTE_PATTERNS = {
|
||||
"/profile(.*)",
|
||||
"/search(.*)",
|
||||
"/settings(.*)",
|
||||
"/tauri(.*)"
|
||||
"/tauri(.*)",
|
||||
"/onboarding(.*)"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user