@opensaas/stack-tiptap 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +8 -0
- package/README.md +335 -0
- package/dist/components/TiptapField.d.ts +21 -0
- package/dist/components/TiptapField.d.ts.map +1 -0
- package/dist/components/TiptapField.js +60 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +1 -0
- package/dist/config/types.d.ts +34 -0
- package/dist/config/types.d.ts.map +1 -0
- package/dist/config/types.js +1 -0
- package/dist/fields/index.d.ts +3 -0
- package/dist/fields/index.d.ts.map +1 -0
- package/dist/fields/index.js +1 -0
- package/dist/fields/richText.d.ts +22 -0
- package/dist/fields/richText.d.ts.map +1 -0
- package/dist/fields/richText.js +59 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/styles/tiptap.css +170 -0
- package/package.json +55 -0
- package/src/components/TiptapField.tsx +193 -0
- package/src/components/index.ts +2 -0
- package/src/config/types.ts +35 -0
- package/src/fields/index.ts +2 -0
- package/src/fields/richText.ts +63 -0
- package/src/index.ts +9 -0
- package/src/styles/tiptap.css +170 -0
- package/tsconfig.json +21 -0
package/README.md
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
# @opensaas/stack-tiptap
|
|
2
|
+
|
|
3
|
+
Rich text editor integration for OpenSaas Stack using [Tiptap](https://tiptap.dev).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- ✅ Rich text editing with Tiptap editor
|
|
8
|
+
- ✅ JSON storage in database
|
|
9
|
+
- ✅ SSR-safe Next.js integration
|
|
10
|
+
- ✅ Edit and read-only modes
|
|
11
|
+
- ✅ Customizable toolbar and UI options
|
|
12
|
+
- ✅ Full TypeScript support
|
|
13
|
+
- ✅ Integrates with OpenSaas access control
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
This package is designed as a separate optional dependency to keep the core stack lightweight.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pnpm add @opensaas/stack-tiptap
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The following peer dependencies are required:
|
|
24
|
+
|
|
25
|
+
- `@opensaas/stack-core`
|
|
26
|
+
- `@opensaas/stack-ui`
|
|
27
|
+
- `next`
|
|
28
|
+
- `react`
|
|
29
|
+
- `react-dom`
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
### Basic Setup
|
|
34
|
+
|
|
35
|
+
1. **Register the field component** on the client side:
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
// lib/register-fields.ts
|
|
39
|
+
'use client'
|
|
40
|
+
|
|
41
|
+
import { registerFieldComponent } from '@opensaas/stack-ui'
|
|
42
|
+
import { TiptapField } from '@opensaas/stack-tiptap'
|
|
43
|
+
|
|
44
|
+
registerFieldComponent('richText', TiptapField)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
2. **Import the registration in your admin page**:
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
// app/admin/[[...admin]]/page.tsx
|
|
51
|
+
import { AdminUI } from "@opensaas/stack-ui";
|
|
52
|
+
import config from "../../../opensaas.config";
|
|
53
|
+
import "../../../lib/register-fields"; // Import to trigger registration
|
|
54
|
+
|
|
55
|
+
export default async function AdminPage() {
|
|
56
|
+
// ... your code
|
|
57
|
+
return <AdminUI config={config} />;
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
3. **Define your schema** with the `richText` field builder:
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
// opensaas.config.ts
|
|
65
|
+
import { config, list } from '@opensaas/stack-core'
|
|
66
|
+
import { text } from '@opensaas/stack-core/fields'
|
|
67
|
+
import { richText } from '@opensaas/stack-tiptap/fields'
|
|
68
|
+
|
|
69
|
+
export default config({
|
|
70
|
+
db: {
|
|
71
|
+
provider: 'sqlite',
|
|
72
|
+
url: 'file:./dev.db',
|
|
73
|
+
},
|
|
74
|
+
lists: {
|
|
75
|
+
Article: list({
|
|
76
|
+
fields: {
|
|
77
|
+
title: text({ validation: { isRequired: true } }),
|
|
78
|
+
content: richText({
|
|
79
|
+
validation: { isRequired: true },
|
|
80
|
+
}),
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
4. Generate Prisma schema:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
pnpm generate
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
This will create a Prisma field with type `Json`:
|
|
94
|
+
|
|
95
|
+
```prisma
|
|
96
|
+
model Article {
|
|
97
|
+
id String @id @default(cuid())
|
|
98
|
+
title String
|
|
99
|
+
content Json // Tiptap JSON content
|
|
100
|
+
createdAt DateTime @default(now())
|
|
101
|
+
updatedAt DateTime @updatedAt
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Field Options
|
|
106
|
+
|
|
107
|
+
#### Validation
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
content: richText({
|
|
111
|
+
validation: {
|
|
112
|
+
isRequired: true, // Make field required
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
#### UI Customization
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
content: richText({
|
|
121
|
+
ui: {
|
|
122
|
+
placeholder: 'Start writing...',
|
|
123
|
+
minHeight: 200, // Minimum editor height in pixels
|
|
124
|
+
maxHeight: 800, // Maximum editor height (scrollable)
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Access Control
|
|
130
|
+
|
|
131
|
+
Rich text fields work seamlessly with OpenSaas access control:
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
Article: list({
|
|
135
|
+
fields: {
|
|
136
|
+
content: richText({
|
|
137
|
+
validation: { isRequired: true },
|
|
138
|
+
access: {
|
|
139
|
+
read: () => true,
|
|
140
|
+
create: isSignedIn,
|
|
141
|
+
update: isAuthor,
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Database Operations
|
|
149
|
+
|
|
150
|
+
Content is stored as JSON and can be queried using Prisma's JSON operations:
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
import { prisma } from './lib/context'
|
|
154
|
+
|
|
155
|
+
// Create article with rich text
|
|
156
|
+
const article = await prisma.article.create({
|
|
157
|
+
data: {
|
|
158
|
+
title: 'My Article',
|
|
159
|
+
content: {
|
|
160
|
+
type: 'doc',
|
|
161
|
+
content: [
|
|
162
|
+
{
|
|
163
|
+
type: 'paragraph',
|
|
164
|
+
content: [{ type: 'text', text: 'Hello world!' }],
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// Query articles
|
|
172
|
+
const articles = await prisma.article.findMany({
|
|
173
|
+
select: {
|
|
174
|
+
title: true,
|
|
175
|
+
content: true,
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Component Features
|
|
181
|
+
|
|
182
|
+
The `TiptapField` component includes:
|
|
183
|
+
|
|
184
|
+
### Text Formatting
|
|
185
|
+
|
|
186
|
+
- **Bold**
|
|
187
|
+
- _Italic_
|
|
188
|
+
- ~~Strike-through~~
|
|
189
|
+
|
|
190
|
+
### Headings
|
|
191
|
+
|
|
192
|
+
- H1, H2, H3
|
|
193
|
+
|
|
194
|
+
### Lists
|
|
195
|
+
|
|
196
|
+
- Bullet lists
|
|
197
|
+
- Ordered lists
|
|
198
|
+
|
|
199
|
+
### Blockquotes
|
|
200
|
+
|
|
201
|
+
- Quote blocks
|
|
202
|
+
|
|
203
|
+
### Modes
|
|
204
|
+
|
|
205
|
+
- **Edit mode**: Full toolbar with all formatting options
|
|
206
|
+
- **Read mode**: Render-only view (no toolbar)
|
|
207
|
+
|
|
208
|
+
## Advanced Usage
|
|
209
|
+
|
|
210
|
+
### Custom Field Component
|
|
211
|
+
|
|
212
|
+
Create a custom Tiptap component with additional extensions:
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
// components/CustomTiptapField.tsx
|
|
216
|
+
"use client";
|
|
217
|
+
|
|
218
|
+
import { useEditor, EditorContent } from "@tiptap/react";
|
|
219
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
220
|
+
import Link from "@tiptap/extension-link";
|
|
221
|
+
import Image from "@tiptap/extension-image";
|
|
222
|
+
|
|
223
|
+
export function CustomTiptapField(props) {
|
|
224
|
+
const editor = useEditor({
|
|
225
|
+
extensions: [
|
|
226
|
+
StarterKit,
|
|
227
|
+
Link,
|
|
228
|
+
Image,
|
|
229
|
+
],
|
|
230
|
+
content: props.value,
|
|
231
|
+
immediatelyRender: false,
|
|
232
|
+
onUpdate: ({ editor }) => {
|
|
233
|
+
props.onChange(editor.getJSON());
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
return <EditorContent editor={editor} />;
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Then use it in your config:
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
import { registerFieldComponent } from '@opensaas/stack-ui'
|
|
245
|
+
import { CustomTiptapField } from './components/CustomTiptapField'
|
|
246
|
+
|
|
247
|
+
// Global registration
|
|
248
|
+
registerFieldComponent('richTextExtended', CustomTiptapField)
|
|
249
|
+
|
|
250
|
+
// Use in config
|
|
251
|
+
fields: {
|
|
252
|
+
content: richText({
|
|
253
|
+
ui: { fieldType: 'richTextExtended' },
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Or per-field override
|
|
258
|
+
fields: {
|
|
259
|
+
content: richText({
|
|
260
|
+
ui: { component: CustomTiptapField },
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## Architecture
|
|
266
|
+
|
|
267
|
+
This package follows OpenSaas's extensibility pattern:
|
|
268
|
+
|
|
269
|
+
1. **Field Builder** (`richText()`) - Defines field configuration
|
|
270
|
+
- Returns `RichTextField` type
|
|
271
|
+
- Implements `getZodSchema()`, `getPrismaType()`, `getTypeScriptType()`
|
|
272
|
+
- Stores data as `Json` in Prisma
|
|
273
|
+
|
|
274
|
+
2. **React Component** (`TiptapField`) - UI implementation
|
|
275
|
+
- Client component with `"use client"` directive
|
|
276
|
+
- SSR-safe with `immediatelyRender: false`
|
|
277
|
+
- Supports edit and read modes
|
|
278
|
+
|
|
279
|
+
3. **No Core Modifications** - Extends stack without changes
|
|
280
|
+
- Uses `BaseFieldConfig` extension point
|
|
281
|
+
- Compatible with access control system
|
|
282
|
+
- Works with hooks and validation
|
|
283
|
+
|
|
284
|
+
## Example
|
|
285
|
+
|
|
286
|
+
See `examples/tiptap-demo` for a complete working example demonstrating:
|
|
287
|
+
|
|
288
|
+
- Multiple rich text fields
|
|
289
|
+
- Custom UI options
|
|
290
|
+
- Access control integration
|
|
291
|
+
- Database operations
|
|
292
|
+
|
|
293
|
+
## API Reference
|
|
294
|
+
|
|
295
|
+
### `richText(options?)`
|
|
296
|
+
|
|
297
|
+
Creates a rich text field configuration.
|
|
298
|
+
|
|
299
|
+
**Options:**
|
|
300
|
+
|
|
301
|
+
- `validation.isRequired` - Make field required (default: `false`)
|
|
302
|
+
- `ui.placeholder` - Placeholder text (default: `"Start writing..."`)
|
|
303
|
+
- `ui.minHeight` - Minimum editor height in pixels (default: `200`)
|
|
304
|
+
- `ui.maxHeight` - Maximum editor height in pixels (default: `undefined`)
|
|
305
|
+
- `ui.component` - Custom React component
|
|
306
|
+
- `ui.fieldType` - Global field type name
|
|
307
|
+
- `access` - Field-level access control
|
|
308
|
+
|
|
309
|
+
**Returns:** `RichTextField`
|
|
310
|
+
|
|
311
|
+
### `TiptapField` Component
|
|
312
|
+
|
|
313
|
+
React component for rendering the Tiptap editor.
|
|
314
|
+
|
|
315
|
+
**Props:**
|
|
316
|
+
|
|
317
|
+
- `name: string` - Field name
|
|
318
|
+
- `value: any` - JSON content value
|
|
319
|
+
- `onChange: (value: any) => void` - Change handler
|
|
320
|
+
- `label: string` - Field label
|
|
321
|
+
- `error?: string` - Validation error message
|
|
322
|
+
- `disabled?: boolean` - Disable editing
|
|
323
|
+
- `required?: boolean` - Show required indicator
|
|
324
|
+
- `mode?: "read" | "edit"` - Display mode
|
|
325
|
+
- `placeholder?: string` - Placeholder text
|
|
326
|
+
- `minHeight?: number` - Minimum height
|
|
327
|
+
- `maxHeight?: number` - Maximum height
|
|
328
|
+
|
|
329
|
+
## Contributing
|
|
330
|
+
|
|
331
|
+
Contributions are welcome! This package is part of the OpenSaas Stack monorepo.
|
|
332
|
+
|
|
333
|
+
## License
|
|
334
|
+
|
|
335
|
+
MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { UseEditorOptions } from '@tiptap/react';
|
|
2
|
+
import '../styles/tiptap.css';
|
|
3
|
+
export interface TiptapFieldProps {
|
|
4
|
+
name: string;
|
|
5
|
+
value: UseEditorOptions['content'];
|
|
6
|
+
onChange: UseEditorOptions['onUpdate'];
|
|
7
|
+
label: string;
|
|
8
|
+
error?: string;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
required?: boolean;
|
|
11
|
+
mode?: 'read' | 'edit';
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
minHeight?: number;
|
|
14
|
+
maxHeight?: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Tiptap rich text editor field component
|
|
18
|
+
* Supports both edit and read-only modes with JSON content storage
|
|
19
|
+
*/
|
|
20
|
+
export declare function TiptapField({ name, value, onChange, label, error, disabled, required, mode, placeholder, minHeight, maxHeight, }: TiptapFieldProps): import("react/jsx-runtime").JSX.Element;
|
|
21
|
+
//# sourceMappingURL=TiptapField.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TiptapField.d.ts","sourceRoot":"","sources":["../../src/components/TiptapField.tsx"],"names":[],"mappings":"AAEA,OAAO,EAA4B,gBAAgB,EAAE,MAAM,eAAe,CAAA;AAI1E,OAAO,sBAAsB,CAAA;AAE7B,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,gBAAgB,CAAC,SAAS,CAAC,CAAA;IAClC,QAAQ,EAAE,gBAAgB,CAAC,UAAU,CAAC,CAAA;IACtC,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACtB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,EAC1B,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,KAAK,EACL,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,IAAa,EACb,WAAgC,EAChC,SAAe,EACf,SAAS,GACV,EAAE,gBAAgB,2CA0JlB"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEditor, EditorContent } from '@tiptap/react';
|
|
4
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
5
|
+
import Placeholder from '@tiptap/extension-placeholder';
|
|
6
|
+
import { useEffect } from 'react';
|
|
7
|
+
import '../styles/tiptap.css';
|
|
8
|
+
/**
|
|
9
|
+
* Tiptap rich text editor field component
|
|
10
|
+
* Supports both edit and read-only modes with JSON content storage
|
|
11
|
+
*/
|
|
12
|
+
export function TiptapField({ name, value, onChange, label, error, disabled, required, mode = 'edit', placeholder = 'Start writing...', minHeight = 200, maxHeight, }) {
|
|
13
|
+
const isEditable = mode === 'edit' && !disabled;
|
|
14
|
+
const editor = useEditor({
|
|
15
|
+
extensions: [
|
|
16
|
+
StarterKit.configure({
|
|
17
|
+
heading: {
|
|
18
|
+
levels: [1, 2, 3],
|
|
19
|
+
},
|
|
20
|
+
}),
|
|
21
|
+
Placeholder.configure({
|
|
22
|
+
placeholder,
|
|
23
|
+
}),
|
|
24
|
+
],
|
|
25
|
+
content: value || undefined,
|
|
26
|
+
editable: isEditable,
|
|
27
|
+
// Don't render immediately on the server to avoid SSR issues
|
|
28
|
+
immediatelyRender: false,
|
|
29
|
+
onUpdate: (props) => {
|
|
30
|
+
if (isEditable && onChange) {
|
|
31
|
+
onChange(props);
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
editorProps: {
|
|
35
|
+
attributes: {
|
|
36
|
+
class: 'tiptap',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
// Update content when value changes externally
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (editor && value !== editor.getJSON()) {
|
|
43
|
+
editor.commands.setContent(value || '');
|
|
44
|
+
}
|
|
45
|
+
}, [editor, value]);
|
|
46
|
+
// Update editable state when mode or disabled changes
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (editor) {
|
|
49
|
+
editor.setEditable(isEditable);
|
|
50
|
+
}
|
|
51
|
+
}, [editor, isEditable]);
|
|
52
|
+
if (mode === 'read') {
|
|
53
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsx("label", { className: "text-sm font-medium text-foreground", children: label }), _jsx("div", { className: "tiptap-read-only", children: _jsx(EditorContent, { editor: editor }) })] }));
|
|
54
|
+
}
|
|
55
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsxs("label", { htmlFor: name, className: "text-sm font-medium text-foreground flex items-center gap-1", children: [label, required && _jsx("span", { className: "text-destructive", children: "*" })] }), _jsxs("div", { className: `tiptap-editor ${error ? 'border-destructive' : ''}`, children: [isEditable && editor && (_jsxs("div", { className: "tiptap-toolbar", children: [_jsx("button", { type: "button", onClick: () => editor.chain().focus().toggleBold().run(), disabled: !editor.can().chain().focus().toggleBold().run(), className: editor.isActive('bold') ? 'is-active' : '', children: _jsx("strong", { children: "Bold" }) }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().toggleItalic().run(), disabled: !editor.can().chain().focus().toggleItalic().run(), className: editor.isActive('italic') ? 'is-active' : '', children: _jsx("em", { children: "Italic" }) }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().toggleStrike().run(), disabled: !editor.can().chain().focus().toggleStrike().run(), className: editor.isActive('strike') ? 'is-active' : '', children: _jsx("s", { children: "Strike" }) }), _jsx("div", { className: "tiptap-divider" }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), className: editor.isActive('heading', { level: 1 }) ? 'is-active' : '', children: "H1" }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), className: editor.isActive('heading', { level: 2 }) ? 'is-active' : '', children: "H2" }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), className: editor.isActive('heading', { level: 3 }) ? 'is-active' : '', children: "H3" }), _jsx("div", { className: "tiptap-divider" }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().toggleBulletList().run(), className: editor.isActive('bulletList') ? 'is-active' : '', children: "Bullet List" }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().toggleOrderedList().run(), className: editor.isActive('orderedList') ? 'is-active' : '', children: "Ordered List" }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().toggleBlockquote().run(), className: editor.isActive('blockquote') ? 'is-active' : '', children: "Quote" })] })), _jsx("div", { className: disabled ? 'opacity-50 cursor-not-allowed' : '', style: {
|
|
56
|
+
minHeight: `${minHeight}px`,
|
|
57
|
+
maxHeight: maxHeight ? `${maxHeight}px` : undefined,
|
|
58
|
+
overflowY: maxHeight ? 'auto' : undefined,
|
|
59
|
+
}, children: _jsx(EditorContent, { editor: editor }) })] }), error && _jsx("p", { className: "text-sm text-destructive", children: error })] }));
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAC9C,YAAY,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { TiptapField } from './TiptapField.js';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { BaseFieldConfig } from '@opensaas/stack-core';
|
|
2
|
+
/**
|
|
3
|
+
* Rich text field configuration using Tiptap editor
|
|
4
|
+
* Stores content as JSON in the database
|
|
5
|
+
*/
|
|
6
|
+
export type RichTextField = BaseFieldConfig & {
|
|
7
|
+
type: 'richText';
|
|
8
|
+
validation?: {
|
|
9
|
+
isRequired?: boolean;
|
|
10
|
+
};
|
|
11
|
+
ui?: {
|
|
12
|
+
/**
|
|
13
|
+
* Placeholder text for empty editor
|
|
14
|
+
*/
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Minimum height for editor in pixels
|
|
18
|
+
*/
|
|
19
|
+
minHeight?: number;
|
|
20
|
+
/**
|
|
21
|
+
* Maximum height for editor in pixels
|
|
22
|
+
*/
|
|
23
|
+
maxHeight?: number;
|
|
24
|
+
/**
|
|
25
|
+
* Custom React component to render this field
|
|
26
|
+
*/
|
|
27
|
+
component?: any;
|
|
28
|
+
/**
|
|
29
|
+
* Custom field type name to use from the global registry
|
|
30
|
+
*/
|
|
31
|
+
fieldType?: string;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/config/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AAE3D;;;GAGG;AACH,MAAM,MAAM,aAAa,GAAG,eAAe,GAAG;IAC5C,IAAI,EAAE,UAAU,CAAA;IAChB,UAAU,CAAC,EAAE;QACX,UAAU,CAAC,EAAE,OAAO,CAAA;KACrB,CAAA;IACD,EAAE,CAAC,EAAE;QACH;;WAEG;QACH,WAAW,CAAC,EAAE,MAAM,CAAA;QACpB;;WAEG;QACH,SAAS,CAAC,EAAE,MAAM,CAAA;QAClB;;WAEG;QACH,SAAS,CAAC,EAAE,MAAM,CAAA;QAClB;;WAEG;QAEH,SAAS,CAAC,EAAE,GAAG,CAAA;QACf;;WAEG;QACH,SAAS,CAAC,EAAE,MAAM,CAAA;KACnB,CAAA;CACF,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/fields/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AACxC,YAAY,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { richText } from './richText.js';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { RichTextField } from '../config/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Rich text field using Tiptap editor
|
|
4
|
+
* Stores content as JSON in the database
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { richText } from '@opensaas/stack-tiptap/fields'
|
|
9
|
+
*
|
|
10
|
+
* fields: {
|
|
11
|
+
* content: richText({
|
|
12
|
+
* validation: { isRequired: true },
|
|
13
|
+
* ui: {
|
|
14
|
+
* placeholder: "Write your content here...",
|
|
15
|
+
* minHeight: 200
|
|
16
|
+
* }
|
|
17
|
+
* })
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export declare function richText(options?: Omit<RichTextField, 'type'>): RichTextField;
|
|
22
|
+
//# sourceMappingURL=richText.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"richText.d.ts","sourceRoot":"","sources":["../../src/fields/richText.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAEvD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,QAAQ,CAAC,OAAO,CAAC,EAAE,IAAI,CAAC,aAAa,EAAE,MAAM,CAAC,GAAG,aAAa,CAwC7E"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Rich text field using Tiptap editor
|
|
4
|
+
* Stores content as JSON in the database
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { richText } from '@opensaas/stack-tiptap/fields'
|
|
9
|
+
*
|
|
10
|
+
* fields: {
|
|
11
|
+
* content: richText({
|
|
12
|
+
* validation: { isRequired: true },
|
|
13
|
+
* ui: {
|
|
14
|
+
* placeholder: "Write your content here...",
|
|
15
|
+
* minHeight: 200
|
|
16
|
+
* }
|
|
17
|
+
* })
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function richText(options) {
|
|
22
|
+
return {
|
|
23
|
+
type: 'richText',
|
|
24
|
+
...options,
|
|
25
|
+
getZodSchema: (fieldName, operation) => {
|
|
26
|
+
const validation = options?.validation;
|
|
27
|
+
const isRequired = validation?.isRequired;
|
|
28
|
+
// Accept any valid JSON structure from Tiptap
|
|
29
|
+
// Tiptap outputs JSONContent which is a complex nested structure
|
|
30
|
+
const baseSchema = z.any();
|
|
31
|
+
if (isRequired && operation === 'create') {
|
|
32
|
+
// For create, reject undefined
|
|
33
|
+
return baseSchema;
|
|
34
|
+
}
|
|
35
|
+
else if (isRequired && operation === 'update') {
|
|
36
|
+
// For update, allow undefined (partial updates)
|
|
37
|
+
return z.union([baseSchema, z.undefined()]);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
// Not required
|
|
41
|
+
return baseSchema.optional();
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
getPrismaType: () => {
|
|
45
|
+
const isRequired = options?.validation?.isRequired;
|
|
46
|
+
return {
|
|
47
|
+
type: 'Json',
|
|
48
|
+
modifiers: isRequired ? undefined : '?',
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
getTypeScriptType: () => {
|
|
52
|
+
const isRequired = options?.validation?.isRequired;
|
|
53
|
+
return {
|
|
54
|
+
type: 'any',
|
|
55
|
+
optional: !isRequired,
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { richText } from './fields/richText.js';
|
|
2
|
+
export { TiptapField } from './components/TiptapField.js';
|
|
3
|
+
export type { RichTextField } from './config/types.js';
|
|
4
|
+
export type { TiptapFieldProps } from './components/TiptapField.js';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAA;AAG/C,OAAO,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAA;AAGzD,YAAY,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AACtD,YAAY,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/* Tiptap Editor Styles */
|
|
2
|
+
|
|
3
|
+
/* Editor container */
|
|
4
|
+
.tiptap-editor {
|
|
5
|
+
border: 1px solid hsl(var(--border));
|
|
6
|
+
border-radius: 0.5rem;
|
|
7
|
+
background: hsl(var(--background));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/* Toolbar */
|
|
11
|
+
.tiptap-toolbar {
|
|
12
|
+
display: flex;
|
|
13
|
+
flex-wrap: wrap;
|
|
14
|
+
gap: 0.25rem;
|
|
15
|
+
padding: 0.5rem;
|
|
16
|
+
border-bottom: 1px solid hsl(var(--border));
|
|
17
|
+
background: hsl(var(--muted) / 0.3);
|
|
18
|
+
border-radius: 0.5rem 0.5rem 0 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.tiptap-toolbar button {
|
|
22
|
+
padding: 0.5rem 0.75rem;
|
|
23
|
+
font-size: 0.875rem;
|
|
24
|
+
border-radius: 0.375rem;
|
|
25
|
+
background: transparent;
|
|
26
|
+
border: none;
|
|
27
|
+
color: hsl(var(--foreground));
|
|
28
|
+
cursor: pointer;
|
|
29
|
+
transition: background-color 0.2s;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.tiptap-toolbar button:hover:not(:disabled) {
|
|
33
|
+
background: hsl(var(--accent));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.tiptap-toolbar button:disabled {
|
|
37
|
+
opacity: 0.5;
|
|
38
|
+
cursor: not-allowed;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.tiptap-toolbar button.is-active {
|
|
42
|
+
background: hsl(var(--accent));
|
|
43
|
+
font-weight: 600;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.tiptap-divider {
|
|
47
|
+
width: 1px;
|
|
48
|
+
height: 1.5rem;
|
|
49
|
+
background: hsl(var(--border));
|
|
50
|
+
margin: 0 0.25rem;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Editor content */
|
|
54
|
+
.tiptap {
|
|
55
|
+
padding: 1rem;
|
|
56
|
+
outline: none;
|
|
57
|
+
min-height: 200px;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.tiptap:focus {
|
|
61
|
+
outline: none;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* Prose styles for rich text content */
|
|
65
|
+
.tiptap p {
|
|
66
|
+
margin: 0.75rem 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.tiptap p:first-child {
|
|
70
|
+
margin-top: 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.tiptap p:last-child {
|
|
74
|
+
margin-bottom: 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.tiptap h1 {
|
|
78
|
+
font-size: 2rem;
|
|
79
|
+
font-weight: 700;
|
|
80
|
+
margin: 1.5rem 0 1rem;
|
|
81
|
+
line-height: 1.2;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.tiptap h2 {
|
|
85
|
+
font-size: 1.5rem;
|
|
86
|
+
font-weight: 600;
|
|
87
|
+
margin: 1.25rem 0 0.75rem;
|
|
88
|
+
line-height: 1.3;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.tiptap h3 {
|
|
92
|
+
font-size: 1.25rem;
|
|
93
|
+
font-weight: 600;
|
|
94
|
+
margin: 1rem 0 0.5rem;
|
|
95
|
+
line-height: 1.4;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.tiptap ul,
|
|
99
|
+
.tiptap ol {
|
|
100
|
+
padding-left: 1.5rem;
|
|
101
|
+
margin: 0.75rem 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.tiptap ul {
|
|
105
|
+
list-style-type: disc;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.tiptap ol {
|
|
109
|
+
list-style-type: decimal;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.tiptap li {
|
|
113
|
+
margin: 0.25rem 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.tiptap blockquote {
|
|
117
|
+
border-left: 3px solid hsl(var(--border));
|
|
118
|
+
padding-left: 1rem;
|
|
119
|
+
margin: 1rem 0;
|
|
120
|
+
color: hsl(var(--muted-foreground));
|
|
121
|
+
font-style: italic;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.tiptap code {
|
|
125
|
+
background: hsl(var(--muted));
|
|
126
|
+
padding: 0.125rem 0.25rem;
|
|
127
|
+
border-radius: 0.25rem;
|
|
128
|
+
font-size: 0.875em;
|
|
129
|
+
font-family: monospace;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.tiptap pre {
|
|
133
|
+
background: hsl(var(--muted));
|
|
134
|
+
padding: 1rem;
|
|
135
|
+
border-radius: 0.5rem;
|
|
136
|
+
overflow-x: auto;
|
|
137
|
+
margin: 1rem 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.tiptap pre code {
|
|
141
|
+
background: transparent;
|
|
142
|
+
padding: 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* Placeholder */
|
|
146
|
+
.tiptap p.is-editor-empty:first-child::before {
|
|
147
|
+
content: attr(data-placeholder);
|
|
148
|
+
float: left;
|
|
149
|
+
color: hsl(var(--muted-foreground));
|
|
150
|
+
pointer-events: none;
|
|
151
|
+
height: 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* Selection */
|
|
155
|
+
.tiptap ::selection {
|
|
156
|
+
background: hsl(var(--accent));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* Read-only mode */
|
|
160
|
+
.tiptap-read-only {
|
|
161
|
+
border: 1px solid hsl(var(--border));
|
|
162
|
+
border-radius: 0.5rem;
|
|
163
|
+
background: hsl(var(--muted) / 0.5);
|
|
164
|
+
padding: 1rem;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.tiptap-read-only .tiptap {
|
|
168
|
+
padding: 0;
|
|
169
|
+
min-height: auto;
|
|
170
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opensaas/stack-tiptap",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tiptap rich text editor integration for OpenSaas Stack",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./fields": {
|
|
14
|
+
"types": "./dist/fields/index.d.ts",
|
|
15
|
+
"default": "./dist/fields/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"opensaas",
|
|
20
|
+
"tiptap",
|
|
21
|
+
"rich-text",
|
|
22
|
+
"editor",
|
|
23
|
+
"nextjs"
|
|
24
|
+
],
|
|
25
|
+
"author": "",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"next": "^15.0.0 || ^16.0.0",
|
|
29
|
+
"react": "^19.0.0",
|
|
30
|
+
"react-dom": "^19.0.0",
|
|
31
|
+
"@opensaas/stack-core": "0.1.0",
|
|
32
|
+
"@opensaas/stack-ui": "0.1.0"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@tiptap/react": "^3.7.2",
|
|
36
|
+
"@tiptap/pm": "^3.7.2",
|
|
37
|
+
"@tiptap/starter-kit": "^3.7.2",
|
|
38
|
+
"@tiptap/extension-placeholder": "^3.7.2",
|
|
39
|
+
"zod": "^4.1.12"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^24.7.2",
|
|
43
|
+
"@types/react": "^19.2.2",
|
|
44
|
+
"@types/react-dom": "^19.2.2",
|
|
45
|
+
"typescript": "^5.9.3",
|
|
46
|
+
"@opensaas/stack-core": "0.1.0",
|
|
47
|
+
"@opensaas/stack-ui": "0.1.0"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsc && npm run copy:css",
|
|
51
|
+
"copy:css": "mkdir -p dist/styles && cp src/styles/tiptap.css dist/styles/",
|
|
52
|
+
"dev": "tsc --watch",
|
|
53
|
+
"clean": "rm -rf .turbo dist tsconfig.tsbuildinfo"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEditor, EditorContent, UseEditorOptions } from '@tiptap/react'
|
|
4
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
5
|
+
import Placeholder from '@tiptap/extension-placeholder'
|
|
6
|
+
import { useEffect } from 'react'
|
|
7
|
+
import '../styles/tiptap.css'
|
|
8
|
+
|
|
9
|
+
export interface TiptapFieldProps {
|
|
10
|
+
name: string
|
|
11
|
+
value: UseEditorOptions['content']
|
|
12
|
+
onChange: UseEditorOptions['onUpdate']
|
|
13
|
+
label: string
|
|
14
|
+
error?: string
|
|
15
|
+
disabled?: boolean
|
|
16
|
+
required?: boolean
|
|
17
|
+
mode?: 'read' | 'edit'
|
|
18
|
+
placeholder?: string
|
|
19
|
+
minHeight?: number
|
|
20
|
+
maxHeight?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Tiptap rich text editor field component
|
|
25
|
+
* Supports both edit and read-only modes with JSON content storage
|
|
26
|
+
*/
|
|
27
|
+
export function TiptapField({
|
|
28
|
+
name,
|
|
29
|
+
value,
|
|
30
|
+
onChange,
|
|
31
|
+
label,
|
|
32
|
+
error,
|
|
33
|
+
disabled,
|
|
34
|
+
required,
|
|
35
|
+
mode = 'edit',
|
|
36
|
+
placeholder = 'Start writing...',
|
|
37
|
+
minHeight = 200,
|
|
38
|
+
maxHeight,
|
|
39
|
+
}: TiptapFieldProps) {
|
|
40
|
+
const isEditable = mode === 'edit' && !disabled
|
|
41
|
+
|
|
42
|
+
const editor = useEditor({
|
|
43
|
+
extensions: [
|
|
44
|
+
StarterKit.configure({
|
|
45
|
+
heading: {
|
|
46
|
+
levels: [1, 2, 3],
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
Placeholder.configure({
|
|
50
|
+
placeholder,
|
|
51
|
+
}),
|
|
52
|
+
],
|
|
53
|
+
content: value || undefined,
|
|
54
|
+
editable: isEditable,
|
|
55
|
+
// Don't render immediately on the server to avoid SSR issues
|
|
56
|
+
immediatelyRender: false,
|
|
57
|
+
onUpdate: (props) => {
|
|
58
|
+
if (isEditable && onChange) {
|
|
59
|
+
onChange(props)
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
editorProps: {
|
|
63
|
+
attributes: {
|
|
64
|
+
class: 'tiptap',
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// Update content when value changes externally
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (editor && value !== editor.getJSON()) {
|
|
72
|
+
editor.commands.setContent(value || '')
|
|
73
|
+
}
|
|
74
|
+
}, [editor, value])
|
|
75
|
+
|
|
76
|
+
// Update editable state when mode or disabled changes
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (editor) {
|
|
79
|
+
editor.setEditable(isEditable)
|
|
80
|
+
}
|
|
81
|
+
}, [editor, isEditable])
|
|
82
|
+
|
|
83
|
+
if (mode === 'read') {
|
|
84
|
+
return (
|
|
85
|
+
<div className="space-y-2">
|
|
86
|
+
<label className="text-sm font-medium text-foreground">{label}</label>
|
|
87
|
+
<div className="tiptap-read-only">
|
|
88
|
+
<EditorContent editor={editor} />
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className="space-y-2">
|
|
96
|
+
<label htmlFor={name} className="text-sm font-medium text-foreground flex items-center gap-1">
|
|
97
|
+
{label}
|
|
98
|
+
{required && <span className="text-destructive">*</span>}
|
|
99
|
+
</label>
|
|
100
|
+
|
|
101
|
+
{/* Editor with toolbar */}
|
|
102
|
+
<div className={`tiptap-editor ${error ? 'border-destructive' : ''}`}>
|
|
103
|
+
{/* Toolbar */}
|
|
104
|
+
{isEditable && editor && (
|
|
105
|
+
<div className="tiptap-toolbar">
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
109
|
+
disabled={!editor.can().chain().focus().toggleBold().run()}
|
|
110
|
+
className={editor.isActive('bold') ? 'is-active' : ''}
|
|
111
|
+
>
|
|
112
|
+
<strong>Bold</strong>
|
|
113
|
+
</button>
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
117
|
+
disabled={!editor.can().chain().focus().toggleItalic().run()}
|
|
118
|
+
className={editor.isActive('italic') ? 'is-active' : ''}
|
|
119
|
+
>
|
|
120
|
+
<em>Italic</em>
|
|
121
|
+
</button>
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
onClick={() => editor.chain().focus().toggleStrike().run()}
|
|
125
|
+
disabled={!editor.can().chain().focus().toggleStrike().run()}
|
|
126
|
+
className={editor.isActive('strike') ? 'is-active' : ''}
|
|
127
|
+
>
|
|
128
|
+
<s>Strike</s>
|
|
129
|
+
</button>
|
|
130
|
+
<div className="tiptap-divider" />
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
|
134
|
+
className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
|
|
135
|
+
>
|
|
136
|
+
H1
|
|
137
|
+
</button>
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
|
141
|
+
className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
|
|
142
|
+
>
|
|
143
|
+
H2
|
|
144
|
+
</button>
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
|
148
|
+
className={editor.isActive('heading', { level: 3 }) ? 'is-active' : ''}
|
|
149
|
+
>
|
|
150
|
+
H3
|
|
151
|
+
</button>
|
|
152
|
+
<div className="tiptap-divider" />
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
156
|
+
className={editor.isActive('bulletList') ? 'is-active' : ''}
|
|
157
|
+
>
|
|
158
|
+
Bullet List
|
|
159
|
+
</button>
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
163
|
+
className={editor.isActive('orderedList') ? 'is-active' : ''}
|
|
164
|
+
>
|
|
165
|
+
Ordered List
|
|
166
|
+
</button>
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
|
170
|
+
className={editor.isActive('blockquote') ? 'is-active' : ''}
|
|
171
|
+
>
|
|
172
|
+
Quote
|
|
173
|
+
</button>
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
|
|
177
|
+
{/* Editor content */}
|
|
178
|
+
<div
|
|
179
|
+
className={disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
|
180
|
+
style={{
|
|
181
|
+
minHeight: `${minHeight}px`,
|
|
182
|
+
maxHeight: maxHeight ? `${maxHeight}px` : undefined,
|
|
183
|
+
overflowY: maxHeight ? 'auto' : undefined,
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
<EditorContent editor={editor} />
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
191
|
+
</div>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { BaseFieldConfig } from '@opensaas/stack-core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rich text field configuration using Tiptap editor
|
|
5
|
+
* Stores content as JSON in the database
|
|
6
|
+
*/
|
|
7
|
+
export type RichTextField = BaseFieldConfig & {
|
|
8
|
+
type: 'richText'
|
|
9
|
+
validation?: {
|
|
10
|
+
isRequired?: boolean
|
|
11
|
+
}
|
|
12
|
+
ui?: {
|
|
13
|
+
/**
|
|
14
|
+
* Placeholder text for empty editor
|
|
15
|
+
*/
|
|
16
|
+
placeholder?: string
|
|
17
|
+
/**
|
|
18
|
+
* Minimum height for editor in pixels
|
|
19
|
+
*/
|
|
20
|
+
minHeight?: number
|
|
21
|
+
/**
|
|
22
|
+
* Maximum height for editor in pixels
|
|
23
|
+
*/
|
|
24
|
+
maxHeight?: number
|
|
25
|
+
/**
|
|
26
|
+
* Custom React component to render this field
|
|
27
|
+
*/
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
|
+
component?: any
|
|
30
|
+
/**
|
|
31
|
+
* Custom field type name to use from the global registry
|
|
32
|
+
*/
|
|
33
|
+
fieldType?: string
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { RichTextField } from '../config/types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Rich text field using Tiptap editor
|
|
6
|
+
* Stores content as JSON in the database
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { richText } from '@opensaas/stack-tiptap/fields'
|
|
11
|
+
*
|
|
12
|
+
* fields: {
|
|
13
|
+
* content: richText({
|
|
14
|
+
* validation: { isRequired: true },
|
|
15
|
+
* ui: {
|
|
16
|
+
* placeholder: "Write your content here...",
|
|
17
|
+
* minHeight: 200
|
|
18
|
+
* }
|
|
19
|
+
* })
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function richText(options?: Omit<RichTextField, 'type'>): RichTextField {
|
|
24
|
+
return {
|
|
25
|
+
type: 'richText',
|
|
26
|
+
...options,
|
|
27
|
+
getZodSchema: (fieldName: string, operation: 'create' | 'update') => {
|
|
28
|
+
const validation = options?.validation
|
|
29
|
+
const isRequired = validation?.isRequired
|
|
30
|
+
|
|
31
|
+
// Accept any valid JSON structure from Tiptap
|
|
32
|
+
// Tiptap outputs JSONContent which is a complex nested structure
|
|
33
|
+
const baseSchema = z.any()
|
|
34
|
+
|
|
35
|
+
if (isRequired && operation === 'create') {
|
|
36
|
+
// For create, reject undefined
|
|
37
|
+
return baseSchema
|
|
38
|
+
} else if (isRequired && operation === 'update') {
|
|
39
|
+
// For update, allow undefined (partial updates)
|
|
40
|
+
return z.union([baseSchema, z.undefined()])
|
|
41
|
+
} else {
|
|
42
|
+
// Not required
|
|
43
|
+
return baseSchema.optional()
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
getPrismaType: () => {
|
|
47
|
+
const isRequired = options?.validation?.isRequired
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
type: 'Json',
|
|
51
|
+
modifiers: isRequired ? undefined : '?',
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
getTypeScriptType: () => {
|
|
55
|
+
const isRequired = options?.validation?.isRequired
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
type: 'any',
|
|
59
|
+
optional: !isRequired,
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Field builder
|
|
2
|
+
export { richText } from './fields/richText.js'
|
|
3
|
+
|
|
4
|
+
// Components
|
|
5
|
+
export { TiptapField } from './components/TiptapField.js'
|
|
6
|
+
|
|
7
|
+
// Types
|
|
8
|
+
export type { RichTextField } from './config/types.js'
|
|
9
|
+
export type { TiptapFieldProps } from './components/TiptapField.js'
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/* Tiptap Editor Styles */
|
|
2
|
+
|
|
3
|
+
/* Editor container */
|
|
4
|
+
.tiptap-editor {
|
|
5
|
+
border: 1px solid hsl(var(--border));
|
|
6
|
+
border-radius: 0.5rem;
|
|
7
|
+
background: hsl(var(--background));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/* Toolbar */
|
|
11
|
+
.tiptap-toolbar {
|
|
12
|
+
display: flex;
|
|
13
|
+
flex-wrap: wrap;
|
|
14
|
+
gap: 0.25rem;
|
|
15
|
+
padding: 0.5rem;
|
|
16
|
+
border-bottom: 1px solid hsl(var(--border));
|
|
17
|
+
background: hsl(var(--muted) / 0.3);
|
|
18
|
+
border-radius: 0.5rem 0.5rem 0 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.tiptap-toolbar button {
|
|
22
|
+
padding: 0.5rem 0.75rem;
|
|
23
|
+
font-size: 0.875rem;
|
|
24
|
+
border-radius: 0.375rem;
|
|
25
|
+
background: transparent;
|
|
26
|
+
border: none;
|
|
27
|
+
color: hsl(var(--foreground));
|
|
28
|
+
cursor: pointer;
|
|
29
|
+
transition: background-color 0.2s;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.tiptap-toolbar button:hover:not(:disabled) {
|
|
33
|
+
background: hsl(var(--accent));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.tiptap-toolbar button:disabled {
|
|
37
|
+
opacity: 0.5;
|
|
38
|
+
cursor: not-allowed;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.tiptap-toolbar button.is-active {
|
|
42
|
+
background: hsl(var(--accent));
|
|
43
|
+
font-weight: 600;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.tiptap-divider {
|
|
47
|
+
width: 1px;
|
|
48
|
+
height: 1.5rem;
|
|
49
|
+
background: hsl(var(--border));
|
|
50
|
+
margin: 0 0.25rem;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Editor content */
|
|
54
|
+
.tiptap {
|
|
55
|
+
padding: 1rem;
|
|
56
|
+
outline: none;
|
|
57
|
+
min-height: 200px;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.tiptap:focus {
|
|
61
|
+
outline: none;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* Prose styles for rich text content */
|
|
65
|
+
.tiptap p {
|
|
66
|
+
margin: 0.75rem 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.tiptap p:first-child {
|
|
70
|
+
margin-top: 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.tiptap p:last-child {
|
|
74
|
+
margin-bottom: 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.tiptap h1 {
|
|
78
|
+
font-size: 2rem;
|
|
79
|
+
font-weight: 700;
|
|
80
|
+
margin: 1.5rem 0 1rem;
|
|
81
|
+
line-height: 1.2;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.tiptap h2 {
|
|
85
|
+
font-size: 1.5rem;
|
|
86
|
+
font-weight: 600;
|
|
87
|
+
margin: 1.25rem 0 0.75rem;
|
|
88
|
+
line-height: 1.3;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.tiptap h3 {
|
|
92
|
+
font-size: 1.25rem;
|
|
93
|
+
font-weight: 600;
|
|
94
|
+
margin: 1rem 0 0.5rem;
|
|
95
|
+
line-height: 1.4;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.tiptap ul,
|
|
99
|
+
.tiptap ol {
|
|
100
|
+
padding-left: 1.5rem;
|
|
101
|
+
margin: 0.75rem 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.tiptap ul {
|
|
105
|
+
list-style-type: disc;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.tiptap ol {
|
|
109
|
+
list-style-type: decimal;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.tiptap li {
|
|
113
|
+
margin: 0.25rem 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.tiptap blockquote {
|
|
117
|
+
border-left: 3px solid hsl(var(--border));
|
|
118
|
+
padding-left: 1rem;
|
|
119
|
+
margin: 1rem 0;
|
|
120
|
+
color: hsl(var(--muted-foreground));
|
|
121
|
+
font-style: italic;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.tiptap code {
|
|
125
|
+
background: hsl(var(--muted));
|
|
126
|
+
padding: 0.125rem 0.25rem;
|
|
127
|
+
border-radius: 0.25rem;
|
|
128
|
+
font-size: 0.875em;
|
|
129
|
+
font-family: monospace;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.tiptap pre {
|
|
133
|
+
background: hsl(var(--muted));
|
|
134
|
+
padding: 1rem;
|
|
135
|
+
border-radius: 0.5rem;
|
|
136
|
+
overflow-x: auto;
|
|
137
|
+
margin: 1rem 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.tiptap pre code {
|
|
141
|
+
background: transparent;
|
|
142
|
+
padding: 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* Placeholder */
|
|
146
|
+
.tiptap p.is-editor-empty:first-child::before {
|
|
147
|
+
content: attr(data-placeholder);
|
|
148
|
+
float: left;
|
|
149
|
+
color: hsl(var(--muted-foreground));
|
|
150
|
+
pointer-events: none;
|
|
151
|
+
height: 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* Selection */
|
|
155
|
+
.tiptap ::selection {
|
|
156
|
+
background: hsl(var(--accent));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* Read-only mode */
|
|
160
|
+
.tiptap-read-only {
|
|
161
|
+
border: 1px solid hsl(var(--border));
|
|
162
|
+
border-radius: 0.5rem;
|
|
163
|
+
background: hsl(var(--muted) / 0.5);
|
|
164
|
+
padding: 1rem;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.tiptap-read-only .tiptap {
|
|
168
|
+
padding: 0;
|
|
169
|
+
min-height: auto;
|
|
170
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"resolveJsonModule": true,
|
|
8
|
+
"allowJs": true,
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"declarationMap": true,
|
|
15
|
+
"outDir": "./dist",
|
|
16
|
+
"rootDir": "./src",
|
|
17
|
+
"jsx": "react-jsx"
|
|
18
|
+
},
|
|
19
|
+
"include": ["src/**/*"],
|
|
20
|
+
"exclude": ["node_modules", "dist"]
|
|
21
|
+
}
|