Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/erp/genseki/collections/posts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const postEditorProviderProps = {
accept: 'image/*',
maxSize: 1024 * 1024 * 10, // 10MB
limit: 3,
pathName: 'posts/rich-text',
}),
],
}
Expand Down
3 changes: 2 additions & 1 deletion examples/erp/genseki/collections/users.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { createColumnHelper } from '@tanstack/react-table'

import type { InferFields } from '@genseki/react'
import { actionsColumn, createEditActionItem, type InferFields } from '@genseki/react'

import type { fields } from './users'

Expand All @@ -19,4 +19,5 @@ export const columns = [
columnHelper.accessor('email', {
cell: (info) => info.getValue(),
}),
actionsColumn([createEditActionItem()]),
]
1 change: 1 addition & 0 deletions examples/erp/genseki/collections/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const usersCollection = createPlugin('users', (app) => {
},
})
)
.addPageAndApiRouter(collection.update(fields))
.addApiRouter({
example: builder.endpoint(
{
Expand Down
2 changes: 1 addition & 1 deletion examples/erp/genseki/config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const app = new GensekiApp({
appBaseUrl: process.env.NEXT_PUBLIC_APP_BASE_URL || 'http://localhost:3000',
apiBaseUrl: process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000',
appPathPrefix: '/admin',
apiPathPrefix: '/admin/api',
apiPathPrefix: '/api',
storageAdapter: StorageAdapterS3.initialize(context, {
bucket: process.env.AWS_BUCKET_NAME!,
imageBaseUrl: process.env.NEXT_PUBLIC_AWS_IMAGE_URL!,
Expand Down
134 changes: 130 additions & 4 deletions examples/erp/src/app/(admin)/admin/tiptap-style.css
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
h5,
h6 {
font-size: 1rem;
font-weight: 6 00;
font-weight: 600;
}

/* Code and preformatted text styles */
Expand Down Expand Up @@ -118,7 +118,6 @@
/* max-width: 500px; */
width: 100%;
height: auto;
margin: 1em 0;
border-radius: var(--radius-lg);
}

Expand All @@ -142,18 +141,145 @@
}
}

/* Image laoded animation */
/* Image loaded animation */
.custom-image-loaded {
animation: image-loaded-fade-in 0.75s ease-out;
}

/* Selecting an image */
img.ProseMirror-selectednode,
/* Selcting custom image */

/* Selecting custom image */
.ProseMirror-selectednode img {
outline: 4px solid var(--color-primary);
}

/* Custom Image Figure Styles */
.custom-image-figure {
margin: calc(var(--spacing) * 6) 0;
position: relative;
}

/* Custom Image Loading State */
.custom-image-loading {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-bluegray-100);
min-height: 200px;
border-radius: var(--radius-lg);
margin: 1em 0;
}

.custom-image-loading-spinner {
display: flex;
flex-direction: column;
align-items: center;
gap: calc(var(--spacing) * 3);
}

.custom-image-loading-text {
font-size: 0.875rem;
color: var(--color-bluegray-500);
}

/* Custom Image Error State */
.custom-image-error {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-surface-incorrect, #fef2f2);
border: 1px solid var(--color-border-incorrect, #fecaca);
min-height: 200px;
border-radius: var(--radius-lg);
margin: 1em 0;
padding: calc(var(--spacing) * 4);
}

.custom-image-error-content {
text-align: center;
}

.custom-image-error-title {
font-weight: 600;
color: var(--color-text-incorrect, #dc2626);
margin-bottom: calc(var(--spacing) * 1);
}

.custom-image-error-message {
font-size: 0.875rem;
color: var(--color-text-incorrect, #dc2626);
}

/* Caption Styles */
.custom-image-caption {
margin-top: calc(var(--spacing) * 3);
padding: calc(var(--spacing) * 3);
text-align: center;
font-size: 0.875rem;
line-height: 1.5;
cursor: text;
border-radius: calc(var(--spacing) * 2);
transition:
background-color 0.2s ease,
color 0.2s ease;
color: var(--color-text-trivial);
}

.custom-image-caption-filled {
background-color: var(--color-bluegray-50);
}

.custom-image-caption-filled:hover {
background-color: var(--color-bluegray-100);
}

.custom-image-caption-empty {
background-color: var(--color-bluegray-50);
font-style: italic;
opacity: 0.7;
}

.custom-image-caption-empty:hover {
background-color: var(--color-bluegray-100);
opacity: 1;
}

/* Caption Editor Styles */
.custom-image-caption-editor {
margin-top: calc(var(--spacing) * 2);
}

.custom-image-caption-textarea {
width: 100%;
padding: calc(var(--spacing) * 2);
font-size: 0.875rem;
color: var(--color-text-trivial);
background-color: var(--color-white);
border: 1px solid var(--color-bluegray-200);
border-radius: calc(var(--spacing) * 1);
resize: none;
outline: none;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
font-family: inherit;
line-height: 1.5;
text-align: center;
}

.custom-image-caption-textarea:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb), 0.1);
}

.custom-image-caption-hint {
margin-top: calc(var(--spacing) * 1);
font-size: 0.75rem;
color: var(--color-text-trivial);
text-align: center;
}

.tiptap-image-upload {
--outside-padding: calc(var(--spacing) * 4);
--outside-border-radius: var(--radius-4xl);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function grabDeleteObjUrl<const TContext extends AnyContextable>(
return createEndpoint(
context,
{
method: 'DELETE',
method: 'GET',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does delete endpoint has method GET na?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint just for generate signed url object to process. It's should be GET instead of DELETE

path: '/storage/delete-obj-signed-url',
query: z.object({
key: z.string(),
Expand Down
31 changes: 19 additions & 12 deletions packages/react/src/core/richtext/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ import { toast } from 'sonner'

import type { EditorProviderClientProps, SanitizedExtension } from './types'

import { BackColorExtension, CustomImageExtension, ImageUploadNodeExtension } from '../../react'
import {
BackColorExtension,
CustomImageExtension,
generatePutObjSignedUrlData,
ImageUploadNodeExtension,
uploadObject,
} from '../../react'
import { SelectionExtension } from '../../react/components/compound/editor/extensions/selection-extension'
import type { StorageAdapterClient } from '../file-storage-adapters'

Expand Down Expand Up @@ -83,18 +89,19 @@ export const constructSanitizedExtensions = (
toast.error(error.message)
},
async upload(file) {
const key = `${crypto.randomUUID()}-${file.name}`
const name = file.name.split('.').slice(0, -1).join('.')
const fileType = file.name.split('.').pop()
const key = `${extension.options.pathName ? `${extension.options.pathName}/` : ''}${name}-${crypto.randomUUID()}.${fileType}`
const signedUrlPath = storageAdapter.grabPutObjectSignedUrlApiRoute.path
const putObjSignedUrl = await generatePutObjSignedUrlData(signedUrlPath, key)

// Upload image
const putObjSignedUrlApiRoute = storageAdapter.grabPutObjectSignedUrlApiRoute
const putObjUrlEndpoint = new URL(putObjSignedUrlApiRoute.path, window.location.origin)
putObjUrlEndpoint.searchParams.append('key', key)
const putObjSignedUrl = await fetch(putObjUrlEndpoint.toString())
const putObjSignedUrlData = await putObjSignedUrl.json()
await fetch(putObjSignedUrlData.signedUrl, {
method: 'PUT',
body: file,
})
if (!putObjSignedUrl.ok) {
throw new Error('Generating put object signed URL error: ' + putObjSignedUrl.message)
}
const uploadResult = await uploadObject(putObjSignedUrl.data.body.signedUrl, file)
if (!uploadResult.ok) {
throw new Error('File upload error: ' + uploadResult.message)
}

return { key }
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ChainedCommands } from '@tiptap/core'
import Image, { type ImageOptions } from '@tiptap/extension-image'

import type { StorageAdapterClient } from '../../../../../core/file-storage-adapters'
Expand Down Expand Up @@ -30,6 +31,90 @@ export const CustomImageExtension = Image.extend<CustomImageOptions>({
}
},
},
caption: {
default: null,
parseHTML: (element) => {
if (element.tagName === 'FIGURE') {
const figcaption = element.querySelector('figcaption')
return figcaption?.textContent || null
}
return element.getAttribute('data-caption')
},
renderHTML: (attributes) => {
if (!attributes.caption) {
return {}
}
return {
'data-caption': attributes.caption,
}
},
},
}
},

parseHTML() {
return [
{
tag: 'img[data-key]',
getAttrs: (element) => ({
'data-key': element.getAttribute('data-key'),
alt: element.getAttribute('alt'),
title: element.getAttribute('title'),
caption: element.getAttribute('data-caption'),
}),
},
{
tag: 'figure[data-type="custom-image"]',
getAttrs: (element) => {
const img = element.querySelector('img[data-key]')
const figcaption = element.querySelector('figcaption')

if (!img) return false

return {
'data-key': img.getAttribute('data-key'),
alt: img.getAttribute('alt'),
title: img.getAttribute('title'),
caption: figcaption?.textContent || null,
}
},
},
]
},

renderHTML({ HTMLAttributes }) {
const { caption, ...imgAttributes } = HTMLAttributes

if (caption) {
return [
'figure',
{ 'data-type': 'custom-image', class: 'custom-image-with-caption' },
['img', imgAttributes],
['figcaption', { class: 'custom-image-caption' }, caption],
]
}

return ['img', imgAttributes]
},

addCommands() {
const parentCommands = this.parent?.() || {}

return {
...parentCommands,
setCustomImage:
(options: { 'data-key': string; alt?: string; title?: string; caption?: string }) =>
({ commands }: { commands: ChainedCommands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
})
},
updateCustomImageCaption:
(caption: string) =>
({ commands }: { commands: ChainedCommands }) => {
return commands.updateAttributes(this.name, { caption })
},
}
},
addNodeView() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export interface ImageUploadNodeOptions {
* Whether to show the progress bar or not.
*/
showProgress?: boolean

/**
* Path to store uploaded image in storage
*/
pathName?: string
}

declare module '@tiptap/react' {
Expand Down
Loading
Loading