mirror of
https://github.com/linsa-io/linsa.git
synced 2026-03-28 03:51:17 +01:00
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:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user