feat: implement FieldRichTextEditor component using TipTap and Mantine integration
This commit is contained in:
@@ -122,7 +122,8 @@ packages/ui/src/components/Form/
|
||||
├── tags-input.field.tsx # FieldTagsInput
|
||||
├── chip-group.field.tsx # FieldChipGroup
|
||||
├── segmented-control.field.tsx # FieldSegmentedControl
|
||||
└── file-input.field.tsx # FieldFileInput
|
||||
├── file-input.field.tsx # FieldFileInput
|
||||
└── rich-text.field.tsx # FieldRichTextEditor
|
||||
```
|
||||
|
||||
Each field file is a thin one-liner:
|
||||
@@ -889,7 +890,11 @@ export const FieldDatePicker = withRHF<DatePickerInputProps>(
|
||||
| `FieldColorPicker` | `ColorPicker` | Color | Color picker only (uses `Input.Wrapper`) |
|
||||
| `FieldLocalSelect` | `Select / MultiSelect` | Selection | Stores full `T` or `T[]` object in RHF instead of string ID. Accepts static `options` array with `valueKey`/`labelKey` mapping. |
|
||||
| `FieldAsyncSelect` | `Select / MultiSelect` | Selection | Async paginated object select with IoC `loadOptions` callback. Supports search-keyed caching, `defaultOptions` for edit forms, and automatic pagination detection. |
|
||||
| `FieldFileInput` | `FileInput` | File | File upload input |
|
||||
| `FieldFileInput` | `<FileInput />` | `File | File[] | null` |
|
||||
| `FieldRichTextEditor` | `@mantine/tiptap` | `string` (HTML) |
|
||||
|
||||
### Rich Text Editor (TipTap)
|
||||
The `FieldRichTextEditor` component integrates `@mantine/tiptap` directly with React Hook Form. It safely stores the Editor's HTML output directly into the RHF state as a `string`. Because TipTap is an uncontrolled editor natively, this field uses a specialized `useController` wrapper that automatically syncs bidirectional updates (e.g., calling `editor.commands.setContent(field.value)` when the form is reset or async default values arrive).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -23,8 +23,15 @@
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@mantine/core": "^8.3.15",
|
||||
"@mantine/hooks": "^8.3.15",
|
||||
"@mantine/tiptap": "^9.3.2",
|
||||
"@repo/core-i18n": "workspace:*",
|
||||
"@repo/utils": "workspace:*",
|
||||
"@tiptap/extension-link": "^3.27.1",
|
||||
"@tiptap/extension-text-align": "^3.27.1",
|
||||
"@tiptap/extension-underline": "^3.27.1",
|
||||
"@tiptap/pm": "^3.27.1",
|
||||
"@tiptap/react": "^3.27.1",
|
||||
"@tiptap/starter-kit": "^3.27.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"react-hook-form": "^7.56.4",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useController, type FieldPath, type FieldValues, type UseControllerProps } from 'react-hook-form';
|
||||
import { useEditor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import { RichTextEditor } from '@mantine/tiptap';
|
||||
import { Input } from '@mantine/core';
|
||||
import { useTranslatedError } from '../useTranslatedError';
|
||||
|
||||
export type FieldRichTextEditorProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = UseControllerProps<TFieldValues, TName> & {
|
||||
label?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
withAsterisk?: boolean;
|
||||
};
|
||||
|
||||
function FieldRichTextEditorComponent<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>(props: FieldRichTextEditorProps<TFieldValues, TName>) {
|
||||
const {
|
||||
name,
|
||||
control,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
defaultValue,
|
||||
disabled,
|
||||
label,
|
||||
description,
|
||||
withAsterisk,
|
||||
} = props;
|
||||
|
||||
const {
|
||||
field,
|
||||
fieldState: { error },
|
||||
} = useController({
|
||||
name,
|
||||
control,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
defaultValue,
|
||||
disabled,
|
||||
});
|
||||
|
||||
const translatedError = useTranslatedError(error?.message);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Underline,
|
||||
Link,
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'], alignments: ['left', 'center', 'right', 'justify'] }),
|
||||
],
|
||||
content: field.value || '',
|
||||
onUpdate({ editor }) {
|
||||
field.onChange(editor.getHTML());
|
||||
},
|
||||
onBlur() {
|
||||
field.onBlur();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && field.value !== editor.getHTML()) {
|
||||
editor.commands.setContent(field.value || '');
|
||||
}
|
||||
}, [field.value, editor]);
|
||||
|
||||
return (
|
||||
<Input.Wrapper
|
||||
label={label}
|
||||
description={description}
|
||||
withAsterisk={withAsterisk}
|
||||
error={translatedError}
|
||||
>
|
||||
<RichTextEditor editor={editor}>
|
||||
<RichTextEditor.Toolbar sticky stickyOffset={60}>
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.Bold />
|
||||
<RichTextEditor.Italic />
|
||||
<RichTextEditor.Underline />
|
||||
<RichTextEditor.Strikethrough />
|
||||
<RichTextEditor.ClearFormatting />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.H1 />
|
||||
<RichTextEditor.H2 />
|
||||
<RichTextEditor.H3 />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.BulletList />
|
||||
<RichTextEditor.OrderedList />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.Link />
|
||||
<RichTextEditor.Unlink />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.AlignLeft />
|
||||
<RichTextEditor.AlignCenter />
|
||||
<RichTextEditor.AlignRight />
|
||||
<RichTextEditor.AlignJustify />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
</RichTextEditor.Toolbar>
|
||||
|
||||
<RichTextEditor.Content />
|
||||
</RichTextEditor>
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap with React.memo for identical performance characteristics as withRHF components
|
||||
export const FieldRichTextEditor = React.memo(FieldRichTextEditorComponent) as typeof FieldRichTextEditorComponent;
|
||||
@@ -28,6 +28,7 @@ export { FieldNumberInput } from './fields/number-input.field';
|
||||
export { FieldJsonInput } from './fields/json-input.field';
|
||||
export { FieldPinInput } from './fields/pin-input.field';
|
||||
export { FieldAutocomplete } from './fields/autocomplete.field';
|
||||
export { FieldRichTextEditor } from './fields/rich-text.field';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection Fields
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export * from '@mantine/core';
|
||||
export {
|
||||
List,
|
||||
TypographyStylesProvider,
|
||||
} from '@mantine/core';
|
||||
|
||||
export * from './Form';
|
||||
export * from './system-pages/coming-soon';
|
||||
|
||||
+60
-28
@@ -1,19 +1,35 @@
|
||||
/* =========================================
|
||||
1. CORE IMPORTS & TAILWIND CONFIG
|
||||
========================================= */
|
||||
/* Import Mantine core and TipTap extensions */
|
||||
@import '@mantine/core/styles.css';
|
||||
@import '@mantine/tiptap/styles.css';
|
||||
|
||||
/* Initialize Tailwind CSS v4 engine */
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Instruct Tailwind to scan the src directory for utility class usage */
|
||||
@source "../src";
|
||||
|
||||
@theme {
|
||||
/* =========================================
|
||||
FONT FAMILY MAPPING (Mantine -> Tailwind)
|
||||
2. FONT FAMILY MAPPING
|
||||
Synchronizes Tailwind's typography utilities
|
||||
with Mantine's global font configurations.
|
||||
========================================= */
|
||||
--base-font-size: 13px;
|
||||
--font-sans: var(--mantine-font-family);
|
||||
--font-mono: var(--mantine-font-family-monospace);
|
||||
|
||||
/* =========================================
|
||||
1. COLORS (Strictly mapped to Mantine 0-9)
|
||||
3. COLOR SYSTEM (Mantine to Tailwind Sync)
|
||||
Maps Tailwind's 50-900 scale directly to
|
||||
Mantine's 0-9 scale for seamless theming.
|
||||
Usage: `bg-brand-500`, `text-error-700`
|
||||
========================================= */
|
||||
--color-brand-50: var(--mantine-color-brand-0);
|
||||
|
||||
/* Brand Colors */
|
||||
--color-brand-50: var(--mantine-color-brand-0);
|
||||
--color-brand-100: var(--mantine-color-brand-1);
|
||||
--color-brand-200: var(--mantine-color-brand-2);
|
||||
--color-brand-300: var(--mantine-color-brand-3);
|
||||
@@ -24,7 +40,8 @@
|
||||
--color-brand-800: var(--mantine-color-brand-8);
|
||||
--color-brand-900: var(--mantine-color-brand-9);
|
||||
|
||||
--color-error-50: var(--mantine-color-error-0);
|
||||
/* Error Colors (Red/Danger) */
|
||||
--color-error-50: var(--mantine-color-error-0);
|
||||
--color-error-100: var(--mantine-color-error-1);
|
||||
--color-error-200: var(--mantine-color-error-2);
|
||||
--color-error-300: var(--mantine-color-error-3);
|
||||
@@ -35,7 +52,8 @@
|
||||
--color-error-800: var(--mantine-color-error-8);
|
||||
--color-error-900: var(--mantine-color-error-9);
|
||||
|
||||
--color-warning-50: var(--mantine-color-warning-0);
|
||||
/* Warning Colors (Yellow/Orange) */
|
||||
--color-warning-50: var(--mantine-color-warning-0);
|
||||
--color-warning-100: var(--mantine-color-warning-1);
|
||||
--color-warning-200: var(--mantine-color-warning-2);
|
||||
--color-warning-300: var(--mantine-color-warning-3);
|
||||
@@ -46,7 +64,8 @@
|
||||
--color-warning-800: var(--mantine-color-warning-8);
|
||||
--color-warning-900: var(--mantine-color-warning-9);
|
||||
|
||||
--color-success-50: var(--mantine-color-success-0);
|
||||
/* Success Colors (Green) */
|
||||
--color-success-50: var(--mantine-color-success-0);
|
||||
--color-success-100: var(--mantine-color-success-1);
|
||||
--color-success-200: var(--mantine-color-success-2);
|
||||
--color-success-300: var(--mantine-color-success-3);
|
||||
@@ -57,7 +76,8 @@
|
||||
--color-success-800: var(--mantine-color-success-8);
|
||||
--color-success-900: var(--mantine-color-success-9);
|
||||
|
||||
--color-info-50: var(--mantine-color-info-0);
|
||||
/* Info Colors (Blue/Cyan) */
|
||||
--color-info-50: var(--mantine-color-info-0);
|
||||
--color-info-100: var(--mantine-color-info-1);
|
||||
--color-info-200: var(--mantine-color-info-2);
|
||||
--color-info-300: var(--mantine-color-info-3);
|
||||
@@ -69,22 +89,26 @@
|
||||
--color-info-900: var(--mantine-color-info-9);
|
||||
|
||||
/* =========================================
|
||||
2. SPACING & CONTAINERS
|
||||
4. SPACING, BREAKPOINTS & CONTAINERS
|
||||
Aligns Tailwind's padding/margin scale
|
||||
with Mantine's layout engine.
|
||||
========================================= */
|
||||
--spacing: 0.25rem;
|
||||
--spacing: 0.25rem; /* Base Tailwind unit (1 = 0.25rem) */
|
||||
|
||||
/* Core mapped to Mantine */
|
||||
/* Map core layout spacing to Mantine */
|
||||
--spacing-xs: var(--mantine-spacing-xs);
|
||||
--spacing-sm: var(--mantine-spacing-sm);
|
||||
--spacing-md: var(--mantine-spacing-md);
|
||||
--spacing-lg: var(--mantine-spacing-lg);
|
||||
--spacing-xl: var(--mantine-spacing-xl);
|
||||
|
||||
/* Standard Tailwind responsive breakpoints and container sizes */
|
||||
--breakpoint-sm: 40rem;
|
||||
--breakpoint-md: 48rem;
|
||||
--breakpoint-lg: 64rem;
|
||||
--breakpoint-xl: 80rem;
|
||||
--breakpoint-2xl: 96rem;
|
||||
|
||||
--container-3xs: 16rem;
|
||||
--container-2xs: 18rem;
|
||||
--container-xs: 20rem;
|
||||
@@ -100,17 +124,22 @@
|
||||
--container-7xl: 80rem;
|
||||
|
||||
/* =========================================
|
||||
3. TYPOGRAPHY
|
||||
5. TYPOGRAPHY SCALES
|
||||
Base sizes (xs to xl) inherit from Mantine.
|
||||
Extended sizes (2xl to 9xl) use static rems.
|
||||
========================================= */
|
||||
/* Core text sizes mapped to Mantine, extended kept static */
|
||||
--text-xs: var(--mantine-font-size-xs);
|
||||
--text-xs--line-height: calc(1 / 0.75);
|
||||
|
||||
--text-sm: var(--mantine-font-size-sm);
|
||||
--text-sm--line-height: calc(1.25 / 0.875);
|
||||
|
||||
--text-base: var(--mantine-font-size-md);
|
||||
--text-base--line-height: calc(1.5 / 1);
|
||||
|
||||
--text-lg: var(--mantine-font-size-lg);
|
||||
--text-lg--line-height: calc(1.75 / 1.125);
|
||||
|
||||
--text-xl: var(--mantine-font-size-xl);
|
||||
--text-xl--line-height: calc(1.75 / 1.25);
|
||||
|
||||
@@ -131,6 +160,7 @@
|
||||
--text-9xl: 8rem;
|
||||
--text-9xl--line-height: 1;
|
||||
|
||||
/* Font Weights */
|
||||
--font-weight-thin: 100;
|
||||
--font-weight-extralight: 200;
|
||||
--font-weight-light: 300;
|
||||
@@ -141,6 +171,7 @@
|
||||
--font-weight-extrabold: 800;
|
||||
--font-weight-black: 900;
|
||||
|
||||
/* Letter Spacing (Tracking) */
|
||||
--tracking-tighter: -0.05em;
|
||||
--tracking-tight: -0.025em;
|
||||
--tracking-normal: 0em;
|
||||
@@ -148,6 +179,7 @@
|
||||
--tracking-wider: 0.05em;
|
||||
--tracking-widest: 0.1em;
|
||||
|
||||
/* Line Height (Leading) */
|
||||
--leading-tight: 1.25;
|
||||
--leading-snug: 1.375;
|
||||
--leading-normal: 1.5;
|
||||
@@ -155,19 +187,23 @@
|
||||
--leading-loose: 2;
|
||||
|
||||
/* =========================================
|
||||
4. RADIUS
|
||||
6. BORDER RADIUS
|
||||
Inherits exact corner rounding from Mantine.
|
||||
========================================= */
|
||||
--radius-xs: var(--mantine-radius-xs);
|
||||
--radius-sm: var(--mantine-radius-sm);
|
||||
--radius-md: var(--mantine-radius-md);
|
||||
--radius-lg: var(--mantine-radius-lg);
|
||||
--radius-xl: var(--mantine-radius-xl);
|
||||
|
||||
--radius-2xl: 1rem;
|
||||
--radius-3xl: 1.5rem;
|
||||
--radius-4xl: 2rem;
|
||||
|
||||
/* =========================================
|
||||
5. SHADOWS & BLURS
|
||||
7. SHADOWS & BLURS
|
||||
Ensures popovers, modals, and dropdowns
|
||||
share identical elevation depths.
|
||||
========================================= */
|
||||
--shadow-2xs: 0 1px rgb(0 0 0 / 0.05);
|
||||
--shadow-xs: var(--mantine-shadow-xs);
|
||||
@@ -203,7 +239,7 @@
|
||||
--blur-3xl: 64px;
|
||||
|
||||
/* =========================================
|
||||
6. MISCELLANEOUS (Aspect, Anim, Perspective)
|
||||
8. MISCELLANEOUS & ANIMATIONS
|
||||
========================================= */
|
||||
--perspective-dramatic: 100px;
|
||||
--perspective-near: 300px;
|
||||
@@ -217,31 +253,26 @@
|
||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* Standard Tailwind Animations */
|
||||
--animate-spin: spin 1s linear infinite;
|
||||
--animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
--animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
--animate-bounce: bounce 1s infinite;
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes ping {
|
||||
75%,
|
||||
100% {
|
||||
75%, 100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
100% {
|
||||
0%, 100% {
|
||||
transform: translateY(-25%);
|
||||
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
|
||||
}
|
||||
@@ -253,13 +284,14 @@
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
BASE RESETS
|
||||
9. BASE RESETS
|
||||
Applies global typography smoothing and
|
||||
sets the root font size.
|
||||
========================================= */
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
font-size: var(--base-font-size);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user