diff --git a/app/(main)/(auth)/_components/SignInForm.tsx b/app/(main)/(auth)/_components/SignInForm.tsx index ebb80434..b5cfee93 100644 --- a/app/(main)/(auth)/_components/SignInForm.tsx +++ b/app/(main)/(auth)/_components/SignInForm.tsx @@ -1,9 +1,9 @@ -import { login } from '~/server/actions/auth'; -import { Input } from '~/components/ui/form/Input'; -import { SubmitButton } from '~/components/ui/form/SubmitButton'; -import Form from '~/components/ui/form/Form'; import { getTranslations } from 'next-intl/server'; +import Form from '~/components/form/Form'; +import { Input } from '~/components/form/Input'; +import { SubmitButton } from '~/components/form/SubmitButton'; import Link from '~/components/Link'; +import { login } from '~/server/actions/auth'; export default async function SignInForm() { const t = await getTranslations('Auth'); diff --git a/app/(main)/(auth)/_components/SignUpForm.tsx b/app/(main)/(auth)/_components/SignUpForm.tsx index 93c74678..fad7d1f2 100644 --- a/app/(main)/(auth)/_components/SignUpForm.tsx +++ b/app/(main)/(auth)/_components/SignUpForm.tsx @@ -1,11 +1,11 @@ 'use client'; +import { useTranslations } from 'next-intl'; import { useFormState } from 'react-dom'; +import Form from '~/components/form/Form'; +import { Input } from '~/components/form/Input'; +import { SubmitButton } from '~/components/form/SubmitButton'; import { signup } from '~/server/actions/auth'; -import { Input } from '~/components/ui/form/Input'; -import { useTranslations } from 'next-intl'; -import { SubmitButton } from '~/components/ui/form/SubmitButton'; -import Form from '~/components/ui/form/Form'; export default function SignUpForm() { const [formState, formAction] = useFormState(signup, { diff --git a/app/(main)/(dashboard)/_components/CreateStudyForm.tsx b/app/(main)/(dashboard)/_components/CreateStudyForm.tsx index e54d79a7..c73fbbf4 100644 --- a/app/(main)/(dashboard)/_components/CreateStudyForm.tsx +++ b/app/(main)/(dashboard)/_components/CreateStudyForm.tsx @@ -1,17 +1,17 @@ -import { createStudy } from '~/server/actions/study'; +import { Role } from '@prisma/client'; import { getTranslations } from 'next-intl/server'; -import { SubmitButton } from '~/components/ui/form/SubmitButton'; -import { Input } from '~/components/ui/form/Input'; +import Form from '~/components/form/Form'; +import { Input } from '~/components/form/Input'; +import { SubmitButton } from '~/components/form/SubmitButton'; import Section from '~/components/layout/Section'; import { Select, SelectContent, - SelectValue, - SelectTrigger, SelectItem, -} from '~/components/ui/select'; -import { Role } from '@prisma/client'; -import Form from '~/components/ui/form/Form'; + SelectTrigger, + SelectValue, +} from '~/components/select'; +import { createStudy } from '~/server/actions/study'; export default async function CreateStudyForm() { const t = await getTranslations('Components.CreateStudyForm'); diff --git a/app/(main)/(dashboard)/_components/SignOutBtn.tsx b/app/(main)/(dashboard)/_components/SignOutBtn.tsx index 49f7c985..4396b774 100644 --- a/app/(main)/(dashboard)/_components/SignOutBtn.tsx +++ b/app/(main)/(dashboard)/_components/SignOutBtn.tsx @@ -1,9 +1,8 @@ 'use client'; -import React from 'react'; -import { logout } from '~/server/actions/auth'; import { useTranslations } from 'next-intl'; -import { SubmitButton } from '~/components/ui/form/SubmitButton'; +import { SubmitButton } from '~/components/form/SubmitButton'; +import { logout } from '~/server/actions/auth'; export default function SignOutBtn() { const t = useTranslations('Auth.SignOut'); diff --git a/app/(main)/(dashboard)/_components/StudySwitcherClient.tsx b/app/(main)/(dashboard)/_components/StudySwitcherClient.tsx index c3d73afd..d2cfc092 100644 --- a/app/(main)/(dashboard)/_components/StudySwitcherClient.tsx +++ b/app/(main)/(dashboard)/_components/StudySwitcherClient.tsx @@ -1,8 +1,9 @@ 'use client'; import { type Study } from '@prisma/client'; -import { useParams, useRouter } from 'next/navigation'; import { useTranslations } from 'next-intl'; +import { useParams, useRouter } from 'next/navigation'; +import { route } from 'nextjs-routes'; import { useEffect, useState } from 'react'; import { Select, @@ -10,8 +11,7 @@ import { SelectItem, SelectTrigger, SelectValue, -} from '~/components/ui/select'; -import { route } from 'nextjs-routes'; +} from '~/components/select'; export default function StudySwitcherClient({ studies }: { studies: Study[] }) { const t = useTranslations('Components.StudySwitcher'); diff --git a/app/(main)/(dashboard)/layout.tsx b/app/(main)/(dashboard)/layout.tsx index 20fba013..2628fbba 100644 --- a/app/(main)/(dashboard)/layout.tsx +++ b/app/(main)/(dashboard)/layout.tsx @@ -1,12 +1,12 @@ import { SearchIcon } from 'lucide-react'; -import { Input } from '~/components/ui/form/Input'; import Image from 'next/image'; -import StudySwitcher from './_components/StudySwitcher'; import LanguageSwitcher from '~/app/_components/LocaleSwitcher'; -import SignOutBtn from './_components/SignOutBtn'; -import { requirePageAuth } from '~/lib/auth'; -import ResponsiveContainer from '~/components/layout/ResponsiveContainer'; import ThemeSwitcher from '~/app/_components/ThemeSwitcher'; +import { Input } from '~/components/form/Input'; +import ResponsiveContainer from '~/components/layout/ResponsiveContainer'; +import { requirePageAuth } from '~/lib/auth'; +import SignOutBtn from './_components/SignOutBtn'; +import StudySwitcher from './_components/StudySwitcher'; export default async function DashboardLayout({ children, diff --git a/app/(main)/(dashboard)/page.tsx b/app/(main)/(dashboard)/page.tsx index 7200f860..90af0e10 100644 --- a/app/(main)/(dashboard)/page.tsx +++ b/app/(main)/(dashboard)/page.tsx @@ -1,15 +1,15 @@ -import { getUserStudies } from '~/server/queries/studies'; import { getTranslations } from 'next-intl/server'; -import Link from '~/components/Link'; -import UnorderedList from '~/components/typography/UnorderedList'; +import { route } from 'nextjs-routes'; +import { Button } from '~/components/Button'; import Section from '~/components/layout/Section'; +import Link from '~/components/Link'; import PageHeader from '~/components/typography/PageHeader'; +import Paragraph from '~/components/typography/Paragraph'; +import UnorderedList from '~/components/typography/UnorderedList'; +import { requirePageAuth } from '~/lib/auth'; import { getInterviews } from '~/server/queries/interviews'; +import { getUserStudies } from '~/server/queries/studies'; import CreateStudyForm from './_components/CreateStudyForm'; -import { requirePageAuth } from '~/lib/auth'; -import { Button } from '~/components/ui/Button'; -import Paragraph from '~/components/typography/Paragraph'; -import { route } from 'nextjs-routes'; export default async function Dashboard() { await requirePageAuth(); diff --git a/app/_components/LocaleSwitcherSelect.tsx b/app/_components/LocaleSwitcherSelect.tsx index 019a6bb2..72694e04 100644 --- a/app/_components/LocaleSwitcherSelect.tsx +++ b/app/_components/LocaleSwitcherSelect.tsx @@ -1,14 +1,14 @@ 'use client'; +import { useTransition } from 'react'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '~/components/ui/select'; +} from '~/components/select'; import { type Locale } from '~/lib/localisation/config'; -import { useTransition } from 'react'; import { setUserLocale } from '~/lib/localisation/locale'; import { getLocaleRecordsFromCodes } from '~/lib/localisation/utils'; diff --git a/app/_components/Providers.tsx b/app/_components/Providers.tsx index b3414e3b..c5968979 100644 --- a/app/_components/Providers.tsx +++ b/app/_components/Providers.tsx @@ -1,13 +1,13 @@ +import { MotionConfig } from 'framer-motion'; import { type AbstractIntlMessages } from 'next-intl'; -import { type ReactNode } from 'react'; -import RadixDirectionProvider from './RadixDirectionProvider'; -import { TooltipProvider } from '~/components/ui/Tooltip'; -import { type Locale } from '~/lib/localisation/config'; -import IntlProvider from './IntlProvider'; import { ThemeProvider } from 'next-themes'; -import { WizardProvider } from '~/lib/onboarding-wizard/Provider'; -import { MotionConfig } from 'framer-motion'; +import { type ReactNode } from 'react'; +import { TooltipProvider } from '~/components/Tooltip'; import DialogProvider from '~/lib/dialogs/DialogProvider'; +import { WizardProvider } from '~/lib/onboarding-wizard/Provider'; +import { type Locale } from '~/schemas/protocol/i18n'; +import IntlProvider from './IntlProvider'; +import RadixDirectionProvider from './RadixDirectionProvider'; export default function Providers({ intlParams: { dir, messages, locale, now, timeZone }, diff --git a/components/ui/Button.stories.tsx b/components/Button.stories.tsx similarity index 98% rename from components/ui/Button.stories.tsx rename to components/Button.stories.tsx index 064aad2a..527a6621 100644 --- a/components/ui/Button.stories.tsx +++ b/components/Button.stories.tsx @@ -1,12 +1,12 @@ import type { Meta } from '@storybook/react'; import { fn } from '@storybook/test'; +import Heading from '~/components/typography/Heading'; import { Button, type ButtonProps, type ButtonVariants, buttonVariants, } from './Button'; -import Heading from '../typography/Heading'; // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export const meta = { diff --git a/components/ui/Button.tsx b/components/Button.tsx similarity index 100% rename from components/ui/Button.tsx rename to components/Button.tsx diff --git a/components/ui/Card.stories.tsx b/components/Card.stories.tsx similarity index 94% rename from components/ui/Card.stories.tsx rename to components/Card.stories.tsx index 8a034d8c..ce2d2e7f 100644 --- a/components/ui/Card.stories.tsx +++ b/components/Card.stories.tsx @@ -1,9 +1,8 @@ // Card.stories.tsx -import React from 'react'; import type { Meta, StoryFn } from '@storybook/react'; +import Paragraph from '~/components/typography/Paragraph'; import { Card, type CardProps } from './Card'; -import Paragraph from '../typography/Paragraph'; // Meta configuration for Storybook const meta: Meta = { diff --git a/components/ui/Card.tsx b/components/Card.tsx similarity index 86% rename from components/ui/Card.tsx rename to components/Card.tsx index 866f8fc4..86b64a70 100644 --- a/components/ui/Card.tsx +++ b/components/Card.tsx @@ -1,11 +1,11 @@ 'use client'; import * as React from 'react'; -import { cn } from '~/lib/utils'; +import Divider from '~/components/layout/Divider'; +import Surface from '~/components/layout/Surface'; import Heading from '~/components/typography/Heading'; -import Paragraph from '../typography/Paragraph'; -import Surface from '../layout/Surface'; -import Divider from '../layout/Divider'; +import Paragraph from '~/components/typography/Paragraph'; +import { cn } from '~/lib/utils'; export type CardProps = { title: string; diff --git a/components/ui/CloseButton.tsx b/components/CloseButton.tsx similarity index 100% rename from components/ui/CloseButton.tsx rename to components/CloseButton.tsx diff --git a/components/DropdownMenu.stories.tsx b/components/DropdownMenu.stories.tsx new file mode 100644 index 00000000..9a76d8ca --- /dev/null +++ b/components/DropdownMenu.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryFn } from '@storybook/react'; +import { Button } from './Button'; +import DropdownMenu from './DropdownMenu'; + +const meta: Meta = { + title: 'UI/DropdownMenu', + component: DropdownMenu.Root, +}; + +export default meta; + +export const Default: StoryFn = () => ( + + + + + + Input Control + + { + alert('selected checkbox'); + }} + > + Checkbox Group + + { + alert('selected toggle'); + }} + > + Toggle Button Group + + + + +); diff --git a/components/DropdownMenu.tsx b/components/DropdownMenu.tsx new file mode 100644 index 00000000..cfc3aa06 --- /dev/null +++ b/components/DropdownMenu.tsx @@ -0,0 +1,82 @@ +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { type PropsWithChildren } from 'react'; +import { cn } from '~/lib/utils'; +import Heading from './typography/Heading'; + +const DropdownTrigger = ({ children }: PropsWithChildren) => ( + + {children} + +); + +const DropdownMenuContent = ({ + children, + side = 'bottom', + container, +}: PropsWithChildren<{ + side?: 'top' | 'right' | 'bottom' | 'left'; + container?: React.RefObject; +}>) => { + // Use the portal prop if provided, otherwise fallback to the 'dialog-portal' element + const portalContainer = container?.current + ? container.current + : typeof window !== 'undefined' + ? document.getElementById('dialog-portal') + : null; + + return ( + + + {children} + + + ); +}; + +const DropdownItem = ({ + children, + textValue, + onSelect, + active, +}: PropsWithChildren<{ + textValue: string; + onSelect: () => void; + active?: boolean; +}>) => ( + + {children} + +); + +const DropdownLabel = ({ children }: PropsWithChildren) => ( + + {children} + +); + +const DropdownSeparator = () => ( + +); + +const DropdownMenu = { + Root: DropdownMenuPrimitive.Root, + RadioGroup: DropdownMenuPrimitive.RadioGroup, + Trigger: DropdownTrigger, + Content: DropdownMenuContent, + Label: DropdownLabel, + Item: DropdownItem, + Separator: DropdownSeparator, +}; + +export default DropdownMenu; diff --git a/components/ui/Popover.tsx b/components/Popover.tsx similarity index 92% rename from components/ui/Popover.tsx rename to components/Popover.tsx index 37ecd38a..8e86f38d 100644 --- a/components/ui/Popover.tsx +++ b/components/Popover.tsx @@ -1,20 +1,22 @@ -import { type PropsWithChildren } from 'react'; import * as PopoverPrimitive from '@radix-ui/react-popover'; +import { type PropsWithChildren } from 'react'; +import { MotionSurface } from '~/components/layout/Surface'; +import Heading from '~/components/typography/Heading'; import { cn } from '~/lib/utils'; import CloseButton from './CloseButton'; -import Heading from '../typography/Heading'; -import { MotionSurface } from '../layout/Surface'; const Popover = ({ children, title, content, modal, + side, isOpen, onOpenChange, }: PropsWithChildren<{ title?: string; content: string | React.ReactNode; + side?: 'top' | 'right' | 'bottom' | 'left'; modal?: boolean; isOpen?: boolean; onOpenChange?: (open: boolean) => void; @@ -50,6 +52,7 @@ const Popover = ({ // floating-ui prop exposed in @radix-ui/react-popper patch // see https://floating-ui.com/docs/flip#fallbackaxissidedirection fallbackAxisSideDirection="start" + side={side} > { + const [selectedToggle, setSelectedToggle] = useState(null); + + return ( + + alert('Button 1 clicked!')}> + Button + + { + setSelectedToggle(value ?? null); + }} + > + + Toggle 1 + + + Toggle 2 + + + + ); +}; + +export const Default: StoryFn = () => ( + }> + + +); diff --git a/components/Toolbar.tsx b/components/Toolbar.tsx new file mode 100644 index 00000000..11e49a01 --- /dev/null +++ b/components/Toolbar.tsx @@ -0,0 +1,79 @@ +import * as ToolbarPrimitive from '@radix-ui/react-toolbar'; +import { type PropsWithChildren } from 'react'; +import { cn } from '~/lib/utils'; + +const ToolbarRoot = ({ children }: PropsWithChildren) => ( + + {children} + +); + +const ToolbarButton = ({ + children, + onClick, +}: { + children: React.ReactNode; + onClick?: () => void; +}) => ( + +
+ {children} +
+
+); + +const ToolbarToggleGroup = ({ + children, + type, + onValueChange, +}: PropsWithChildren<{ + type: 'single' | 'multiple'; + onValueChange?: (value?: string) => void; +}>) => ( + + {children} + +); + +const ToolbarToggleItem = ({ + value, + children, + active, + onClick, +}: { + value: string; + children: React.ReactNode; + active?: boolean; + onClick?: () => void; +}) => ( + +
+ {children} +
+
+); + +const ToolbarMenu = { + Root: ToolbarRoot, + Button: ToolbarButton, + ToggleGroup: ToolbarToggleGroup, + ToggleItem: ToolbarToggleItem, + Separator: ToolbarPrimitive.Separator, +}; + +export default ToolbarMenu; diff --git a/components/ui/Tooltip.stories.tsx b/components/Tooltip.stories.tsx similarity index 100% rename from components/ui/Tooltip.stories.tsx rename to components/Tooltip.stories.tsx diff --git a/components/ui/Tooltip.tsx b/components/Tooltip.tsx similarity index 100% rename from components/ui/Tooltip.tsx rename to components/Tooltip.tsx diff --git a/components/block-editor/BlockEditor.stories.tsx b/components/block-editor/BlockEditor.stories.tsx new file mode 100644 index 00000000..d720f573 --- /dev/null +++ b/components/block-editor/BlockEditor.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import BlockEditor from './BlockEditor'; + +const meta: Meta = { + title: 'Systems/BlockEditor', + component: BlockEditor, + parameters: { + nextjs: { + appDirectory: 'true', + }, + layout: 'fullscreen', + }, + decorators: [ + (Story, _context) => { + return ( +
+ +
+ ); + }, + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/components/block-editor/BlockEditor.tsx b/components/block-editor/BlockEditor.tsx new file mode 100644 index 00000000..fdc7c4e8 --- /dev/null +++ b/components/block-editor/BlockEditor.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { EditorContent } from '@tiptap/react'; +import { useRef } from 'react'; +import GroupMenu from '~/lib/block-editor/extensions/Group/GroupMenu'; +import { NodeBubbleMenu } from '~/lib/block-editor/extensions/NodeBubbleMenu'; +import { useBlockEditor } from '~/lib/block-editor/useBlockEditor'; +import PlusMenu from './PlusMenu'; +import SidePanel from './SidePanel'; + +const BlockEditor = () => { + const { editor } = useBlockEditor(); + const menuContainerRef = useRef(null); + + return ( +
+ + +
+ + +
+ + + +
+ ); +}; + +export default BlockEditor; diff --git a/components/block-editor/BubbleMenu.tsx b/components/block-editor/BubbleMenu.tsx new file mode 100644 index 00000000..9da7cf64 --- /dev/null +++ b/components/block-editor/BubbleMenu.tsx @@ -0,0 +1,13 @@ +import { + BubbleMenu as BaseBubbleMenu, + type BubbleMenuProps, +} from '@tiptap/react'; + +export default function BubbleMenu(props: BubbleMenuProps) { + return ( + + ); +} diff --git a/components/block-editor/PlusMenu.tsx b/components/block-editor/PlusMenu.tsx new file mode 100644 index 00000000..ac6fb815 --- /dev/null +++ b/components/block-editor/PlusMenu.tsx @@ -0,0 +1,90 @@ +import { type Editor } from '@tiptap/react'; +import { Plus } from 'lucide-react'; +import { useState } from 'react'; +import { + type TiptapContent, + contentMap, +} from '~/lib/block-editor/contentTypes'; +import { createVariableNode } from '~/lib/block-editor/extensions/Variable/utils'; +import devProtocol from '~/lib/db/sample-data/dev-protocol'; +import { type TVariableDefinition } from '~/schemas/protocol/variables'; +import { Button } from '../Button'; +import DropdownMenu from '../DropdownMenu'; + +export default function PlusMenu({ editor }: { editor: Editor | null }) { + const [isOpen, setIsOpen] = useState(false); + + if (!editor) return null; + const { variables } = devProtocol; + + const handleCommand = (type: TiptapContent) => () => { + const endPos = editor.state.doc.content.size; + const value = contentMap[type]; + if (!value) return; + editor + .chain() + .focus() + .insertContentAt(endPos, value, { + updateSelection: true, + }) + .run(); + + setIsOpen(false); + }; + + const addVariable = (variable: TVariableDefinition) => { + const endPos = editor.state.doc.content.size; + const variableNode = createVariableNode({ + newVariable: variable, + view: editor.view, + key: variable.label.en ?? '', //TODO: better integration with translations + }); + + if (!variableNode) return; + const transaction = editor.view.state.tr.insert(endPos, variableNode); + editor.view.dispatch(transaction); + setIsOpen(false); + }; + + return ( + + + {isOpen ? ( +
+ ) : ( + + )} + + + Add content + {Object.keys(contentMap).map((type) => ( + + {type} + + ))} + + + + Add variable + {variables && + Object.entries(variables).map(([key, variable]) => ( + { + addVariable(variable); + }} + textValue={variable.label.en ?? ''} + > + {variable.label.en} + + ))} + + + ); +} diff --git a/components/block-editor/SidePanel.tsx b/components/block-editor/SidePanel.tsx new file mode 100644 index 00000000..65cc440c --- /dev/null +++ b/components/block-editor/SidePanel.tsx @@ -0,0 +1,72 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +import { + CaseSensitive, + Hash, + type LucideIcon, + SquareStack, +} from 'lucide-react'; +import React from 'react'; +import Heading from '~/components/typography/Heading'; +import { handleDrag } from '~/lib/block-editor/utils'; +import devProtocol from '~/lib/db/sample-data/dev-protocol'; +import { cn } from '~/lib/utils'; +import { Card } from '../Card'; + +const VARIABLE_ICONS: Record = { + text: CaseSensitive, + number: Hash, + categorical: SquareStack, +}; + +export const FORMATS = [ + { name: 'Paragraph', type: 'paragraph' }, + { name: 'H1', type: 'h1' }, + { name: 'H2', type: 'h2' }, + { name: 'H3', type: 'h3' }, + { name: 'H4', type: 'h4' }, + { name: 'Bullet List', type: 'bulletList' }, + { name: 'Group', type: 'group' }, +]; + +export default function SidePanel() { + const { variables } = devProtocol; + + if (!variables) return null; + + const renderDraggableItem = ( + label: string, + onDragStart: (e: React.DragEvent) => void, + Icon?: LucideIcon, + ) => ( +
+ {label} + {Icon && } +
+ ); + + return ( + + Variables + {Object.entries(variables).map(([key, variable]) => { + const Icon = VARIABLE_ICONS[variable.type]; + return renderDraggableItem( + variable.label.en, + (e) => handleDrag(e, 'variable', variable, key), + Icon, + ); + })} + + Content + {FORMATS.map(({ name, type }) => + renderDraggableItem(name, (e) => handleDrag(e, type)), + )} + + ); +} diff --git a/components/ui/form/Form.stories.tsx b/components/form/Form.stories.tsx similarity index 100% rename from components/ui/form/Form.stories.tsx rename to components/form/Form.stories.tsx diff --git a/components/ui/form/Form.tsx b/components/form/Form.tsx similarity index 100% rename from components/ui/form/Form.tsx rename to components/form/Form.tsx diff --git a/components/ui/form/Input.tsx b/components/form/Input.tsx similarity index 100% rename from components/ui/form/Input.tsx rename to components/form/Input.tsx diff --git a/components/ui/form/Label.tsx b/components/form/Label.tsx similarity index 92% rename from components/ui/form/Label.tsx rename to components/form/Label.tsx index 9bfcf60e..1ad5d3a9 100644 --- a/components/ui/form/Label.tsx +++ b/components/form/Label.tsx @@ -1,11 +1,11 @@ 'use client'; -import * as React from 'react'; import * as LabelPrimitive from '@radix-ui/react-label'; +import * as React from 'react'; import { type VariantProps } from 'tailwind-variants'; +import { headingVariants } from '~/components/typography/Heading'; import { cn } from '~/lib/utils'; -import { headingVariants } from '../../typography/Heading'; const Label = React.forwardRef< React.ElementRef, diff --git a/components/ui/form/RadioGroup.tsx b/components/form/RadioGroup.tsx similarity index 100% rename from components/ui/form/RadioGroup.tsx rename to components/form/RadioGroup.tsx diff --git a/components/ui/form/Select.tsx b/components/form/Select.tsx similarity index 96% rename from components/ui/form/Select.tsx rename to components/form/Select.tsx index bd617167..83c57731 100644 --- a/components/ui/form/Select.tsx +++ b/components/form/Select.tsx @@ -1,11 +1,11 @@ +import type * as SelectPrimitive from '@radix-ui/react-select'; import { Select, SelectContent, - SelectValue, - SelectTrigger, SelectItem, -} from '~/components/ui/select'; -import type * as SelectPrimitive from '@radix-ui/react-select'; + SelectTrigger, + SelectValue, +} from '~/components/select'; type SimpleSelectProps = { options: { label: string; value: string }[]; diff --git a/components/ui/form/SubmitButton.tsx b/components/form/SubmitButton.tsx similarity index 89% rename from components/ui/form/SubmitButton.tsx rename to components/form/SubmitButton.tsx index 8e3d5abc..5549f780 100644 --- a/components/ui/form/SubmitButton.tsx +++ b/components/form/SubmitButton.tsx @@ -2,7 +2,7 @@ import { Loader2 } from 'lucide-react'; import { useFormStatus } from 'react-dom'; -import { Button } from '~/components/ui/Button'; +import { Button } from '~/components/Button'; export function SubmitButton({ children }: React.PropsWithChildren) { const { pending } = useFormStatus(); diff --git a/components/ui/form/Switch.tsx b/components/form/Switch.tsx similarity index 100% rename from components/ui/form/Switch.tsx rename to components/form/Switch.tsx diff --git a/components/interview/interfaces/name-generator/NameGenerator.tsx b/components/interview/interfaces/name-generator/NameGenerator.tsx index 1075facd..b7f4a6e3 100644 --- a/components/interview/interfaces/name-generator/NameGenerator.tsx +++ b/components/interview/interfaces/name-generator/NameGenerator.tsx @@ -2,17 +2,15 @@ * Building blocks for NameGenerator interface */ -import Prompts from '~/components/interview/Prompts/Prompts'; -import NodePanels from './NodePanels'; import NodeList from '~/components/interview/NodeList'; -import QuickNodeForm from './QuickNodeForm'; -import { cn } from '~/lib/utils'; -import { interfaceWrapperClasses } from '../../ui/SimpleShell'; -import { withOnboardingWizard } from '~/components/onboard-wizard/withOnboardingWizard'; -import { type InterviewStage } from '../../ui/InterviewShell'; -import { useTranslations } from 'next-intl'; +import Prompts from '~/components/interview/Prompts/Prompts'; +import { type InterviewStage } from '~/components/interview/ui/InterviewShell'; +import { interfaceWrapperClasses } from '~/components/interview/ui/SimpleShell'; import devProtocol from '~/lib/db/sample-data/dev-protocol'; +import { cn } from '~/lib/utils'; import { type NameGeneratorInterface } from '~/schemas/protocol/interfaces/name-generator'; +import NodePanels from './NodePanels'; +import QuickNodeForm from './QuickNodeForm'; const demoNodes = [ { diff --git a/components/interview/ui/HelpButton.tsx b/components/interview/ui/HelpButton.tsx index 2b8e0244..a7408e9d 100644 --- a/components/interview/ui/HelpButton.tsx +++ b/components/interview/ui/HelpButton.tsx @@ -1,14 +1,14 @@ import { HelpCircle } from 'lucide-react'; -import { NavButtonWithTooltip } from './NavigationButton'; import { useTranslations } from 'next-intl'; +import { Button } from '~/components/Button'; +import { Card } from '~/components/Card'; +import Form from '~/components/form/Form'; import { useWizardController } from '~/components/onboard-wizard/useWizardController'; -import { env } from '~/env'; -import { WIZARD_LOCAL_STORAGE_KEY } from '~/lib/onboarding-wizard/Provider'; import { renderLocalisedValue } from '~/components/RenderRichText'; +import { env } from '~/env'; import { useDialog } from '~/lib/dialogs/DialogProvider'; -import { Button } from '~/components/ui/Button'; -import { Card } from '~/components/ui/Card'; -import Form from '~/components/ui/form/Form'; +import { WIZARD_LOCAL_STORAGE_KEY } from '~/lib/onboarding-wizard/Provider'; +import { NavButtonWithTooltip } from './NavigationButton'; /** * Button to be added to the main navigation, which triggers the help popover. * This popover allows the participant to trigger help wizards. diff --git a/components/interview/ui/InterviewLocaleSwitcher.tsx b/components/interview/ui/InterviewLocaleSwitcher.tsx index b23652ea..1d8b3c7b 100644 --- a/components/interview/ui/InterviewLocaleSwitcher.tsx +++ b/components/interview/ui/InterviewLocaleSwitcher.tsx @@ -1,12 +1,12 @@ import { SelectTrigger } from '@radix-ui/react-select'; +import { Globe } from 'lucide-react'; import { useLocale, useTranslations } from 'next-intl'; import { useTransition } from 'react'; -import { Select, SelectContent, SelectItem } from '~/components/ui/select'; +import { Select, SelectContent, SelectItem } from '~/components/select'; import { type Locale } from '~/lib/localisation/config'; import { setUserLocale } from '~/lib/localisation/locale'; import { getLocaleRecordsFromCodes } from '~/lib/localisation/utils'; import { NavButtonWithTooltip } from './NavigationButton'; -import { Globe } from 'lucide-react'; export default function InterviewLocaleSwitcher({ codes, diff --git a/components/interview/ui/Navigation.tsx b/components/interview/ui/Navigation.tsx index 6a8bc38e..17ec54e7 100644 --- a/components/interview/ui/Navigation.tsx +++ b/components/interview/ui/Navigation.tsx @@ -1,24 +1,23 @@ 'use client'; import { - ChevronUp, ChevronDown, ChevronLeft, ChevronRight, + ChevronUp, } from 'lucide-react'; import { useTranslations } from 'next-intl'; -import { useSearchParams } from 'next/navigation'; -import { usePathname, useRouter } from 'next/navigation'; -import { cn } from '~/lib/utils'; -import { ProgressBarWithTooltip } from '../../ui/ProgressBar'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import type { IntRange } from 'type-fest'; -import HelpButton from './HelpButton'; -import { NavButtonWithTooltip } from './NavigationButton'; +import { ProgressBarWithTooltip } from '~/components/ProgressBar'; import Surface from '~/components/layout/Surface'; -import { type Locale } from '~/lib/localisation/config'; +import { withOnboardingWizard } from '~/components/onboard-wizard/withOnboardingWizard'; import { useMediaQuery } from '~/hooks/useMediaQuery'; +import { cn } from '~/lib/utils'; +import { type Locale } from '~/schemas/protocol/i18n'; +import HelpButton from './HelpButton'; import InterviewLocaleSwitcher from './InterviewLocaleSwitcher'; -import { withOnboardingWizard } from '~/components/onboard-wizard/withOnboardingWizard'; +import { NavButtonWithTooltip } from './NavigationButton'; type NavigationProps = { pulseNext: boolean; diff --git a/components/interview/ui/NavigationButton.tsx b/components/interview/ui/NavigationButton.tsx index 9553a230..988c88c1 100644 --- a/components/interview/ui/NavigationButton.tsx +++ b/components/interview/ui/NavigationButton.tsx @@ -1,5 +1,5 @@ -import { Button, type ButtonProps } from '~/components/ui/Button'; -import { withTooltip } from '~/components/ui/Tooltip'; +import { Button, type ButtonProps } from '~/components/Button'; +import { withTooltip } from '~/components/Tooltip'; import { cn } from '~/lib/utils'; const NavigationButton = (props: ButtonProps) => { diff --git a/components/layout/Section.tsx b/components/layout/Section.tsx index f086fd89..c6964254 100644 --- a/components/layout/Section.tsx +++ b/components/layout/Section.tsx @@ -1,8 +1,8 @@ 'use client'; -import Heading from '../typography/Heading'; -import { cn } from '~/lib/utils'; import { useId } from 'react'; +import Heading from '~/components/typography/Heading'; +import { cn } from '~/lib/utils'; import Surface, { type SurfaceVariants } from './Surface'; const sectionClasses = 'rounded mb-10'; diff --git a/components/layout/Surface.stories.tsx b/components/layout/Surface.stories.tsx index 148be014..d666932c 100644 --- a/components/layout/Surface.stories.tsx +++ b/components/layout/Surface.stories.tsx @@ -1,9 +1,9 @@ // src/components/Surface.stories.tsx -import React, { type ElementType } from 'react'; import type { Meta, StoryFn } from '@storybook/react'; +import { type ElementType } from 'react'; +import { Button } from '~/components/Button'; import Surface, { MotionSurface, type SurfaceVariants } from './Surface'; -import { Button } from '../ui/Button'; // Define the metadata for the Storybook const meta: Meta = { diff --git a/components/onboard-wizard/WizardStep.tsx b/components/onboard-wizard/WizardStep.tsx index 0758c5ce..fa45d464 100644 --- a/components/onboard-wizard/WizardStep.tsx +++ b/components/onboard-wizard/WizardStep.tsx @@ -1,15 +1,14 @@ -import Popover from '~/components/ui/Popover'; -import { Button } from '../ui/Button'; -import { useWizardController } from './useWizardController'; -import RenderRichText from '../RenderRichText'; import { useTranslations } from 'next-intl'; -import { useElementPosition } from '~/lib/onboarding-wizard/utils'; -import { type Step } from '~/lib/onboarding-wizard/store'; -import Form from '../ui/form/Form'; -import { generatePublicId } from '~/lib/generatePublicId'; +import { Button } from '~/components/Button'; +import Form from '~/components/form/Form'; +import Popover from '~/components/Popover'; import { ControlledDialog } from '~/lib/dialogs/ControlledDialog'; +import { generatePublicId } from '~/lib/generatePublicId'; +import { useElementPosition } from '~/lib/onboarding-wizard/utils'; +import RenderRichText from '../RenderRichText'; +import { useWizardController } from './useWizardController'; -export default function WizardStep({ step }: { step: Step }) { +export default function WizardStep({ step }: { step }) { const { title, content, targetElementId } = step; const { diff --git a/components/ui/select.tsx b/components/select.tsx similarity index 100% rename from components/ui/select.tsx rename to components/select.tsx diff --git a/components/typography/Heading.tsx b/components/typography/Heading.tsx index 56ca1c48..8ae9beef 100644 --- a/components/typography/Heading.tsx +++ b/components/typography/Heading.tsx @@ -1,21 +1,21 @@ 'use client'; -import { tv, type VariantProps } from 'tailwind-variants'; +import { Slot } from '@radix-ui/react-slot'; import React from 'react'; +import { tv, type VariantProps } from 'tailwind-variants'; import { cn } from '~/lib/utils'; -import { Slot } from '@radix-ui/react-slot'; export const headingVariants = tv({ - base: 'text-balance font-heading font-bold [&:has(+.lead)]:mb-2 max-w-[55ch]', + base: 'font-heading max-w-[55ch] text-balance font-bold [&:has(+.lead)]:mb-2', variants: { variant: { - 'h1': 'scroll-m-20 text-2xl tracking-tight mb-6', - 'h2': 'scroll-m-20 text-xl tracking-tight mb-4', - 'h3': 'scroll-m-20 text-lg mb-3', - 'h4': 'scroll-m-20 text-base mb-2 leading-6 font-[460]', - 'h4-all-caps': 'scroll-m-20 text-base tracking-widest uppercase', + 'h1': 'mb-6 scroll-m-20 text-2xl tracking-tight', + 'h2': 'mb-4 scroll-m-20 text-xl tracking-tight', + 'h3': 'mb-3 scroll-m-20 text-lg', + 'h4': 'mb-2 scroll-m-20 text-base font-[460] leading-6', + 'h4-all-caps': 'scroll-m-20 text-base uppercase tracking-widest', 'label': - 'scroll-m-20 text-sm tracking-normal peer-disabled:opacity-70 peer-disabled:cursor-not-allowed font-extrabold', + 'scroll-m-20 text-sm font-extrabold tracking-normal peer-disabled:cursor-not-allowed peer-disabled:opacity-70', }, }, defaultVariants: { diff --git a/components/typography/UnorderedList.tsx b/components/typography/UnorderedList.tsx index 5cc51e24..9a2db876 100644 --- a/components/typography/UnorderedList.tsx +++ b/components/typography/UnorderedList.tsx @@ -1,5 +1,7 @@ import { cn } from '~/lib/utils'; +export const unorderedListClasses = 'my-3 ml-8 list-disc [&>li]:mt-1'; + export default function UnorderedList({ children, className, @@ -7,9 +9,5 @@ export default function UnorderedList({ children: React.ReactNode; className?: string; }) { - return ( -
    li]:mt-1', className)}> - {children} -
- ); + return
    {children}
; } diff --git a/lib/block-editor/contentTypes.ts b/lib/block-editor/contentTypes.ts new file mode 100644 index 00000000..9e15138f --- /dev/null +++ b/lib/block-editor/contentTypes.ts @@ -0,0 +1,49 @@ +import type { Content } from '@tiptap/react'; + +export type TiptapContent = + | 'h1' + | 'h2' + | 'h3' + | 'h4' + | 'paragraph' + | 'bulletList' + | 'group' + | 'variable'; + +export const contentMap: Record = { + h1: { + type: 'heading', + attrs: { level: 1 }, + }, + h2: { + type: 'heading', + attrs: { level: 2 }, + }, + h3: { + type: 'heading', + attrs: { level: 3 }, + }, + h4: { + type: 'heading', + attrs: { level: 4 }, + }, + paragraph: { type: 'paragraph' }, + bulletList: { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + }, + ], + }, + ], + }, + group: { + type: 'group', + attrs: { columns: 1 }, + content: [], + }, +}; diff --git a/lib/block-editor/extensions/Dropcursor/Dropcursor.tsx b/lib/block-editor/extensions/Dropcursor/Dropcursor.tsx new file mode 100644 index 00000000..4d8075e6 --- /dev/null +++ b/lib/block-editor/extensions/Dropcursor/Dropcursor.tsx @@ -0,0 +1,51 @@ +// adapted from tiptap dropcursor extension +// https://github.com/ueberdosis/tiptap/tree/main/packages/extension-dropcursor +// uses custom prosemirror-dropcursor replacement instead of prosemirror-dropcursor plugin + +import { Extension } from '@tiptap/core'; +import { dropCursor } from './pmDropcursorReplacement'; + +export type DropcursorOptions = { + /** + * The color of the drop cursor + * @default 'currentColor' + * @example 'red' + */ + color: string | undefined; + + /** + * The width of the drop cursor + * @default 1 + * @example 2 + */ + width: number | undefined; + + /** + * The class of the drop cursor + * @default undefined + * @example 'drop-cursor' + */ + class: string | undefined; +}; + +/** + * This extension allows you to add a drop cursor to your editor. + * A drop cursor is a line that appears when you drag and drop content + * inbetween nodes. + * @see https://tiptap.dev/api/extensions/dropcursor + */ +export const Dropcursor = Extension.create({ + name: 'dropcursor', + + addOptions() { + return { + color: 'currentColor', + width: 1, + class: undefined, + }; + }, + + addProseMirrorPlugins() { + return [dropCursor(this.options)]; + }, +}); diff --git a/lib/block-editor/extensions/Dropcursor/index.ts b/lib/block-editor/extensions/Dropcursor/index.ts new file mode 100644 index 00000000..a17aba6a --- /dev/null +++ b/lib/block-editor/extensions/Dropcursor/index.ts @@ -0,0 +1 @@ +export * from './Dropcursor'; diff --git a/lib/block-editor/extensions/Dropcursor/pmDropcursorReplacement.ts b/lib/block-editor/extensions/Dropcursor/pmDropcursorReplacement.ts new file mode 100644 index 00000000..659accbd --- /dev/null +++ b/lib/block-editor/extensions/Dropcursor/pmDropcursorReplacement.ts @@ -0,0 +1,225 @@ +// adapted from prosemirror-dropcursor +// https://github.com/ProseMirror/prosemirror-dropcursor + +import { Plugin, type EditorState } from '@tiptap/pm/state'; +import { dropPoint } from '@tiptap/pm/transform'; +import { type EditorView } from '@tiptap/pm/view'; +import { isValidDropPosition } from '~/lib/block-editor/utils'; + +type DropCursorOptions = { + /// The color of the cursor. Defaults to `black`. Use `false` to apply no color and rely only on class. + color?: string | false; + + /// The precise width of the cursor in pixels. Defaults to 1. + width?: number; + + /// A CSS class name to add to the cursor element. + class?: string; +}; + +/// Create a plugin that, when added to a ProseMirror instance, +/// causes a decoration to show up at the drop position when something +/// is dragged over the editor. +/// +/// Nodes may add a `disableDropCursor` property to their spec to +/// control the showing of a drop cursor inside them. This may be a +/// boolean or a function, which will be called with a view and a +/// position, and should return a boolean. +export function dropCursor(options: DropCursorOptions = {}): Plugin { + return new Plugin({ + view(editorView) { + return new DropCursorView(editorView, options); + }, + }); +} + +class DropCursorView { + width: number; + color: string | undefined; + class: string | undefined; + cursorPos: number | null = null; + element: HTMLElement | null = null; + timeout = -1; + handlers: { name: string; handler: (event: Event) => void }[]; + + constructor( + readonly editorView: EditorView, + options: DropCursorOptions, + ) { + this.width = options.width ?? 1; + this.color = options.color === false ? undefined : options.color || 'black'; + this.class = options.class; + + this.handlers = ['dragover', 'dragend', 'drop', 'dragleave'].map((name) => { + const handler = (e: Event) => { + (this as any)[name](e); + }; + editorView.dom.addEventListener(name, handler); + return { name, handler }; + }); + } + + destroy() { + this.handlers.forEach(({ name, handler }) => + this.editorView.dom.removeEventListener(name, handler), + ); + } + + update(editorView: EditorView, prevState: EditorState) { + if (this.cursorPos != null && prevState.doc != editorView.state.doc) { + if (this.cursorPos > editorView.state.doc.content.size) + this.setCursor(null); + else this.updateOverlay(); + } + } + + setCursor(pos: number | null) { + if (pos == this.cursorPos) return; + this.cursorPos = pos; + if (pos == null) { + this.element!.parentNode!.removeChild(this.element!); + this.element = null; + } else { + this.updateOverlay(); + } + } + + updateOverlay() { + const $pos = this.editorView.state.doc.resolve(this.cursorPos!); + let isBlock = !$pos.parent.inlineContent, + rect; + const editorDOM = this.editorView.dom, + editorRect = editorDOM.getBoundingClientRect(); + const scaleX = editorRect.width / editorDOM.offsetWidth, + scaleY = editorRect.height / editorDOM.offsetHeight; + if (isBlock) { + const before = $pos.nodeBefore, + after = $pos.nodeAfter; + if (before || after) { + const node = this.editorView.nodeDOM( + this.cursorPos! - (before ? before.nodeSize : 0), + ); + if (node) { + const nodeRect = (node as HTMLElement).getBoundingClientRect(); + let top = before ? nodeRect.bottom : nodeRect.top; + if (before && after) + top = + (top + + ( + this.editorView.nodeDOM(this.cursorPos!) as HTMLElement + ).getBoundingClientRect().top) / + 2; + const halfWidth = (this.width / 2) * scaleY; + rect = { + left: nodeRect.left, + right: nodeRect.right, + top: top - halfWidth, + bottom: top + halfWidth, + }; + } + } + } + if (!rect) { + const coords = this.editorView.coordsAtPos(this.cursorPos!); + const halfWidth = (this.width / 2) * scaleX; + rect = { + left: coords.left - halfWidth, + right: coords.left + halfWidth, + top: coords.top, + bottom: coords.bottom, + }; + } + + const parent = this.editorView.dom.offsetParent as HTMLElement; + if (!this.element) { + this.element = parent.appendChild(document.createElement('div')); + if (this.class) this.element.className = this.class; + this.element.style.cssText = + 'position: absolute; z-index: 50; pointer-events: none;'; + if (this.color) { + this.element.style.backgroundColor = this.color; + } + } + this.element.classList.toggle('prosemirror-dropcursor-block', isBlock); + this.element.classList.toggle('prosemirror-dropcursor-inline', !isBlock); + let parentLeft, parentTop; + if ( + !parent || + (parent == document.body && getComputedStyle(parent).position == 'static') + ) { + parentLeft = -pageXOffset; + parentTop = -pageYOffset; + } else { + const rect = parent.getBoundingClientRect(); + const parentScaleX = rect.width / parent.offsetWidth, + parentScaleY = rect.height / parent.offsetHeight; + parentLeft = rect.left - parent.scrollLeft * parentScaleX; + parentTop = rect.top - parent.scrollTop * parentScaleY; + } + this.element.style.left = (rect.left - parentLeft) / scaleX + 'px'; + this.element.style.top = (rect.top - parentTop) / scaleY + 'px'; + this.element.style.width = (rect.right - rect.left) / scaleX + 'px'; + this.element.style.height = (rect.bottom - rect.top) / scaleY + 'px'; + } + + scheduleRemoval(timeout: number) { + clearTimeout(this.timeout); + this.timeout = setTimeout(() => this.setCursor(null), timeout); + } + + dragover(event: DragEvent) { + if (!this.editorView.editable) return; + const pos = this.editorView.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); + + const node = + pos && pos.inside >= 0 && this.editorView.state.doc.nodeAt(pos.inside); + const disableDropCursor = node && node.type.spec.disableDropCursor; + const disabled = + typeof disableDropCursor == 'function' + ? disableDropCursor(this.editorView, pos, event) + : disableDropCursor; + + // if variable is being dragged to an invalid position, prevent dropcursor from being displayed + if (event.dataTransfer) { + const isSidePanelDrop = event.dataTransfer.types.includes( + 'application/x-content-type', + ); + if (isSidePanelDrop && !isValidDropPosition(this.editorView, event)) { + return; + } + } + + if (pos && !disabled) { + let target: number | null = pos.pos; + if (this.editorView.dragging && this.editorView.dragging.slice) { + const point = dropPoint( + this.editorView.state.doc, + target, + this.editorView.dragging.slice, + ); + if (point != null) target = point; + } + this.setCursor(target); + this.scheduleRemoval(5000); + } + } + + dragend() { + this.scheduleRemoval(20); + } + + drop() { + this.scheduleRemoval(20); + } + + dragleave(event: DragEvent) { + if ( + event.target == this.editorView.dom || + !this.editorView.dom.contains((event as any).relatedTarget) + ) + this.setCursor(null); + } +} diff --git a/lib/block-editor/extensions/Group/Group.tsx b/lib/block-editor/extensions/Group/Group.tsx new file mode 100644 index 00000000..0be4e924 --- /dev/null +++ b/lib/block-editor/extensions/Group/Group.tsx @@ -0,0 +1,46 @@ +import { Node, mergeAttributes } from '@tiptap/react'; + +const gridClasses: Record = { + 1: 'grid-cols-1', + 2: 'grid-cols-2', + 3: 'grid-cols-3', + 4: 'grid-cols-4', +}; + +export const Group = Node.create({ + name: 'group', + + group: 'block', + + content: 'block*', + + parseHTML() { + return [{ tag: 'div[data-type="group"]' }]; + }, + + renderHTML({ HTMLAttributes }) { + const { columns } = HTMLAttributes as { columns: number }; + + const gridClass = gridClasses[columns]; + + return [ + 'div', + mergeAttributes(HTMLAttributes, { + 'data-type': 'group', + 'class': `gap-4 p-4 grid rounded-small ${gridClass} border hover:border-accent`, + }), + 0, + ]; + }, + + addAttributes() { + return { + columns: { + default: 2, + }, + groupRequired: { + default: false, + }, + }; + }, +}); diff --git a/lib/block-editor/extensions/Group/GroupMenu.tsx b/lib/block-editor/extensions/Group/GroupMenu.tsx new file mode 100644 index 00000000..4aa4502b --- /dev/null +++ b/lib/block-editor/extensions/Group/GroupMenu.tsx @@ -0,0 +1,148 @@ +import { useEditorState, type Editor } from '@tiptap/react'; +import { ChevronDown, CircleAlert, Trash } from 'lucide-react'; +import { useCallback, useRef } from 'react'; +import { sticky } from 'tippy.js'; +import DropdownMenu from '~/components/DropdownMenu'; +import Toolbar from '~/components/Toolbar'; +import BubbleMenu from '~/components/block-editor/BubbleMenu'; +import { getRenderContainer } from '../../utils'; +import { toggleGroupRequired } from './utils'; + +type GroupEditorState = { + columns: number; + groupRequired: boolean; + hovered: boolean; +}; + +export default function GroupMenu({ + editor, + appendTo, +}: { + editor: Editor | null; + appendTo: React.RefObject; +}) { + const shouldShow = useCallback(() => { + const isGroup = editor?.isActive('group'); + return !!isGroup; + }, [editor]); + + const getReferenceClientRect = useCallback(() => { + if (!editor) return new DOMRect(-1000, -1000, 0, 0); + const renderContainer = getRenderContainer(editor, 'group'); + + const rect = + renderContainer?.getBoundingClientRect() ?? + new DOMRect(-1000, -1000, 0, 0); + + return rect; + }, [editor]); + + const { columns, groupRequired } = useEditorState({ + editor, + selector: (ctx) => { + return { + columns: ctx.editor?.getAttributes('group')?.columns as number, + groupRequired: ctx.editor?.getAttributes('group') + ?.groupRequired as boolean, + }; + }, + }) as GroupEditorState; + + const containerRef = useRef(null); + + if (!editor) { + return null; + } + + return ( + appendTo?.current ?? document.body, + sticky: true, + placement: 'bottom', + }} + > + + { + editor?.commands.deleteNode('group'); + }} + > + + + + + + Select number of columns + + editor?.commands.updateAttributes('group', { columns: 1 }) + } + > + 1 + + + editor?.commands.updateAttributes('group', { columns: 2 }) + } + > + 2 + + + editor?.commands.updateAttributes('group', { columns: 3 }) + } + > + 3 + + + editor?.commands.updateAttributes('group', { columns: 4 }) + } + > + 4 + + + + + +
+ {columns} Columns + +
+
+
+
+ {/* required toggle */} + toggleGroupRequired(editor)} + > + + Group Required + + +
+
+ ); +} diff --git a/lib/block-editor/extensions/Group/index.ts b/lib/block-editor/extensions/Group/index.ts new file mode 100644 index 00000000..8401278d --- /dev/null +++ b/lib/block-editor/extensions/Group/index.ts @@ -0,0 +1 @@ +export * from './Group'; diff --git a/lib/block-editor/extensions/Group/utils.ts b/lib/block-editor/extensions/Group/utils.ts new file mode 100644 index 00000000..2e9872f3 --- /dev/null +++ b/lib/block-editor/extensions/Group/utils.ts @@ -0,0 +1,49 @@ +import { type Node } from '@tiptap/pm/model'; +import type { Editor } from '@tiptap/react'; + +type GroupNode = Node & { attrs: { groupRequired: boolean } }; + +export function toggleGroupRequired(editor: Editor) { + if (!editor) return; + + // find the current group node + const { from, to } = editor.state.selection; + let groupPos: number | null = null; + let groupNode: GroupNode | null = null; + + editor.state.doc.nodesBetween(from, to, (node, pos) => { + if (node.type.name === 'group') { + groupPos = pos; + if ('groupRequired' in node.attrs) { + groupNode = node as GroupNode; + } + return false; + } + return true; + }); + + if (!groupNode || groupPos === null) return; + + const groupRequired = (groupNode as GroupNode).attrs.groupRequired; + const groupNodeSize = (groupNode as GroupNode).nodeSize; + + // traverse nodes inside the group node and update the required attribute of variable nodes + editor.state.doc.nodesBetween( + groupPos, + groupPos + groupNodeSize, + (node, pos) => { + if (node.type.name === 'variable') { + editor.view.dispatch( + editor.state.tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + required: !groupRequired, + }), + ); + } + return true; + }, + ); + + // update the group's groupRequired attribute + editor.commands.updateAttributes('group', { groupRequired: !groupRequired }); +} diff --git a/lib/block-editor/extensions/HorizontalRule/HorizontalRule.tsx b/lib/block-editor/extensions/HorizontalRule/HorizontalRule.tsx new file mode 100644 index 00000000..5e7b685a --- /dev/null +++ b/lib/block-editor/extensions/HorizontalRule/HorizontalRule.tsx @@ -0,0 +1,17 @@ +import TiptapHorizontalRule from '@tiptap/extension-horizontal-rule'; +import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'; +import Divider from '~/components/layout/Divider'; + +const WrappedDivider = () => ( + + + +); + +export const HorizontalRule = TiptapHorizontalRule.extend({ + addNodeView() { + return ReactNodeViewRenderer(WrappedDivider); + }, +}); + +export default HorizontalRule; diff --git a/lib/block-editor/extensions/HorizontalRule/index.ts b/lib/block-editor/extensions/HorizontalRule/index.ts new file mode 100644 index 00000000..65fc6ace --- /dev/null +++ b/lib/block-editor/extensions/HorizontalRule/index.ts @@ -0,0 +1 @@ +export * from './HorizontalRule' diff --git a/lib/block-editor/extensions/Image/Image.ts b/lib/block-editor/extensions/Image/Image.ts new file mode 100644 index 00000000..cd33a575 --- /dev/null +++ b/lib/block-editor/extensions/Image/Image.ts @@ -0,0 +1,7 @@ +import { Image as BaseImage } from '@tiptap/extension-image' + +export const Image = BaseImage.extend({ + group: 'block', +}) + +export default Image diff --git a/lib/block-editor/extensions/Image/index.ts b/lib/block-editor/extensions/Image/index.ts new file mode 100644 index 00000000..072b1619 --- /dev/null +++ b/lib/block-editor/extensions/Image/index.ts @@ -0,0 +1 @@ +export * from './Image' diff --git a/lib/block-editor/extensions/ImageBlock/ImageBlock.ts b/lib/block-editor/extensions/ImageBlock/ImageBlock.ts new file mode 100644 index 00000000..ffd88e61 --- /dev/null +++ b/lib/block-editor/extensions/ImageBlock/ImageBlock.ts @@ -0,0 +1,117 @@ +import { mergeAttributes, type Range } from '@tiptap/core'; +import { ReactNodeViewRenderer } from '@tiptap/react'; + +import { Image } from '../Image'; +import { ImageBlockView } from './components/ImageBlockView'; + +declare module '@tiptap/core' { + type Commands = { + imageBlock: { + setImageBlock: (attributes: { src: string }) => ReturnType; + setImageBlockAt: (attributes: { + src: string; + pos: number | Range; + }) => ReturnType; + setImageBlockAlign: (align: 'left' | 'center' | 'right') => ReturnType; + setImageBlockWidth: (width: number) => ReturnType; + }; + }; +} + +export const ImageBlock = Image.extend({ + name: 'imageBlock', + + group: 'block', + + defining: true, + + isolating: true, + + addAttributes() { + return { + src: { + default: '', + parseHTML: (element) => element.getAttribute('src'), + renderHTML: (attributes) => ({ + src: attributes.src, + }), + }, + width: { + default: '100%', + parseHTML: (element) => element.getAttribute('data-width'), + renderHTML: (attributes) => ({ + 'data-width': attributes.width, + }), + }, + align: { + default: 'center', + parseHTML: (element) => element.getAttribute('data-align'), + renderHTML: (attributes) => ({ + 'data-align': attributes.align, + }), + }, + alt: { + default: undefined, + parseHTML: (element) => element.getAttribute('alt'), + renderHTML: (attributes) => ({ + alt: attributes.alt, + }), + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'img[src*="tiptap.dev"]:not([src^="data:"]), img[src*="windows.net"]:not([src^="data:"])', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'img', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + ]; + }, + + addCommands() { + return { + setImageBlock: + (attrs) => + ({ commands }) => { + return commands.insertContent({ + type: 'imageBlock', + attrs: { src: attrs.src }, + }); + }, + + setImageBlockAt: + (attrs) => + ({ commands }) => { + return commands.insertContentAt(attrs.pos, { + type: 'imageBlock', + attrs: { src: attrs.src }, + }); + }, + + setImageBlockAlign: + (align) => + ({ commands }) => + commands.updateAttributes('imageBlock', { align }), + + setImageBlockWidth: + (width) => + ({ commands }) => + commands.updateAttributes('imageBlock', { + width: `${Math.max(0, Math.min(100, width))}%`, + }), + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(ImageBlockView); + }, +}); + +export default ImageBlock; diff --git a/lib/block-editor/extensions/ImageBlock/components/ImageBlockMenu.tsx b/lib/block-editor/extensions/ImageBlock/components/ImageBlockMenu.tsx new file mode 100644 index 00000000..719a3bbf --- /dev/null +++ b/lib/block-editor/extensions/ImageBlock/components/ImageBlockMenu.tsx @@ -0,0 +1,98 @@ +import { BubbleMenu as BaseBubbleMenu, useEditorState } from '@tiptap/react' +import React, { useCallback, useRef } from 'react' +import { Instance, sticky } from 'tippy.js' +import { v4 as uuid } from 'uuid' + +import { Toolbar } from '@/components/ui/Toolbar' +import { Icon } from '@/components/ui/Icon' +import { ImageBlockWidth } from './ImageBlockWidth' +import { MenuProps } from '@/components/menus/types' +import { getRenderContainer } from '@/lib/utils' + +export const ImageBlockMenu = ({ editor, appendTo }: MenuProps): JSX.Element => { + const menuRef = useRef(null) + const tippyInstance = useRef(null) + + const getReferenceClientRect = useCallback(() => { + const renderContainer = getRenderContainer(editor, 'node-imageBlock') + const rect = renderContainer?.getBoundingClientRect() || new DOMRect(-1000, -1000, 0, 0) + + return rect + }, [editor]) + + const shouldShow = useCallback(() => { + const isActive = editor.isActive('imageBlock') + + return isActive + }, [editor]) + + const onAlignImageLeft = useCallback(() => { + editor.chain().focus(undefined, { scrollIntoView: false }).setImageBlockAlign('left').run() + }, [editor]) + + const onAlignImageCenter = useCallback(() => { + editor.chain().focus(undefined, { scrollIntoView: false }).setImageBlockAlign('center').run() + }, [editor]) + + const onAlignImageRight = useCallback(() => { + editor.chain().focus(undefined, { scrollIntoView: false }).setImageBlockAlign('right').run() + }, [editor]) + + const onWidthChange = useCallback( + (value: number) => { + editor.chain().focus(undefined, { scrollIntoView: false }).setImageBlockWidth(value).run() + }, + [editor], + ) + const { isImageCenter, isImageLeft, isImageRight, width } = useEditorState({ + editor, + selector: ctx => { + return { + isImageLeft: ctx.editor.isActive('imageBlock', { align: 'left' }), + isImageCenter: ctx.editor.isActive('imageBlock', { align: 'center' }), + isImageRight: ctx.editor.isActive('imageBlock', { align: 'right' }), + width: parseInt(ctx.editor.getAttributes('imageBlock')?.width || 0), + } + }, + }) + + return ( + { + tippyInstance.current = instance + }, + appendTo: () => { + return appendTo?.current + }, + plugins: [sticky], + sticky: 'popper', + }} + > + + + + + + + + + + + + + + + ) +} + +export default ImageBlockMenu diff --git a/lib/block-editor/extensions/ImageBlock/components/ImageBlockView.tsx b/lib/block-editor/extensions/ImageBlock/components/ImageBlockView.tsx new file mode 100644 index 00000000..5c762cb6 --- /dev/null +++ b/lib/block-editor/extensions/ImageBlock/components/ImageBlockView.tsx @@ -0,0 +1,45 @@ +import { type Node } from '@tiptap/pm/model'; +import { type Editor, NodeViewWrapper } from '@tiptap/react'; +import { useCallback, useRef } from 'react'; +import { cn } from '~/lib/utils'; + +type ImageBlockViewProps = { + editor: Editor; + getPos: () => number; + node: Node; + updateAttributes: (attrs: Record) => void; +}; + +export const ImageBlockView = (props: ImageBlockViewProps) => { + const { editor, getPos, node } = props as ImageBlockViewProps & { + node: Node & { + attrs: { + src: string; + }; + }; + }; + const imageWrapperRef = useRef(null); + const { src } = node.attrs; + + const wrapperClassName = cn( + node.attrs.align === 'left' ? 'ml-0' : 'ml-auto', + node.attrs.align === 'right' ? 'mr-0' : 'mr-auto', + node.attrs.align === 'center' && 'mx-auto', + ); + + const onClick = useCallback(() => { + editor.commands.setNodeSelection(getPos()); + }, [getPos, editor.commands]); + + return ( + +
+
+ +
+
+
+ ); +}; + +export default ImageBlockView; diff --git a/lib/block-editor/extensions/ImageBlock/components/ImageBlockWidth.tsx b/lib/block-editor/extensions/ImageBlock/components/ImageBlockWidth.tsx new file mode 100644 index 00000000..5edea3a9 --- /dev/null +++ b/lib/block-editor/extensions/ImageBlock/components/ImageBlockWidth.tsx @@ -0,0 +1,40 @@ +import { memo, useCallback, useEffect, useState } from 'react' + +export type ImageBlockWidthProps = { + onChange: (value: number) => void + value: number +} + +export const ImageBlockWidth = memo(({ onChange, value }: ImageBlockWidthProps) => { + const [currentValue, setCurrentValue] = useState(value) + + useEffect(() => { + setCurrentValue(value) + }, [value]) + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const nextValue = parseInt(e.target.value) + onChange(nextValue) + setCurrentValue(nextValue) + }, + [onChange], + ) + + return ( +
+ + {value}% +
+ ) +}) + +ImageBlockWidth.displayName = 'ImageBlockWidth' diff --git a/lib/block-editor/extensions/ImageBlock/index.ts b/lib/block-editor/extensions/ImageBlock/index.ts new file mode 100644 index 00000000..e870ec86 --- /dev/null +++ b/lib/block-editor/extensions/ImageBlock/index.ts @@ -0,0 +1 @@ +export * from './ImageBlock' diff --git a/lib/block-editor/extensions/Link/Link.ts b/lib/block-editor/extensions/Link/Link.ts new file mode 100644 index 00000000..defaae76 --- /dev/null +++ b/lib/block-editor/extensions/Link/Link.ts @@ -0,0 +1,49 @@ +import { mergeAttributes } from '@tiptap/core'; +import TiptapLink from '@tiptap/extension-link'; +import { Plugin } from '@tiptap/pm/state'; +import { type EditorView } from '@tiptap/pm/view'; + +export const Link = TiptapLink.extend({ + inclusive: false, + + parseHTML() { + return [ + { + tag: 'a[href]:not([data-type="button"]):not([href *= "javascript:" i])', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'a', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + class: 'text-link hover:underline', + }), + 0, + ]; + }, + + addProseMirrorPlugins() { + const { editor } = this; + + return [ + ...(this.parent?.() ?? []), + new Plugin({ + props: { + handleKeyDown: (view: EditorView, event: KeyboardEvent) => { + const { selection } = editor.state; + + if (event.key === 'Escape' && selection.empty !== true) { + editor.commands.focus(selection.to, { scrollIntoView: false }); + } + + return false; + }, + }, + }), + ]; + }, +}); + +export default Link; diff --git a/lib/block-editor/extensions/Link/index.ts b/lib/block-editor/extensions/Link/index.ts new file mode 100644 index 00000000..9378deb1 --- /dev/null +++ b/lib/block-editor/extensions/Link/index.ts @@ -0,0 +1 @@ +export * from './Link' diff --git a/lib/block-editor/extensions/MultiColumn/Column.ts b/lib/block-editor/extensions/MultiColumn/Column.ts new file mode 100644 index 00000000..3ae4203b --- /dev/null +++ b/lib/block-editor/extensions/MultiColumn/Column.ts @@ -0,0 +1,37 @@ +import { Node, mergeAttributes } from '@tiptap/core'; + +export const Column = Node.create({ + name: 'column', + + content: 'block+', + + isolating: true, + + addAttributes() { + return { + position: { + default: '', + parseHTML: (element) => element.getAttribute('data-position'), + renderHTML: (attributes) => ({ 'data-position': attributes.position }), + }, + }; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + mergeAttributes(HTMLAttributes, { 'data-type': 'column' }), + 0, + ]; + }, + + parseHTML() { + return [ + { + tag: 'div[data-type="column"]', + }, + ]; + }, +}); + +export default Column; diff --git a/lib/block-editor/extensions/MultiColumn/Columns.ts b/lib/block-editor/extensions/MultiColumn/Columns.ts new file mode 100644 index 00000000..817596dd --- /dev/null +++ b/lib/block-editor/extensions/MultiColumn/Columns.ts @@ -0,0 +1,65 @@ +import { Node } from '@tiptap/core' + +export enum ColumnLayout { + SidebarLeft = 'sidebar-left', + SidebarRight = 'sidebar-right', + TwoColumn = 'two-column', +} + +declare module '@tiptap/core' { + interface Commands { + columns: { + setColumns: () => ReturnType + setLayout: (layout: ColumnLayout) => ReturnType + } + } +} + +export const Columns = Node.create({ + name: 'columns', + + group: 'columns', + + content: 'column column', + + defining: true, + + isolating: true, + + addAttributes() { + return { + layout: { + default: ColumnLayout.TwoColumn, + }, + } + }, + + addCommands() { + return { + setColumns: + () => + ({ commands }) => + commands.insertContent( + `

`, + ), + setLayout: + (layout: ColumnLayout) => + ({ commands }) => + commands.updateAttributes('columns', { layout }), + } + }, + + renderHTML({ HTMLAttributes }) { + return ['div', { 'data-type': 'columns', class: `layout-${HTMLAttributes.layout}` }, 0] + }, + + parseHTML() { + return [ + { + tag: 'div[data-type="columns"]', + }, + ] + }, +}) + +export default Columns diff --git a/lib/block-editor/extensions/MultiColumn/index.ts b/lib/block-editor/extensions/MultiColumn/index.ts new file mode 100644 index 00000000..8537f09a --- /dev/null +++ b/lib/block-editor/extensions/MultiColumn/index.ts @@ -0,0 +1,2 @@ +export * from './Columns' +export * from './Column' diff --git a/lib/block-editor/extensions/MultiColumn/menus/ColumnsMenu.tsx b/lib/block-editor/extensions/MultiColumn/menus/ColumnsMenu.tsx new file mode 100644 index 00000000..ca6d390f --- /dev/null +++ b/lib/block-editor/extensions/MultiColumn/menus/ColumnsMenu.tsx @@ -0,0 +1,79 @@ +import { BubbleMenu as BaseBubbleMenu, useEditorState } from '@tiptap/react' +import { useCallback } from 'react' +import { sticky } from 'tippy.js' +import { v4 as uuid } from 'uuid' + +import { MenuProps } from '@/components/menus/types' +import { getRenderContainer } from '@/lib/utils/getRenderContainer' +import { Toolbar } from '@/components/ui/Toolbar' +import { ColumnLayout } from '../Columns' +import { Icon } from '@/components/ui/Icon' + +export const ColumnsMenu = ({ editor, appendTo }: MenuProps) => { + const getReferenceClientRect = useCallback(() => { + const renderContainer = getRenderContainer(editor, 'columns') + const rect = renderContainer?.getBoundingClientRect() || new DOMRect(-1000, -1000, 0, 0) + + return rect + }, [editor]) + + const shouldShow = useCallback(() => { + const isColumns = editor.isActive('columns') + return isColumns + }, [editor]) + + const onColumnLeft = useCallback(() => { + editor.chain().focus().setLayout(ColumnLayout.SidebarLeft).run() + }, [editor]) + + const onColumnRight = useCallback(() => { + editor.chain().focus().setLayout(ColumnLayout.SidebarRight).run() + }, [editor]) + + const onColumnTwo = useCallback(() => { + editor.chain().focus().setLayout(ColumnLayout.TwoColumn).run() + }, [editor]) + const { isColumnLeft, isColumnRight, isColumnTwo } = useEditorState({ + editor, + selector: ctx => { + return { + isColumnLeft: ctx.editor.isActive('columns', { layout: ColumnLayout.SidebarLeft }), + isColumnRight: ctx.editor.isActive('columns', { layout: ColumnLayout.SidebarRight }), + isColumnTwo: ctx.editor.isActive('columns', { layout: ColumnLayout.TwoColumn }), + } + }, + }) + + return ( + appendTo?.current, + plugins: [sticky], + sticky: 'popper', + }} + > + + + + + + + + + + + + + ) +} + +export default ColumnsMenu diff --git a/lib/block-editor/extensions/MultiColumn/menus/index.ts b/lib/block-editor/extensions/MultiColumn/menus/index.ts new file mode 100644 index 00000000..5a7b3232 --- /dev/null +++ b/lib/block-editor/extensions/MultiColumn/menus/index.ts @@ -0,0 +1 @@ +export * from './ColumnsMenu' diff --git a/lib/block-editor/extensions/NodeBubbleMenu/NodeBubbleMenu.tsx b/lib/block-editor/extensions/NodeBubbleMenu/NodeBubbleMenu.tsx new file mode 100644 index 00000000..3cfbf65b --- /dev/null +++ b/lib/block-editor/extensions/NodeBubbleMenu/NodeBubbleMenu.tsx @@ -0,0 +1,132 @@ +import { useEditorState, type Editor } from '@tiptap/react'; +import { Bold, Italic, Link, Trash } from 'lucide-react'; +import { useCallback } from 'react'; +import BubbleMenu from '~/components/block-editor/BubbleMenu'; +import { Button } from '~/components/Button'; +import Popover from '~/components/Popover'; +import Toolbar from '~/components/Toolbar'; +import VariableMenu from '../Variable/VariableMenu'; + +export const NodeBubbleMenu = ({ editor }: { editor: Editor | null }) => { + const shouldShow = useCallback(() => { + if (!editor) return false; + const isVariable = editor.isActive('variable'); + + if (isVariable) return true; + + if (editor.view.state.selection.empty) return false; + + const isText = editor.isActive('paragraph') || editor.isActive('heading'); + + return !!isVariable || !!isText; + }, [editor]); + + const editorState = useEditorState({ + editor, + selector: ({ editor }) => { + return { + isBold: !!editor?.isActive('bold'), + isItalic: !!editor?.isActive('italic'), + isLink: !!editor?.isActive('link'), + activeLink: editor?.getAttributes('link').href as string | null, + isVariable: + !!editor?.isActive('variable') || + !!editor?.isActive('control') || + !!editor?.isActive('label') || + !!editor?.isActive('hint'), + isGroup: !!editor?.isActive('group'), + }; + }, + }); + + if (!editor) { + return null; + } + + const handleLinkSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const url = formData.get('url') as string; + + if (url) { + editor + .chain() + .focus() + .setLink({ + href: url, + target: '_blank', + }) + .run(); + } + }; + + const handleLinkRemove = () => { + editor?.chain().focus().unsetLink().run(); + }; + + return ( + + + + { + editor?.chain().focus().toggleBold().run(); + }} + active={editorState?.isBold} + > + + + { + editor?.chain().focus().toggleItalic().run(); + }} + active={editorState?.isItalic} + > + + + + + + + {editorState.activeLink} + + +
+ ) : ( +
+ + +
+ ) + } + > + + + + + {editorState?.isVariable && } + + + ); +}; diff --git a/lib/block-editor/extensions/NodeBubbleMenu/index.ts b/lib/block-editor/extensions/NodeBubbleMenu/index.ts new file mode 100644 index 00000000..62083573 --- /dev/null +++ b/lib/block-editor/extensions/NodeBubbleMenu/index.ts @@ -0,0 +1 @@ +export * from './NodeBubbleMenu'; diff --git a/lib/block-editor/extensions/Selection/Selection.ts b/lib/block-editor/extensions/Selection/Selection.ts new file mode 100644 index 00000000..19c7b529 --- /dev/null +++ b/lib/block-editor/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/lib/block-editor/extensions/Selection/index.ts b/lib/block-editor/extensions/Selection/index.ts new file mode 100644 index 00000000..9279d55f --- /dev/null +++ b/lib/block-editor/extensions/Selection/index.ts @@ -0,0 +1 @@ +export * from './Selection' diff --git a/lib/block-editor/extensions/SlashCommand/CommandButton.tsx b/lib/block-editor/extensions/SlashCommand/CommandButton.tsx new file mode 100644 index 00000000..5a969b01 --- /dev/null +++ b/lib/block-editor/extensions/SlashCommand/CommandButton.tsx @@ -0,0 +1,33 @@ +import { forwardRef } from 'react' +import { cn } from '@/lib/utils' +import { icons } from 'lucide-react' +import { Icon } from '@/components/ui/Icon' + +export type CommandButtonProps = { + active?: boolean + description: string + icon: keyof typeof icons + onClick: () => void + title: string +} + +export const CommandButton = forwardRef( + ({ active, icon, onClick, title }, ref) => { + const wrapperClass = cn( + 'flex text-neutral-500 items-center text-xs font-semibold justify-start p-1.5 gap-2 rounded', + !active && 'bg-transparent hover:bg-neutral-50 hover:text-black', + active && 'bg-neutral-100 text-black hover:bg-neutral-100', + ) + + return ( + + ) + }, +) + +CommandButton.displayName = 'CommandButton' diff --git a/lib/block-editor/extensions/SlashCommand/MenuList.tsx b/lib/block-editor/extensions/SlashCommand/MenuList.tsx new file mode 100644 index 00000000..14c7b35f --- /dev/null +++ b/lib/block-editor/extensions/SlashCommand/MenuList.tsx @@ -0,0 +1,162 @@ +import { CircleIcon } from 'lucide-react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Button } from '~/components/Button'; +import Surface from '~/components/layout/Surface'; +import { type Command, type MenuListProps } from './types'; + +export const MenuList = React.forwardRef((props: MenuListProps, ref) => { + const scrollContainer = useRef(null); + const activeItem = useRef(null); + const [selectedGroupIndex, setSelectedGroupIndex] = useState(0); + const [selectedCommandIndex, setSelectedCommandIndex] = useState(0); + + // Anytime the groups change, i.e. the user types to narrow it down, we want to + // reset the current selection to the first menu item + useEffect(() => { + setSelectedGroupIndex(0); + setSelectedCommandIndex(0); + }, [props.items]); + + const selectItem = useCallback( + (groupIndex: number, commandIndex: number) => { + const command = props.items[groupIndex].commands[commandIndex]; + props.command(command); + }, + [props], + ); + + React.useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }: { event: React.KeyboardEvent }) => { + if (event.key === 'ArrowDown') { + if (!props.items.length) { + return false; + } + + const commands = props.items[selectedGroupIndex].commands; + + let newCommandIndex = selectedCommandIndex + 1; + let newGroupIndex = selectedGroupIndex; + + if (commands.length - 1 < newCommandIndex) { + newCommandIndex = 0; + newGroupIndex = selectedGroupIndex + 1; + } + + if (props.items.length - 1 < newGroupIndex) { + newGroupIndex = 0; + } + + setSelectedCommandIndex(newCommandIndex); + setSelectedGroupIndex(newGroupIndex); + + return true; + } + + if (event.key === 'ArrowUp') { + if (!props.items.length) { + return false; + } + + let newCommandIndex = selectedCommandIndex - 1; + let newGroupIndex = selectedGroupIndex; + + if (newCommandIndex < 0) { + newGroupIndex = selectedGroupIndex - 1; + newCommandIndex = + props.items[newGroupIndex]?.commands.length - 1 || 0; + } + + if (newGroupIndex < 0) { + newGroupIndex = props.items.length - 1; + newCommandIndex = props.items[newGroupIndex].commands.length - 1; + } + + setSelectedCommandIndex(newCommandIndex); + setSelectedGroupIndex(newGroupIndex); + + return true; + } + + if (event.key === 'Enter') { + if ( + !props.items.length || + selectedGroupIndex === -1 || + selectedCommandIndex === -1 + ) { + return false; + } + + selectItem(selectedGroupIndex, selectedCommandIndex); + + return true; + } + + return false; + }, + })); + + useEffect(() => { + if (activeItem.current && scrollContainer.current) { + const offsetTop = activeItem.current.offsetTop; + const offsetHeight = activeItem.current.offsetHeight; + + scrollContainer.current.scrollTop = offsetTop - offsetHeight; + } + }, [selectedCommandIndex, selectedGroupIndex]); + + const createCommandClickHandler = useCallback( + (groupIndex: number, commandIndex: number) => { + return () => { + selectItem(groupIndex, commandIndex); + }; + }, + [selectItem], + ); + + if (!props.items.length) { + return null; + } + + return ( + +
+ {props.items.map((group, groupIndex: number) => ( + +
+ {group.title} +
+ {group.commands.map((command: Command, commandIndex: number) => ( + + ))} +
+ ))} +
+
+ ); +}); + +MenuList.displayName = 'MenuList'; + +export default MenuList; diff --git a/lib/block-editor/extensions/SlashCommand/SlashCommand.ts b/lib/block-editor/extensions/SlashCommand/SlashCommand.ts new file mode 100644 index 00000000..1bed4000 --- /dev/null +++ b/lib/block-editor/extensions/SlashCommand/SlashCommand.ts @@ -0,0 +1,287 @@ +import { type Editor, Extension } from '@tiptap/core'; +import { PluginKey } from '@tiptap/pm/state'; +import { ReactRenderer } from '@tiptap/react'; +import Suggestion, { + type SuggestionKeyDownProps, + type SuggestionProps, +} from '@tiptap/suggestion'; +import tippy from 'tippy.js'; + +import { GROUPS } from './groups'; +import { MenuList } from './MenuList'; + +const extensionName = 'slashCommand'; + +let popup: any; + +export const SlashCommand = Extension.create({ + name: extensionName, + + priority: 200, + + onCreate() { + popup = tippy('body', { + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + theme: 'slash-command', + maxWidth: '16rem', + offset: [16, 8], + popperOptions: { + strategy: 'fixed', + modifiers: [ + { + name: 'flip', + enabled: false, + }, + ], + }, + }); + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + char: '/', + allowSpaces: true, + startOfLine: true, + pluginKey: new PluginKey(extensionName), + allow: ({ state, range }) => { + const $from = state.doc.resolve(range.from); + const isRootDepth = $from.depth === 1; + const isParagraph = $from.parent.type.name === 'paragraph'; + const isStartOfNode = $from.parent.textContent?.startsWith('/'); + // TODO + const isInColumn = this.editor.isActive('column'); + + const afterContent = $from.parent.textContent?.substring( + $from.parent.textContent?.indexOf('/'), + ); + const isValidAfterContent = !afterContent?.endsWith(' '); + + return ( + ((isRootDepth && isParagraph && isStartOfNode) || + (isInColumn && isParagraph && isStartOfNode)) && + isValidAfterContent + ); + }, + command: ({ editor, props }: { editor: Editor; props: any }) => { + const { view, state } = editor; + const { $head, $from } = view.state.selection; + + const end = $from.pos; + const from = $head?.nodeBefore + ? end - + ($head.nodeBefore.text?.substring( + $head.nodeBefore.text?.indexOf('/'), + ).length ?? 0) + : $from.start(); + + const tr = state.tr.deleteRange(from, end); + view.dispatch(tr); + + props.action(editor); + view.focus(); + }, + items: ({ query }: { query: string }) => { + const withFilteredCommands = GROUPS.map((group) => ({ + ...group, + commands: group.commands + .filter((item) => { + const labelNormalized = item.label.toLowerCase().trim(); + const queryNormalized = query.toLowerCase().trim(); + + if (item.aliases) { + const aliases = item.aliases.map((alias) => + alias.toLowerCase().trim(), + ); + + return ( + labelNormalized.includes(queryNormalized) || + aliases.includes(queryNormalized) + ); + } + + return labelNormalized.includes(queryNormalized); + }) + .filter((command) => + command.shouldBeHidden + ? !command.shouldBeHidden(this.editor) + : true, + ), + })); + + const withoutEmptyGroups = withFilteredCommands.filter((group) => { + if (group.commands.length > 0) { + return true; + } + + return false; + }); + + const withEnabledSettings = withoutEmptyGroups.map((group) => ({ + ...group, + commands: group.commands.map((command) => ({ + ...command, + isEnabled: true, + })), + })); + + return withEnabledSettings; + }, + render: () => { + let component: any; + + let scrollHandler: (() => void) | null = null; + + return { + onStart: (props: SuggestionProps) => { + component = new ReactRenderer(MenuList, { + props, + editor: props.editor, + }); + + const { view } = props.editor; + + const editorNode = view.dom; + + const getReferenceClientRect = () => { + if (!props.clientRect) { + return props.editor.storage[extensionName].rect; + } + + const rect = props.clientRect(); + + if (!rect) { + return props.editor.storage[extensionName].rect; + } + + let yPos = rect.y; + + if ( + rect.top + component.element.offsetHeight + 40 > + window.innerHeight + ) { + const diff = + rect.top + + component.element.offsetHeight - + window.innerHeight + + 40; + yPos = rect.y - diff; + } + + // Account for when the editor is bound inside a container that doesn't go all the way to the edge of the screen + const editorXOffset = editorNode.getBoundingClientRect().x; + return new DOMRect(rect.x, yPos, rect.width, rect.height); + }; + + scrollHandler = () => { + popup?.[0].setProps({ + getReferenceClientRect, + }); + }; + + view.dom.parentElement?.addEventListener('scroll', scrollHandler); + + popup?.[0].setProps({ + getReferenceClientRect, + appendTo: () => document.body, + content: component.element, + }); + + popup?.[0].show(); + }, + + onUpdate(props: SuggestionProps) { + component.updateProps(props); + + const { view } = props.editor; + + const editorNode = view.dom; + + const getReferenceClientRect = () => { + if (!props.clientRect) { + return props.editor.storage[extensionName].rect; + } + + const rect = props.clientRect(); + + if (!rect) { + return props.editor.storage[extensionName].rect; + } + + // Account for when the editor is bound inside a container that doesn't go all the way to the edge of the screen + return new DOMRect(rect.x, rect.y, rect.width, rect.height); + }; + + const scrollHandler = () => { + popup?.[0].setProps({ + getReferenceClientRect, + }); + }; + + view.dom.parentElement?.addEventListener('scroll', scrollHandler); + + // eslint-disable-next-line no-param-reassign + props.editor.storage[extensionName].rect = props.clientRect + ? getReferenceClientRect() + : { + width: 0, + height: 0, + left: 0, + top: 0, + right: 0, + bottom: 0, + }; + popup?.[0].setProps({ + getReferenceClientRect, + }); + }, + + onKeyDown(props: SuggestionKeyDownProps) { + if (props.event.key === 'Escape') { + popup?.[0].hide(); + + return true; + } + + if (!popup?.[0].state.isShown) { + popup?.[0].show(); + } + + return component.ref?.onKeyDown(props); + }, + + onExit(props) { + popup?.[0].hide(); + if (scrollHandler) { + const { view } = props.editor; + view.dom.parentElement?.removeEventListener( + 'scroll', + scrollHandler, + ); + } + component.destroy(); + }, + }; + }, + }), + ]; + }, + + addStorage() { + return { + rect: { + width: 0, + height: 0, + left: 0, + top: 0, + right: 0, + bottom: 0, + }, + }; + }, +}); + +export default SlashCommand; diff --git a/lib/block-editor/extensions/SlashCommand/groups.ts b/lib/block-editor/extensions/SlashCommand/groups.ts new file mode 100644 index 00000000..52fa1f05 --- /dev/null +++ b/lib/block-editor/extensions/SlashCommand/groups.ts @@ -0,0 +1,147 @@ +import { type Group } from './types'; + +export const GROUPS: Group[] = [ + { + name: 'variables', + title: 'Variables', + commands: [ + { + name: 'addVariable', + label: 'Add Variable', + iconName: 'Sparkles', + description: 'Insert a variable', + shouldBeHidden: (editor) => editor.isActive('columns'), + action: (editor) => () => {}, + }, + ], + }, + { + name: 'format', + title: 'Format', + commands: [ + { + name: 'heading1', + label: 'Heading 1', + iconName: 'Heading1', + description: 'High priority section title', + aliases: ['h1'], + action: (editor) => { + editor.chain().focus().setHeading({ level: 1 }).run(); + }, + }, + { + name: 'heading2', + label: 'Heading 2', + iconName: 'Heading2', + description: 'Medium priority section title', + aliases: ['h2'], + action: (editor) => { + editor.chain().focus().setHeading({ level: 2 }).run(); + }, + }, + { + name: 'heading3', + label: 'Heading 3', + iconName: 'Heading3', + description: 'Low priority section title', + aliases: ['h3'], + action: (editor) => { + editor.chain().focus().setHeading({ level: 3 }).run(); + }, + }, + { + name: 'bulletList', + label: 'Bullet List', + iconName: 'List', + description: 'Unordered list of items', + aliases: ['ul'], + action: (editor) => { + editor.chain().focus().toggleBulletList().run(); + }, + }, + { + name: 'numberedList', + label: 'Numbered List', + iconName: 'ListOrdered', + description: 'Ordered list of items', + aliases: ['ol'], + action: (editor) => { + editor.chain().focus().toggleOrderedList().run(); + }, + }, + { + name: 'taskList', + label: 'Task List', + iconName: 'ListTodo', + description: 'Task list with todo items', + aliases: ['todo'], + action: (editor) => { + editor.chain().focus().toggleTaskList().run(); + }, + }, + { + name: 'toggleList', + label: 'Toggle List', + iconName: 'ListCollapse', + description: 'Toggles can show and hide content', + aliases: ['toggle'], + action: (editor) => { + editor.chain().focus().setDetails().run(); + }, + }, + ], + }, + { + name: 'insert', + title: 'Insert', + commands: [ + { + name: 'group', + label: 'Group', + iconName: 'Group', + description: 'Group content together in a grid', + aliases: ['grid'], + shouldBeHidden: (editor) => editor.isActive('group'), + action: (editor) => { + editor + .chain() + .focus() + .insertContent({ + type: 'group', + attrs: { columns: 1 }, + content: [], + }) + .run(); + }, + }, + { + name: 'columns', + label: 'Columns', + iconName: 'Columns2', + description: 'Add two column content', + aliases: ['cols'], + shouldBeHidden: (editor) => editor.isActive('columns'), + action: (editor) => { + editor + .chain() + .focus() + .setColumns() + .focus(editor.state.selection.head - 1) + .run(); + }, + }, + { + name: 'horizontalRule', + label: 'Horizontal Rule', + iconName: 'Minus', + description: 'Insert a horizontal divider', + aliases: ['hr'], + action: (editor) => { + editor.chain().focus().setHorizontalRule().run(); + }, + }, + ], + }, +]; + +export default GROUPS; diff --git a/lib/block-editor/extensions/SlashCommand/index.ts b/lib/block-editor/extensions/SlashCommand/index.ts new file mode 100644 index 00000000..6e4e154b --- /dev/null +++ b/lib/block-editor/extensions/SlashCommand/index.ts @@ -0,0 +1 @@ +export * from './SlashCommand' diff --git a/lib/block-editor/extensions/SlashCommand/types.ts b/lib/block-editor/extensions/SlashCommand/types.ts new file mode 100644 index 00000000..ba8f6bb6 --- /dev/null +++ b/lib/block-editor/extensions/SlashCommand/types.ts @@ -0,0 +1,25 @@ +import { type Editor } from '@tiptap/core'; + +import { type icons } from 'lucide-react'; + +export type Group = { + name: string; + title: string; + commands: Command[]; +}; + +export type Command = { + name: string; + label: string; + description: string; + aliases?: string[]; + iconName: keyof typeof icons; + action: (editor: Editor) => void; + shouldBeHidden?: (editor: Editor) => boolean; +}; + +export type MenuListProps = { + editor: Editor; + items: Group[]; + command: (command: Command) => void; +}; diff --git a/lib/block-editor/extensions/Variable/Hint/Hint.ts b/lib/block-editor/extensions/Variable/Hint/Hint.ts new file mode 100644 index 00000000..c3c7b3d8 --- /dev/null +++ b/lib/block-editor/extensions/Variable/Hint/Hint.ts @@ -0,0 +1,26 @@ +import { Node, mergeAttributes } from '@tiptap/core'; +import { ReactNodeViewRenderer } from '@tiptap/react'; +import { HintNodeView } from './HintNodeView'; + +export const HintNode = Node.create({ + name: 'hint', + group: 'variable', + content: 'inline*', + draggable: false, + disableDropCursor: true, + + parseHTML() { + return [ + { + tag: 'hint', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ['hint', mergeAttributes(HTMLAttributes)]; + }, + addNodeView() { + return ReactNodeViewRenderer(HintNodeView); + }, +}); diff --git a/lib/block-editor/extensions/Variable/Hint/HintNodeView.tsx b/lib/block-editor/extensions/Variable/Hint/HintNodeView.tsx new file mode 100644 index 00000000..006c393f --- /dev/null +++ b/lib/block-editor/extensions/Variable/Hint/HintNodeView.tsx @@ -0,0 +1,23 @@ +import { + NodeViewContent, + type NodeViewProps, + NodeViewWrapper, +} from '@tiptap/react'; +import { cn } from '~/lib/utils'; + +export const HintNodeView: React.FC = ({ node }) => { + const isEmpty = node.content.size === 0; + + return ( + + + + ); +}; diff --git a/lib/block-editor/extensions/Variable/Label/Label.ts b/lib/block-editor/extensions/Variable/Label/Label.ts new file mode 100644 index 00000000..ae718562 --- /dev/null +++ b/lib/block-editor/extensions/Variable/Label/Label.ts @@ -0,0 +1,26 @@ +// basically extends p tag to be an editable paragraph above the control +import { Node, mergeAttributes } from '@tiptap/core'; +import { ReactNodeViewRenderer } from '@tiptap/react'; +import { LabelNodeView } from './LabelNodeView'; + +export const LabelNode = Node.create({ + name: 'label', + group: 'variable', + content: 'inline*', + draggable: false, + disableDropCursor: true, + + parseHTML() { + return [ + { + tag: 'label', + }, + ]; + }, + renderHTML({ HTMLAttributes }) { + return ['label', mergeAttributes(HTMLAttributes)]; + }, + addNodeView() { + return ReactNodeViewRenderer(LabelNodeView); + }, +}); diff --git a/lib/block-editor/extensions/Variable/Label/LabelNodeView.tsx b/lib/block-editor/extensions/Variable/Label/LabelNodeView.tsx new file mode 100644 index 00000000..e0c4d3a7 --- /dev/null +++ b/lib/block-editor/extensions/Variable/Label/LabelNodeView.tsx @@ -0,0 +1,24 @@ +import { + NodeViewContent, + type NodeViewProps, + NodeViewWrapper, +} from '@tiptap/react'; +import { cn } from '~/lib/utils'; + +export const LabelNodeView: React.FC = ({ node }) => { + const isEmpty = node.content.size === 0; + + return ( + // this prevents dragging the label node out of the variable + + + + ); +}; diff --git a/lib/block-editor/extensions/Variable/Variable.ts b/lib/block-editor/extensions/Variable/Variable.ts new file mode 100644 index 00000000..a272929c --- /dev/null +++ b/lib/block-editor/extensions/Variable/Variable.ts @@ -0,0 +1,64 @@ +import { Node, mergeAttributes } from '@tiptap/core'; +import { ReactNodeViewRenderer } from '@tiptap/react'; +import { HintNode } from './Hint/Hint'; +import { LabelNode } from './Label/Label'; +import { VariableNodeView } from './VariableNodeView'; + +export type VariableNodeAttributes = { + type: string; + control?: string; + options: string[]; + name: string; + required: boolean; +}; + +export const VariableNode = Node.create({ + name: 'variable', + + group: 'block', + content: 'label hint?', + draggable: true, + selectable: true, + isolating: true, + disableDropCursor: true, + + parseHTML() { + return [ + { + tag: 'variable', + }, + ]; + }, + + addAttributes() { + return { + type: { default: 'text' }, + control: { default: null }, + options: { default: [] }, + name: { default: '' }, + required: { default: false }, + }; + }, + + addExtensions() { + // adds nested label and control nodes + return [LabelNode, HintNode]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'variable', + mergeAttributes( + { + 'data-type': 'variable', + }, + HTMLAttributes, + ), + 0, + ]; + }, + + addNodeView() { + return ReactNodeViewRenderer(VariableNodeView); + }, +}); diff --git a/lib/block-editor/extensions/Variable/VariableMenu.tsx b/lib/block-editor/extensions/Variable/VariableMenu.tsx new file mode 100644 index 00000000..dce33919 --- /dev/null +++ b/lib/block-editor/extensions/Variable/VariableMenu.tsx @@ -0,0 +1,130 @@ +import { useEditorState, type Editor } from '@tiptap/react'; +import { ChevronDown, CircleAlert, Trash } from 'lucide-react'; +import DropdownMenu from '~/components/DropdownMenu'; +import Toolbar from '~/components/Toolbar'; + +type VariableEditorState = { + type: 'categorical' | 'text'; + control: 'checkboxGroup' | 'toggleButtonGroup' | 'text' | 'textArea'; + required: boolean; +}; + +export default function VariableMenu({ editor }: { editor: Editor | null }) { + const { type, control, required } = useEditorState({ + editor, + selector: (ctx) => { + return { + type: ctx.editor?.getAttributes('variable')?.type as + | 'categorical' + | 'text' + | 'number' + | undefined, + control: ctx.editor?.getAttributes('variable')?.control as + | 'checkboxGroup' + | 'toggleButtonGroup' + | 'text' + | 'textArea', + required: ctx.editor?.getAttributes('variable')?.required as boolean, + }; + }, + }) as VariableEditorState; + + if (!editor) { + return null; + } + + return ( + <> + + + + Select an input control + {type === 'categorical' && ( + <> + + editor?.commands.updateAttributes('variable', { + control: 'checkboxGroup', + }) + } + > + Checkbox Group + + + editor?.commands.updateAttributes('variable', { + control: 'toggleButtonGroup', + }) + } + > + Toggle Button Group + + + )} + {type === 'text' && ( + <> + + editor?.commands.updateAttributes('variable', { + control: 'text', + }) + } + > + Text + + + editor?.commands.updateAttributes('variable', { + control: 'textArea', + }) + } + > + Text Area + + + )} + + + + +
+ {control} +
+
+
+
+ {/* required toggle */} + + editor?.commands.updateAttributes('variable', { + required: !required, + }) + } + > + + Required + + + { + editor?.commands.deleteNode('variable'); + }} + > + + + + ); +} diff --git a/lib/block-editor/extensions/Variable/VariableNodeView.tsx b/lib/block-editor/extensions/Variable/VariableNodeView.tsx new file mode 100644 index 00000000..e5d77064 --- /dev/null +++ b/lib/block-editor/extensions/Variable/VariableNodeView.tsx @@ -0,0 +1,87 @@ +import { Label } from '@radix-ui/react-label'; +import { + NodeViewContent, + type NodeViewProps, + NodeViewWrapper, + useEditorState, +} from '@tiptap/react'; +import { AlertCircle } from 'lucide-react'; +import { Input } from '~/components/form/Input'; +import { cn } from '~/lib/utils'; +import { type VariableNodeAttributes } from './Variable'; + +export const VariableNodeView: React.FC = ({ node, editor }) => { + const { type, control, options, name, required } = + node.attrs as VariableNodeAttributes; + + // get selected here instead of from props to get updated value + const { selected } = useEditorState({ + editor, + selector: (ctx) => { + return { + selected: ctx.editor?.isActive('variable', { + name, + }), + }; + }, + }); + + const renderControl = () => { + switch (type) { + case 'text': + return control === 'textArea' ? ( +