diff --git a/web/.env.example b/web/.env.example index e9474cf47..8587bc0c7 100644 --- a/web/.env.example +++ b/web/.env.example @@ -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 \ No newline at end of file diff --git a/web/.gitignore b/web/.gitignore index fd3dbb571..5083fca00 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -34,3 +34,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +.ronin \ No newline at end of file diff --git a/web/.npmrc b/web/.npmrc new file mode 100644 index 000000000..2806c9c25 --- /dev/null +++ b/web/.npmrc @@ -0,0 +1,2 @@ +[install.scopes] +ronin = { url = "https://ronin.supply", token = "$RONIN_TOKEN" } \ No newline at end of file diff --git a/web/app/actions.ts b/web/app/actions.ts new file mode 100644 index 000000000..b3d3d04d3 --- /dev/null +++ b/web/app/actions.ts @@ -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 => { + 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") + } + }) diff --git a/web/components/custom/sidebar/partial/feedback.tsx b/web/components/custom/sidebar/partial/feedback.tsx new file mode 100644 index 000000000..038c253c1 --- /dev/null +++ b/web/components/custom/sidebar/partial/feedback.tsx @@ -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(null) + const { isPending, execute } = useServerAction(sendFeedback) + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + content: "" + } + }) + + async function onSubmit(values: z.infer) { + 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 ( + + + + + + + + +
+ + + Share feedback + + Your feedback helps us improve. Please share your thoughts, ideas, and suggestions + + + + ( + + Content + + + + + + )} + /> + + + Cancel + + + + +
+
+
+ ) +} diff --git a/web/components/custom/sidebar/partial/profile-section.tsx b/web/components/custom/sidebar/partial/profile-section.tsx index 76c8653e8..adc87fb8c 100644 --- a/web/components/custom/sidebar/partial/profile-section.tsx +++ b/web/components/custom/sidebar/partial/profile-section.tsx @@ -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() @@ -84,6 +85,8 @@ export const ProfileSection: React.FC = () => { + + ) diff --git a/web/components/custom/spinner.tsx b/web/components/custom/spinner.tsx new file mode 100644 index 000000000..cf164b81a --- /dev/null +++ b/web/components/custom/spinner.tsx @@ -0,0 +1,17 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +interface SpinnerProps extends React.SVGAttributes {} + +export const Spinner = React.forwardRef(({ className, ...props }, ref) => ( + + + + +)) + +Spinner.displayName = "Spinner" diff --git a/web/components/minimal-tiptap/components/bubble-menu/image-bubble-menu.tsx b/web/components/minimal-tiptap/components/bubble-menu/image-bubble-menu.tsx new file mode 100644 index 000000000..cae3243e7 --- /dev/null +++ b/web/components/minimal-tiptap/components/bubble-menu/image-bubble-menu.tsx @@ -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 ( + + + + ) +} + +export { ImageBubbleMenu } diff --git a/web/components/minimal-tiptap/components/bubble-menu/link-bubble-menu.tsx b/web/components/minimal-tiptap/components/bubble-menu/link-bubble-menu.tsx new file mode 100644 index 000000000..53aa107be --- /dev/null +++ b/web/components/minimal-tiptap/components/bubble-menu/link-bubble-menu.tsx @@ -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 = ({ editor }) => { + const [showEdit, setShowEdit] = useState(false) + const [linkAttrs, setLinkAttrs] = useState({ 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 ( + setShowEdit(false) + }} + > + {showEdit ? ( + + ) : ( + + )} + + ) +} diff --git a/web/components/minimal-tiptap/components/image/image-edit-block.tsx b/web/components/minimal-tiptap/components/image/image-edit-block.tsx new file mode 100644 index 000000000..f8786b8bb --- /dev/null +++ b/web/components/minimal-tiptap/components/image/image-edit-block.tsx @@ -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 { + editor: Editor + close: () => void +} + +const ImageEditBlock = ({ editor, className, close, ...props }: ImageEditBlockProps) => { + const fileInputRef = useRef(null) + const [link, setLink] = useState("") + const [isUploading, setIsUploading] = useState(false) + const [error, setError] = useState(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) => { + 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 ( +
+
+
+ +
+ setLink(e.target.value)} + /> + +
+
+ + + {error &&
{error}
} +
+
+ ) +} + +export { ImageEditBlock } diff --git a/web/components/minimal-tiptap/components/image/image-edit-dialog.tsx b/web/components/minimal-tiptap/components/image/image-edit-dialog.tsx new file mode 100644 index 000000000..68b10e07b --- /dev/null +++ b/web/components/minimal-tiptap/components/image/image-edit-dialog.tsx @@ -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 { + editor: Editor +} + +const ImageEditDialog = ({ editor, size, variant }: ImageEditDialogProps) => { + const [open, setOpen] = useState(false) + + return ( + + + + + + + + + Select image + Upload an image from your computer + + setOpen(false)} /> + + + ) +} + +export { ImageEditDialog } diff --git a/web/components/minimal-tiptap/components/image/image-popover-block.tsx b/web/components/minimal-tiptap/components/image/image-popover-block.tsx new file mode 100644 index 000000000..28fef8d78 --- /dev/null +++ b/web/components/minimal-tiptap/components/image/image-popover-block.tsx @@ -0,0 +1,21 @@ +import { ToolbarButton } from '../toolbar-button' +import { TrashIcon } from '@radix-ui/react-icons' + +const ImagePopoverBlock = ({ onRemove }: { onRemove: (e: React.MouseEvent) => void }) => { + const handleRemove = (e: React.MouseEvent) => { + e.preventDefault() + onRemove(e) + } + + return ( +
+
+ + + +
+
+ ) +} + +export { ImagePopoverBlock } diff --git a/web/components/minimal-tiptap/components/link/link-edit-block.tsx b/web/components/minimal-tiptap/components/link/link-edit-block.tsx new file mode 100644 index 000000000..d42bc8c1e --- /dev/null +++ b/web/components/minimal-tiptap/components/link/link-edit-block.tsx @@ -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 { + defaultUrl?: string + defaultText?: string + defaultIsNewTab?: boolean + onSave: (url: string, text?: string, isNewTab?: boolean) => void +} + +export const LinkEditBlock = React.forwardRef( + ({ onSave, defaultIsNewTab, defaultUrl, defaultText, className }, ref) => { + const formRef = React.useRef(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 ( +
+
+
+ + setUrl(e.target.value)} /> +
+ +
+ + setText(e.target.value)} /> +
+ +
+ + +
+ +
+ +
+
+
+ ) + } +) + +LinkEditBlock.displayName = 'LinkEditBlock' + +export default LinkEditBlock diff --git a/web/components/minimal-tiptap/components/link/link-edit-popover.tsx b/web/components/minimal-tiptap/components/link/link-edit-popover.tsx new file mode 100644 index 000000000..90fc04673 --- /dev/null +++ b/web/components/minimal-tiptap/components/link/link-edit-popover.tsx @@ -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 { + 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 ( + + + + + + + + + + + ) +} + +export { LinkEditPopover } diff --git a/web/components/minimal-tiptap/components/link/link-popover-block.tsx b/web/components/minimal-tiptap/components/link/link-popover-block.tsx new file mode 100644 index 000000000..7d4954399 --- /dev/null +++ b/web/components/minimal-tiptap/components/link/link-popover-block.tsx @@ -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) => void +} + +export const LinkPopoverBlock: React.FC = ({ url, onClear, onEdit }) => { + const [copyTitle, setCopyTitle] = useState('Copy') + + const handleCopy = useCallback( + (e: React.MouseEvent) => { + 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 ( +
+
+ + Edit link + + + + + + + + + + + { + if (e.target === e.currentTarget) e.preventDefault() + } + }} + > + + +
+
+ ) +} diff --git a/web/components/minimal-tiptap/components/section/five.tsx b/web/components/minimal-tiptap/components/section/five.tsx new file mode 100644 index 000000000..606485c07 --- /dev/null +++ b/web/components/minimal-tiptap/components/section/five.tsx @@ -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: , + 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: , + 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: , + action: editor => editor.chain().focus().setHorizontalRule().run(), + isActive: () => false, + canExecute: editor => editor.can().chain().focus().setHorizontalRule().run(), + shortcuts: ['mod', 'alt', '-'] + } +] + +interface SectionFiveProps extends VariantProps { + editor: Editor + activeActions?: InsertElementAction[] + mainActionCount?: number +} + +export const SectionFive: React.FC = ({ + editor, + activeActions = formatActions.map(action => action.value), + mainActionCount = 0, + size, + variant +}) => { + return ( + <> + + + + + + + } + dropdownTooltip="Insert elements" + size={size} + variant={variant} + /> + + ) +} + +SectionFive.displayName = 'SectionFive' + +export default SectionFive diff --git a/web/components/minimal-tiptap/components/section/four.tsx b/web/components/minimal-tiptap/components/section/four.tsx new file mode 100644 index 000000000..2686c635e --- /dev/null +++ b/web/components/minimal-tiptap/components/section/four.tsx @@ -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: ( + + + + ), + 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: , + 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 { + editor: Editor + activeActions?: ListItemAction[] + mainActionCount?: number +} + +export const SectionFour: React.FC = ({ + editor, + activeActions = formatActions.map(action => action.value), + mainActionCount = 0, + size, + variant +}) => { + return ( + + + + + } + dropdownTooltip="Lists" + size={size} + variant={variant} + /> + ) +} + +SectionFour.displayName = 'SectionFour' + +export default SectionFour diff --git a/web/components/minimal-tiptap/components/section/one.tsx b/web/components/minimal-tiptap/components/section/one.tsx new file mode 100644 index 000000000..85c41d8a6 --- /dev/null +++ b/web/components/minimal-tiptap/components/section/one.tsx @@ -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 { + 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 { + editor: Editor + activeLevels?: Level[] +} + +export const SectionOne: React.FC = 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) => ( + 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} + > + {label} + + + ), + [editor, handleStyleChange] + ) + + return ( + + + + + + + + + {filteredActions.map(renderMenuItem)} + + + ) + } +) + +SectionOne.displayName = 'SectionOne' + +export default SectionOne diff --git a/web/components/minimal-tiptap/components/section/three.tsx b/web/components/minimal-tiptap/components/section/three.tsx new file mode 100644 index 000000000..3b724569d --- /dev/null +++ b/web/components/minimal-tiptap/components/section/three.tsx @@ -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 ( + + + ) => { + e.preventDefault() + onClick(color.cssVar) + }} + > + {isSelected && } + + + +

{label}

+
+
+ ) +}) + +MemoizedColorButton.displayName = 'MemoizedColorButton' + +const MemoizedColorPicker = React.memo<{ + palette: ColorPalette + selectedColor: string + inverse: string + onColorChange: (value: string) => void +}>(({ palette, selectedColor, inverse, onColorChange }) => ( + { + if (value) onColorChange(value) + }} + className="gap-1.5" + > + {palette.colors.map((color, index) => ( + + ))} + +)) + +MemoizedColorPicker.displayName = 'MemoizedColorPicker' + +interface SectionThreeProps extends VariantProps { + editor: Editor +} + +export const SectionThree: React.FC = ({ 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 ( + + + + + + + + + + + + +
+ {COLORS.map((palette, index) => ( + + ))} +
+
+
+ ) +} + +SectionThree.displayName = 'SectionThree' + +export default SectionThree diff --git a/web/components/minimal-tiptap/components/section/two.tsx b/web/components/minimal-tiptap/components/section/two.tsx new file mode 100644 index 000000000..3515e6309 --- /dev/null +++ b/web/components/minimal-tiptap/components/section/two.tsx @@ -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: , + 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: , + 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: , + 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: , + 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: , + 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 { + editor: Editor + activeActions?: TextStyleAction[] + mainActionCount?: number +} + +export const SectionTwo: React.FC = ({ + editor, + activeActions = formatActions.map(action => action.value), + mainActionCount = 2, + size, + variant +}) => { + return ( + } + dropdownTooltip="More formatting" + dropdownClassName="w-8" + size={size} + variant={variant} + /> + ) +} + +SectionTwo.displayName = 'SectionTwo' + +export default SectionTwo diff --git a/web/components/minimal-tiptap/components/shortcut-key.tsx b/web/components/minimal-tiptap/components/shortcut-key.tsx new file mode 100644 index 000000000..2691528db --- /dev/null +++ b/web/components/minimal-tiptap/components/shortcut-key.tsx @@ -0,0 +1,33 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' +import { getShortcutKey } from '../utils' + +export interface ShortcutKeyProps extends React.HTMLAttributes { + keys: string[] +} + +export const ShortcutKey = React.forwardRef(({ className, keys, ...props }, ref) => { + const modifiedKeys = keys.map(key => getShortcutKey(key)) + const ariaLabel = modifiedKeys.map(shortcut => shortcut.readable).join(' + ') + + return ( + + {modifiedKeys.map(shortcut => ( + + {shortcut.symbol} + + ))} + + ) +}) + +ShortcutKey.displayName = 'ShortcutKey' diff --git a/web/components/minimal-tiptap/components/toolbar-button.tsx b/web/components/minimal-tiptap/components/toolbar-button.tsx new file mode 100644 index 000000000..5fd9cf402 --- /dev/null +++ b/web/components/minimal-tiptap/components/toolbar-button.tsx @@ -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 { + isActive?: boolean + tooltip?: string + tooltipOptions?: TooltipContentProps +} + +export const ToolbarButton = React.forwardRef( + ({ isActive, children, tooltip, className, tooltipOptions, ...props }, ref) => { + const toggleButton = ( + + {children} + + ) + + if (!tooltip) { + return toggleButton + } + + return ( + + {toggleButton} + +
{tooltip}
+
+
+ ) + } +) + +ToolbarButton.displayName = 'ToolbarButton' + +export default ToolbarButton diff --git a/web/components/minimal-tiptap/components/toolbar-section.tsx b/web/components/minimal-tiptap/components/toolbar-section.tsx new file mode 100644 index 000000000..e296fd178 --- /dev/null +++ b/web/components/minimal-tiptap/components/toolbar-section.tsx @@ -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 { + editor: Editor + actions: FormatAction[] + activeActions?: string[] + mainActionCount?: number + dropdownIcon?: React.ReactNode + dropdownTooltip?: string + dropdownClassName?: string +} + +export const ToolbarSection: React.FC = ({ + 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) => ( + 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} + + ), + [editor, size, variant] + ) + + const renderDropdownMenuItem = React.useCallback( + (action: FormatAction) => ( + 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} + > + {action.label} + + + ), + [editor] + ) + + const isDropdownActive = React.useMemo( + () => dropdownActions.some(action => action.isActive(editor)), + [dropdownActions, editor] + ) + + return ( + <> + {mainActions.map(renderToolbarButton)} + {dropdownActions.length > 0 && ( + + + + {dropdownIcon || } + + + + {dropdownActions.map(renderDropdownMenuItem)} + + + )} + + ) +} + +export default ToolbarSection diff --git a/web/components/minimal-tiptap/extensions/code-block-lowlight/code-block-lowlight.ts b/web/components/minimal-tiptap/extensions/code-block-lowlight/code-block-lowlight.ts new file mode 100644 index 000000000..545236683 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/code-block-lowlight/code-block-lowlight.ts @@ -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 diff --git a/web/components/minimal-tiptap/extensions/code-block-lowlight/index.ts b/web/components/minimal-tiptap/extensions/code-block-lowlight/index.ts new file mode 100644 index 000000000..9ded0403d --- /dev/null +++ b/web/components/minimal-tiptap/extensions/code-block-lowlight/index.ts @@ -0,0 +1 @@ +export * from './code-block-lowlight' diff --git a/web/components/minimal-tiptap/extensions/color/color.ts b/web/components/minimal-tiptap/extensions/color/color.ts new file mode 100644 index 000000000..9582d38b9 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/color/color.ts @@ -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 + } + } + }) + ] + } +}) diff --git a/web/components/minimal-tiptap/extensions/color/index.ts b/web/components/minimal-tiptap/extensions/color/index.ts new file mode 100644 index 000000000..1315dbcf6 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/color/index.ts @@ -0,0 +1 @@ +export * from './color' diff --git a/web/components/minimal-tiptap/extensions/horizontal-rule/horizontal-rule.ts b/web/components/minimal-tiptap/extensions/horizontal-rule/horizontal-rule.ts new file mode 100644 index 000000000..c530d4f53 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/horizontal-rule/horizontal-rule.ts @@ -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 diff --git a/web/components/minimal-tiptap/extensions/horizontal-rule/index.ts b/web/components/minimal-tiptap/extensions/horizontal-rule/index.ts new file mode 100644 index 000000000..e6cb8015e --- /dev/null +++ b/web/components/minimal-tiptap/extensions/horizontal-rule/index.ts @@ -0,0 +1 @@ +export * from './horizontal-rule' diff --git a/web/components/minimal-tiptap/extensions/image/components/image-view-block.tsx b/web/components/minimal-tiptap/extensions/image/components/image-view-block.tsx new file mode 100644 index 000000000..522569b4f --- /dev/null +++ b/web/components/minimal-tiptap/extensions/image/components/image-view-block.tsx @@ -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 ( + +
+
+
+
+
+
+ {node.attrs.alt} +
+
+
+
+
+
+
+ ) +} + +export { ImageViewBlock } diff --git a/web/components/minimal-tiptap/extensions/image/image.ts b/web/components/minimal-tiptap/extensions/image/image.ts new file mode 100644 index 000000000..12784fb54 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/image/image.ts @@ -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) + } +}) diff --git a/web/components/minimal-tiptap/extensions/image/index.ts b/web/components/minimal-tiptap/extensions/image/index.ts new file mode 100644 index 000000000..556dbfdf8 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/image/index.ts @@ -0,0 +1 @@ +export * from './image' diff --git a/web/components/minimal-tiptap/extensions/index.ts b/web/components/minimal-tiptap/extensions/index.ts new file mode 100644 index 000000000..63f21fa73 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/index.ts @@ -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' diff --git a/web/components/minimal-tiptap/extensions/link/index.ts b/web/components/minimal-tiptap/extensions/link/index.ts new file mode 100644 index 000000000..6bbafd208 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/link/index.ts @@ -0,0 +1 @@ +export * from './link' diff --git a/web/components/minimal-tiptap/extensions/link/link.ts b/web/components/minimal-tiptap/extensions/link/link.ts new file mode 100644 index 000000000..1db71b95e --- /dev/null +++ b/web/components/minimal-tiptap/extensions/link/link.ts @@ -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 elements that have an href attribute, except for: + * - elements with a data-type attribute set to button + * - 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 diff --git a/web/components/minimal-tiptap/extensions/reset-marks-on-enter/index.ts b/web/components/minimal-tiptap/extensions/reset-marks-on-enter/index.ts new file mode 100644 index 000000000..f514cdd12 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/reset-marks-on-enter/index.ts @@ -0,0 +1 @@ +export * from './reset-marks-on-enter' diff --git a/web/components/minimal-tiptap/extensions/reset-marks-on-enter/reset-marks-on-enter.ts b/web/components/minimal-tiptap/extensions/reset-marks-on-enter/reset-marks-on-enter.ts new file mode 100644 index 000000000..e97707109 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/reset-marks-on-enter/reset-marks-on-enter.ts @@ -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 + } + } + } +}) diff --git a/web/components/minimal-tiptap/extensions/selection/index.ts b/web/components/minimal-tiptap/extensions/selection/index.ts new file mode 100644 index 000000000..75df11a69 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/selection/index.ts @@ -0,0 +1 @@ +export * from './selection' diff --git a/web/components/minimal-tiptap/extensions/selection/selection.ts b/web/components/minimal-tiptap/extensions/selection/selection.ts new file mode 100644 index 000000000..7e28ac2f2 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/selection/selection.ts @@ -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 diff --git a/web/components/minimal-tiptap/extensions/unset-all-marks/index.ts b/web/components/minimal-tiptap/extensions/unset-all-marks/index.ts new file mode 100644 index 000000000..50d26fbc8 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/unset-all-marks/index.ts @@ -0,0 +1 @@ +export * from './unset-all-marks' diff --git a/web/components/minimal-tiptap/extensions/unset-all-marks/unset-all-marks.ts b/web/components/minimal-tiptap/extensions/unset-all-marks/unset-all-marks.ts new file mode 100644 index 000000000..6aa644697 --- /dev/null +++ b/web/components/minimal-tiptap/extensions/unset-all-marks/unset-all-marks.ts @@ -0,0 +1,9 @@ +import { Extension } from '@tiptap/core' + +export const UnsetAllMarks = Extension.create({ + addKeyboardShortcuts() { + return { + 'Mod-\\': () => this.editor.commands.unsetAllMarks() + } + } +}) diff --git a/web/components/minimal-tiptap/hooks/use-image-load.ts b/web/components/minimal-tiptap/hooks/use-image-load.ts new file mode 100644 index 000000000..463efb1e8 --- /dev/null +++ b/web/components/minimal-tiptap/hooks/use-image-load.ts @@ -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 +} diff --git a/web/components/minimal-tiptap/hooks/use-minimal-tiptap.ts b/web/components/minimal-tiptap/hooks/use-minimal-tiptap.ts new file mode 100644 index 000000000..ed89b6753 --- /dev/null +++ b/web/components/minimal-tiptap/hooks/use-minimal-tiptap.ts @@ -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 diff --git a/web/components/minimal-tiptap/hooks/use-theme.ts b/web/components/minimal-tiptap/hooks/use-theme.ts new file mode 100644 index 000000000..9bb816b63 --- /dev/null +++ b/web/components/minimal-tiptap/hooks/use-theme.ts @@ -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 diff --git a/web/components/minimal-tiptap/hooks/use-throttle.ts b/web/components/minimal-tiptap/hooks/use-throttle.ts new file mode 100644 index 000000000..f3f88fba5 --- /dev/null +++ b/web/components/minimal-tiptap/hooks/use-throttle.ts @@ -0,0 +1,34 @@ +import { useRef, useCallback } from 'react' + +export function useThrottle void>( + callback: T, + delay: number +): (...args: Parameters) => void { + const lastRan = useRef(Date.now()) + const timeoutRef = useRef(null) + + return useCallback( + (...args: Parameters) => { + 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] + ) +} diff --git a/web/components/minimal-tiptap/index.ts b/web/components/minimal-tiptap/index.ts new file mode 100644 index 000000000..1532aab83 --- /dev/null +++ b/web/components/minimal-tiptap/index.ts @@ -0,0 +1 @@ +export * from './minimal-tiptap' diff --git a/web/components/minimal-tiptap/minimal-tiptap.tsx b/web/components/minimal-tiptap/minimal-tiptap.tsx new file mode 100644 index 000000000..668dde796 --- /dev/null +++ b/web/components/minimal-tiptap/minimal-tiptap.tsx @@ -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 { + value?: Content + onChange?: (value: Content) => void + className?: string + editorContentClassName?: string +} + +const Toolbar = ({ editor }: { editor: Editor }) => ( +
+
+ + + + + + + + + + + + + + + + + +
+
+) + +export type MinimalTiptapEditorRef = { + editor: Editor | null +} + +export const MinimalTiptapEditor = React.forwardRef( + ({ 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 ( +
+ + + + +
+ ) + } +) + +MinimalTiptapEditor.displayName = "MinimalTiptapEditor" + +export default MinimalTiptapEditor diff --git a/web/components/minimal-tiptap/styles/index.css b/web/components/minimal-tiptap/styles/index.css new file mode 100644 index 000000000..7121c31ab --- /dev/null +++ b/web/components/minimal-tiptap/styles/index.css @@ -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; +} diff --git a/web/components/minimal-tiptap/styles/partials/code.css b/web/components/minimal-tiptap/styles/partials/code.css new file mode 100644 index 000000000..b1d03ea7c --- /dev/null +++ b/web/components/minimal-tiptap/styles/partials/code.css @@ -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; + } +} diff --git a/web/components/minimal-tiptap/styles/partials/lists.css b/web/components/minimal-tiptap/styles/partials/lists.css new file mode 100644 index 000000000..f4d75a68e --- /dev/null +++ b/web/components/minimal-tiptap/styles/partials/lists.css @@ -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; +} diff --git a/web/components/minimal-tiptap/styles/partials/placeholder.css b/web/components/minimal-tiptap/styles/partials/placeholder.css new file mode 100644 index 000000000..04bcfdf02 --- /dev/null +++ b/web/components/minimal-tiptap/styles/partials/placeholder.css @@ -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)]; +} diff --git a/web/components/minimal-tiptap/styles/partials/typography.css b/web/components/minimal-tiptap/styles/partials/typography.css new file mode 100644 index 000000000..a1f753b79 --- /dev/null +++ b/web/components/minimal-tiptap/styles/partials/typography.css @@ -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; +} diff --git a/web/components/minimal-tiptap/types.ts b/web/components/minimal-tiptap/types.ts new file mode 100644 index 000000000..fe06c8e00 --- /dev/null +++ b/web/components/minimal-tiptap/types.ts @@ -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 +} diff --git a/web/components/minimal-tiptap/utils.ts b/web/components/minimal-tiptap/utils.ts new file mode 100644 index 000000000..d8772d844 --- /dev/null +++ b/web/components/minimal-tiptap/utils.ts @@ -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() +} diff --git a/web/components/routes/page/detail/PageDetailRoute.tsx b/web/components/routes/page/detail/PageDetailRoute.tsx index ca075c360..8b9fe1eb5 100644 --- a/web/components/routes/page/detail/PageDetailRoute.tsx +++ b/web/components/routes/page/detail/PageDetailRoute.tsx @@ -103,7 +103,6 @@ const SidebarActions = ({ page, handleDelete }: { page: PersonalPage; handleDele ) const DetailPageForm = ({ page }: { page: PersonalPage }) => { - const { me } = useAccount() const titleEditorRef = useRef(null) const contentEditorRef = useRef(null) const isTitleInitialMount = useRef(true) diff --git a/web/components/ui/dialog.tsx b/web/components/ui/dialog.tsx index 2c0239145..dbc51d921 100644 --- a/web/components/ui/dialog.tsx +++ b/web/components/ui/dialog.tsx @@ -93,5 +93,6 @@ export { DialogHeader, DialogFooter, DialogTitle, - DialogDescription + DialogDescription, + DialogPrimitive } diff --git a/web/components/ui/switch.tsx b/web/components/ui/switch.tsx new file mode 100644 index 000000000..5f4117f0f --- /dev/null +++ b/web/components/ui/switch.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/web/components/ui/toggle-group.tsx b/web/components/ui/toggle-group.tsx new file mode 100644 index 000000000..1c876bbee --- /dev/null +++ b/web/components/ui/toggle-group.tsx @@ -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 +>({ + size: "default", + variant: "default", +}) + +const ToggleGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, children, ...props }, ref) => ( + + + {children} + + +)) + +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName + +const ToggleGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, children, variant, size, ...props }, ref) => { + const context = React.useContext(ToggleGroupContext) + + return ( + + {children} + + ) +}) + +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName + +export { ToggleGroup, ToggleGroupItem } diff --git a/web/lib/utils/auth-procedure.ts b/web/lib/utils/auth-procedure.ts new file mode 100644 index 000000000..28347b64c --- /dev/null +++ b/web/lib/utils/auth-procedure.ts @@ -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() diff --git a/web/middleware.ts b/web/middleware.ts index 90c011b9a..9081e8469 100644 --- a/web/middleware.ts +++ b/web/middleware.ts @@ -9,7 +9,8 @@ const ROUTE_PATTERNS = { "/profile(.*)", "/search(.*)", "/settings(.*)", - "/tauri(.*)" + "/tauri(.*)", + "/onboarding(.*)" ] } diff --git a/web/package.json b/web/package.json index 5aec67fd5..5ffce2d26 100644 --- a/web/package.json +++ b/web/package.json @@ -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" } } diff --git a/web/tsconfig.json b/web/tsconfig.json index 581de8fa0..bc715948f 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -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"]