mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
chore: update editor
This commit is contained in:
@@ -7,11 +7,11 @@ import { cn } from "@/lib/utils"
|
||||
import { Controlled as ControlledZoom } from "react-medium-image-zoom"
|
||||
import { ActionButton, ActionWrapper, ImageActions } from "./image-actions"
|
||||
import { useImageActions } from "../hooks/use-image-actions"
|
||||
import { blobUrlToBase64, randomId } from "@shared/editor/lib/utils"
|
||||
import { InfoCircledIcon, TrashIcon } from "@radix-ui/react-icons"
|
||||
import { ImageOverlay } from "./image-overlay"
|
||||
import type { UploadReturnType } from "../image"
|
||||
import { Spinner } from "@shared/components/spinner"
|
||||
import { blobUrlToBase64, randomId } from "@shared/editor/lib/utils"
|
||||
|
||||
const MAX_HEIGHT = 600
|
||||
const MIN_HEIGHT = 120
|
||||
@@ -42,14 +42,13 @@ export const ImageViewBlock: React.FC<NodeViewProps> = ({
|
||||
width: initialWidth,
|
||||
height: initialHeight,
|
||||
fileName,
|
||||
fileType,
|
||||
} = node.attrs
|
||||
const uploadAttemptedRef = React.useRef(false)
|
||||
|
||||
const initSrc = React.useMemo(() => {
|
||||
if (typeof initialSrc === "string") {
|
||||
return initialSrc
|
||||
}
|
||||
|
||||
return initialSrc.src
|
||||
}, [initialSrc])
|
||||
|
||||
@@ -165,54 +164,55 @@ export const ImageViewBlock: React.FC<NodeViewProps> = ({
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleImage = async () => {
|
||||
if (!initSrc.startsWith("blob:") || uploadAttemptedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
uploadAttemptedRef.current = true
|
||||
const imageExtension = editor.options.extensions.find(
|
||||
(ext) => ext.name === "image",
|
||||
)
|
||||
const { uploadFn } = imageExtension?.options ?? {}
|
||||
|
||||
if (initSrc.startsWith("blob:")) {
|
||||
if (!uploadFn) {
|
||||
try {
|
||||
const base64 = await blobUrlToBase64(initSrc)
|
||||
setImageState((prev) => ({ ...prev, src: base64 }))
|
||||
updateAttributes({ src: base64 })
|
||||
} catch {
|
||||
setImageState((prev) => ({ ...prev, error: true }))
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
setImageState((prev) => ({ ...prev, isServerUploading: true }))
|
||||
|
||||
const response = await fetch(initSrc)
|
||||
const blob = await response.blob()
|
||||
|
||||
const file = new File([blob], fileName || "image", {
|
||||
type: fileType || blob.type,
|
||||
})
|
||||
|
||||
const url: UploadReturnType = await uploadFn(file, editor)
|
||||
const normalizedData = normalizeUploadResponse(url)
|
||||
|
||||
setImageState((prev) => ({
|
||||
...prev,
|
||||
...normalizedData,
|
||||
isServerUploading: false,
|
||||
}))
|
||||
|
||||
updateAttributes(normalizedData)
|
||||
} catch {
|
||||
setImageState((prev) => ({
|
||||
...prev,
|
||||
error: true,
|
||||
isServerUploading: false,
|
||||
}))
|
||||
}
|
||||
if (!uploadFn) {
|
||||
try {
|
||||
const base64 = await blobUrlToBase64(initSrc)
|
||||
setImageState((prev) => ({ ...prev, src: base64 }))
|
||||
updateAttributes({ src: base64 })
|
||||
} catch {
|
||||
setImageState((prev) => ({ ...prev, error: true }))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setImageState((prev) => ({ ...prev, isServerUploading: true }))
|
||||
const response = await fetch(initSrc)
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], fileName, { type: blob.type })
|
||||
|
||||
const url = await uploadFn(file, editor)
|
||||
const normalizedData = normalizeUploadResponse(url)
|
||||
|
||||
setImageState((prev) => ({
|
||||
...prev,
|
||||
...normalizedData,
|
||||
isServerUploading: false,
|
||||
}))
|
||||
|
||||
updateAttributes(normalizedData)
|
||||
} catch (error) {
|
||||
console.error("Image upload failed:", error)
|
||||
setImageState((prev) => ({
|
||||
...prev,
|
||||
error: true,
|
||||
isServerUploading: false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
handleImage()
|
||||
}, [editor, fileName, fileType, initSrc, updateAttributes])
|
||||
}, [editor, fileName, initSrc, updateAttributes])
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
|
||||
@@ -5,10 +5,11 @@ 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"
|
||||
|
||||
type ImageAction = "download" | "copyImage" | "copyLink"
|
||||
@@ -57,7 +58,7 @@ interface CustomImageOptions
|
||||
onToggle?: (editor: Editor, files: File[], pos: number) => void
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
declare module "@tiptap/react" {
|
||||
interface Commands<ReturnType> {
|
||||
setImages: {
|
||||
setImages: (
|
||||
@@ -204,9 +205,6 @@ export const Image = TiptapImage.extend<CustomImageOptions>({
|
||||
fileName: {
|
||||
default: undefined,
|
||||
},
|
||||
fileType: {
|
||||
default: undefined,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -238,7 +236,6 @@ export const Image = TiptapImage.extend<CustomImageOptions>({
|
||||
alt: image.alt,
|
||||
title: image.title,
|
||||
fileName: image.src.name,
|
||||
fileType: image.src.type,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
@@ -250,7 +247,6 @@ export const Image = TiptapImage.extend<CustomImageOptions>({
|
||||
alt: image.alt,
|
||||
title: image.title,
|
||||
fileName: null,
|
||||
fileType: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -342,7 +338,8 @@ export const Image = TiptapImage.extend<CustomImageOptions>({
|
||||
|
||||
if (
|
||||
!imageInfo.src.startsWith("blob:") &&
|
||||
!imageInfo.src.startsWith("data:")
|
||||
!imageInfo.src.startsWith("data:") &&
|
||||
isUrl(imageInfo.src)
|
||||
) {
|
||||
this.options.onImageRemoved?.({
|
||||
id: imageInfo.id,
|
||||
|
||||
76
web/shared/editor/extensions/trailing-node/index.ts
Normal file
76
web/shared/editor/extensions/trailing-node/index.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Extension } from "@tiptap/react"
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state"
|
||||
import type { Node, NodeType } from "@tiptap/pm/model"
|
||||
|
||||
function nodeEqualsType({
|
||||
types,
|
||||
node,
|
||||
}: {
|
||||
types: NodeType | NodeType[]
|
||||
node: Node | null
|
||||
}) {
|
||||
if (!node) return false
|
||||
|
||||
if (Array.isArray(types)) {
|
||||
return types.includes(node.type)
|
||||
}
|
||||
|
||||
return node.type === types
|
||||
}
|
||||
|
||||
export interface TrailingNodeOptions {
|
||||
node: string
|
||||
notAfter: string[]
|
||||
}
|
||||
|
||||
export const TrailingNode = Extension.create<TrailingNodeOptions>({
|
||||
name: "trailingNode",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
node: "paragraph",
|
||||
notAfter: ["paragraph"],
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const plugin = new PluginKey(this.name)
|
||||
const disabledNodes = Object.entries(this.editor.schema.nodes)
|
||||
.map(([, value]) => value)
|
||||
.filter((node) => this.options.notAfter.includes(node.name))
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: plugin,
|
||||
appendTransaction: (_, __, state) => {
|
||||
const { doc, tr, schema } = state
|
||||
const shouldInsertNodeAtEnd = plugin.getState(state)
|
||||
const endPosition = doc.content.size
|
||||
const type = schema.nodes[this.options.node]
|
||||
|
||||
if (!shouldInsertNodeAtEnd) {
|
||||
return
|
||||
}
|
||||
|
||||
return tr.insert(endPosition, type.create())
|
||||
},
|
||||
state: {
|
||||
init: (_, state) => {
|
||||
const lastNode = state.tr.doc.lastChild
|
||||
|
||||
return !nodeEqualsType({ node: lastNode, types: disabledNodes })
|
||||
},
|
||||
apply: (tr, value) => {
|
||||
if (!tr.docChanged) {
|
||||
return value
|
||||
}
|
||||
|
||||
const lastNode = tr.doc.lastChild
|
||||
|
||||
return !nodeEqualsType({ node: lastNode, types: disabledNodes })
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user