chore(start): update to alpha and fix editor image removed (#187)

* chore(start): update to alpha and fix editor image removed

* chore: fix image editor
This commit is contained in:
Aslam
2024-11-19 02:22:46 +07:00
committed by GitHub
parent 5e60b2d293
commit e5a0332ec9
19 changed files with 312 additions and 303 deletions

View File

@@ -3,6 +3,7 @@ import { createServerFn } from "@tanstack/start"
import { create, drop } from "ronin"
import { z } from "zod"
import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE } from "./constants"
import { getWebRequest } from "vinxi/http"
const ImageRuleSchema = z.object({
file: z
@@ -16,10 +17,10 @@ const ImageRuleSchema = z.object({
}),
})
export const storeImageFn = createServerFn(
"POST",
async (data: FormData, { request }) => {
const auth = await getAuth(request)
export const storeImageFn = createServerFn({ method: "POST" })
.validator((data: FormData) => data)
.handler(async ({ data }) => {
const auth = await getAuth(getWebRequest())
if (!auth.userId) {
throw new Error("Unauthorized")
@@ -37,18 +38,16 @@ export const storeImageFn = createServerFn(
})
return { fileModel }
},
)
})
export const deleteImageFn = createServerFn(
"POST",
async (data: { id: string }, { request }) => {
const auth = await getAuth(request)
export const deleteImageFn = createServerFn({ method: "POST" })
.validator((id: string) => id)
.handler(async ({ data }) => {
const auth = await getAuth(getWebRequest())
if (!auth.userId) {
throw new Error("Unauthorized")
}
await drop.image.with.id(data.id)
},
)
await drop.image.with.id(data)
})

View File

@@ -202,7 +202,6 @@ export const ImageViewBlock: React.FC<NodeViewProps> = ({
updateAttributes(normalizedData)
} catch (error) {
console.error("Image upload failed:", error)
setImageState((prev) => ({
...prev,
error: true,
@@ -240,7 +239,7 @@ export const ImageViewBlock: React.FC<NodeViewProps> = ({
>
<div className="h-full contain-paint">
<div className="relative h-full">
{!imageState.imageLoaded && !imageState.error && (
{imageState.isServerUploading && !imageState.error && (
<div className="absolute inset-0 flex items-center justify-center">
<Spinner className="size-7" />
</div>
@@ -279,6 +278,8 @@ export const ImageViewBlock: React.FC<NodeViewProps> = ({
onError={handleImageError}
onLoad={handleImageLoad}
alt={node.attrs.alt || ""}
title={node.attrs.title || ""}
id={node.attrs.id}
/>
</ControlledZoom>
</div>

View File

@@ -1,16 +1,16 @@
import type { ImageOptions } from "@tiptap/extension-image"
import { Image as TiptapImage } from "@tiptap/extension-image"
import type { Editor } from "@tiptap/react"
import type { Node } from "@tiptap/pm/model"
import { ReactNodeViewRenderer } from "@tiptap/react"
import { ImageViewBlock } from "./components/image-view-block"
import {
FileError,
FileValidationOptions,
filterFiles,
isUrl,
randomId,
type FileError,
type FileValidationOptions,
} from "@shared/editor/lib/utils"
import { ReplaceStep } from "@tiptap/pm/transform"
import type { Attrs } from "@tiptap/pm/model"
type ImageAction = "download" | "copyImage" | "copyLink"
@@ -23,11 +23,6 @@ interface ImageActionProps extends DownloadImageCommandProps {
action: ImageAction
}
type ImageInfo = {
id?: string | number
src: string
}
export type UploadReturnType =
| string
| {
@@ -39,18 +34,18 @@ interface CustomImageOptions
extends ImageOptions,
Omit<FileValidationOptions, "allowBase64"> {
uploadFn?: (file: File, editor: Editor) => Promise<UploadReturnType>
onImageRemoved?: (props: ImageInfo) => void
onImageRemoved?: (props: Attrs) => void
onActionSuccess?: (props: ImageActionProps) => void
onActionError?: (error: Error, props: ImageActionProps) => void
customDownloadImage?: (
downloadImage?: (
props: ImageActionProps,
options: CustomImageOptions,
) => Promise<void>
customCopyImage?: (
copyImage?: (
props: ImageActionProps,
options: CustomImageOptions,
) => Promise<void>
customCopyLink?: (
copyLink?: (
props: ImageActionProps,
options: CustomImageOptions,
) => Promise<void>
@@ -133,7 +128,7 @@ const saveImage = async (
URL.revokeObjectURL(imageURL)
}
const defaultDownloadImage = async (
const downloadImage = async (
props: ImageActionProps,
options: CustomImageOptions,
): Promise<void> => {
@@ -149,7 +144,7 @@ const defaultDownloadImage = async (
}
}
const defaultCopyImage = async (
const copyImage = async (
props: ImageActionProps,
options: CustomImageOptions,
): Promise<void> => {
@@ -164,7 +159,7 @@ const defaultCopyImage = async (
}
}
const defaultCopyLink = async (
const copyLink = async (
props: ImageActionProps,
options: CustomImageOptions,
): Promise<void> => {
@@ -187,23 +182,34 @@ export const Image = TiptapImage.extend<CustomImageOptions>({
maxFileSize: 0,
uploadFn: undefined,
onToggle: undefined,
downloadImage: undefined,
copyImage: undefined,
copyLink: undefined,
}
},
addAttributes() {
return {
...this.parent?.(),
src: {
default: null,
},
alt: {
default: null,
},
title: {
default: null,
},
id: {
default: undefined,
default: null,
},
width: {
default: undefined,
default: null,
},
height: {
default: undefined,
default: null,
},
fileName: {
default: undefined,
default: null,
},
}
},
@@ -228,10 +234,12 @@ export const Image = TiptapImage.extend<CustomImageOptions>({
validImages.map((image) => {
if (image.src instanceof File) {
const blobUrl = URL.createObjectURL(image.src)
const id = randomId()
return {
type: this.type.name,
attrs: {
id: randomId(),
id,
src: blobUrl,
alt: image.alt,
title: image.title,
@@ -256,96 +264,81 @@ export const Image = TiptapImage.extend<CustomImageOptions>({
return false
},
downloadImage: (attrs) => () => {
const downloadFunc =
this.options.customDownloadImage || defaultDownloadImage
const downloadFunc = this.options.downloadImage || downloadImage
void downloadFunc({ ...attrs, action: "download" }, this.options)
return true
},
copyImage: (attrs) => () => {
const copyImageFunc = this.options.customCopyImage || defaultCopyImage
const copyImageFunc = this.options.copyImage || copyImage
void copyImageFunc({ ...attrs, action: "copyImage" }, this.options)
return true
},
copyLink: (attrs) => () => {
const copyLinkFunc = this.options.customCopyLink || defaultCopyLink
const copyLinkFunc = this.options.copyLink || copyLink
void copyLinkFunc({ ...attrs, action: "copyLink" }, this.options)
return true
},
toggleImage: () => (props) => {
const input = document.createElement("input")
input.type = "file"
input.accept = this.options.allowedMimeTypes.join(",")
input.onchange = () => {
const files = input.files
if (!files) return
const [validImages, errors] = filterFiles(Array.from(files), {
allowedMimeTypes: this.options.allowedMimeTypes,
maxFileSize: this.options.maxFileSize,
allowBase64: this.options.allowBase64,
})
toggleImage:
() =>
({ editor }) => {
const input = document.createElement("input")
input.type = "file"
input.accept = this.options.allowedMimeTypes.join(",")
input.onchange = () => {
const files = input.files
if (!files) return
const [validImages, errors] = filterFiles(Array.from(files), {
allowedMimeTypes: this.options.allowedMimeTypes,
maxFileSize: this.options.maxFileSize,
allowBase64: this.options.allowBase64,
})
if (errors.length > 0 && this.options.onValidationError) {
this.options.onValidationError(errors)
return false
}
if (validImages.length === 0) return false
if (this.options.onToggle) {
this.options.onToggle(
editor,
validImages,
editor.state.selection.from,
)
}
if (errors.length > 0 && this.options.onValidationError) {
this.options.onValidationError(errors)
return false
}
if (validImages.length === 0) return false
if (this.options.onToggle) {
this.options.onToggle(
props.editor,
validImages,
props.editor.state.selection.from,
)
}
}
input.click()
return true
},
input.click()
return true
},
}
},
onTransaction({ transaction }) {
if (!transaction.docChanged) return
transaction.steps.forEach((step) => {
if (step instanceof ReplaceStep && step.slice.size === 0) {
const deletedPages = transaction.before.content.cut(step.from, step.to)
const oldDoc = transaction.before
const newDoc = transaction.doc
deletedPages.forEach((node) => {
if (node.type.name === "image") {
const attrs = node.attrs
const oldImages = new Map<string, ImageInfo>()
const newImages = new Map<string, ImageInfo>()
if (attrs.src.startsWith("blob:")) {
URL.revokeObjectURL(attrs.src)
}
const addToMap = (node: Node, map: Map<string, ImageInfo>) => {
if (node.type.name === "image") {
const attrs = node.attrs
if (attrs.src) {
const key = attrs.id || attrs.src
map.set(key, { id: attrs.id, src: attrs.src })
}
}
}
oldDoc.descendants((node) => addToMap(node, oldImages))
newDoc.descendants((node) => addToMap(node, newImages))
oldImages.forEach((imageInfo, key) => {
if (!newImages.has(key)) {
if (imageInfo.src.startsWith("blob:")) {
URL.revokeObjectURL(imageInfo.src)
}
if (
!imageInfo.src.startsWith("blob:") &&
!imageInfo.src.startsWith("data:") &&
isUrl(imageInfo.src)
) {
this.options.onImageRemoved?.({
id: imageInfo.id,
src: imageInfo.src,
})
}
this.options.onImageRemoved?.(attrs)
}
})
}
})
},

View File

@@ -4,7 +4,7 @@ import type { Content, EditorOptions, UseEditorOptions } from "@tiptap/react"
import { useEditor } from "@tiptap/react"
import { cn } from "@/lib/utils"
import { useThrottleCallback } from "@shared/hooks/use-throttle-callback"
import { getOutput } from "@shared/editor/lib/utils"
import { getOutput, randomId } from "@shared/editor/lib/utils"
import { StarterKit } from "@shared/editor/extensions/starter-kit"
import { TaskList } from "@shared/editor/extensions/task-list"
import { TaskItem } from "@shared/editor/extensions/task-item"
@@ -80,25 +80,39 @@ const createExtensions = ({ placeholder }: { placeholder: string }) => [
formData.append("width", dimensions.width.toString())
formData.append("height", dimensions.height.toString())
const store = await storeImageFn(formData)
const store = await storeImageFn({ data: formData })
return { id: store.fileModel.id, src: store.fileModel.content.src }
},
onImageRemoved({ id }) {
if (id) {
deleteImageFn({ id: id?.toString() })
}
onImageRemoved(props) {
if (props.id) {
deleteImageFn({ data: props.id })
toast.success("Image removed", {
position: "bottom-right",
description: "Image removed successfully",
})
console.log("Image removed", props)
toast.success("Image removed", {
position: "bottom-right",
description: "Image removed successfully",
})
}
},
onToggle(editor, files, pos) {
files.forEach((file) =>
editor.commands.insertContentAt(pos, {
type: "image",
attrs: { src: URL.createObjectURL(file) },
editor.commands.insertContentAt(
pos,
files.map((image) => {
const blobUrl = URL.createObjectURL(image)
const id = randomId()
return {
type: "image",
attrs: {
id,
src: blobUrl,
alt: image.name,
title: image.name,
fileName: image.name,
},
}
}),
)
},

View File

@@ -45,7 +45,7 @@ const ImageEditBlock = ({
formData.append("file", files[0])
try {
const response = await storeImageFn(formData)
const response = await storeImageFn({ data: formData })
editor
.chain()