chore: update editor

This commit is contained in:
Aslam H
2024-11-13 22:27:51 +07:00
parent a44cf910b2
commit 969827072f
5 changed files with 130 additions and 106 deletions

View File

@@ -5,23 +5,16 @@ import { BubbleMenu } from "./components/bubble-menu"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useLaEditor, UseLaEditorProps } from "./hooks/use-la-editor" import { useLaEditor, UseLaEditorProps } from "./hooks/use-la-editor"
import { MeasuredContainer } from "./components/measured-container" import { MeasuredContainer } from "./components/measured-container"
import { LaAccount, PersonalPage } from "~/lib/schema"
export interface LaEditorProps extends UseLaEditorProps { export interface LaEditorProps extends UseLaEditorProps {
value?: Content value?: Content
className?: string className?: string
editorContentClassName?: string editorContentClassName?: string
me: LaAccount
personalPage: PersonalPage
} }
export const LaEditor = React.forwardRef<HTMLDivElement, LaEditorProps>( export const LaEditor = React.forwardRef<HTMLDivElement, LaEditorProps>(
({ className, editorContentClassName, me, personalPage, ...props }, ref) => { ({ className, editorContentClassName, ...props }, ref) => {
const editor = useLaEditor({ const editor = useLaEditor(props)
...props,
me,
personalPage,
})
if (!editor) { if (!editor) {
return null return null

View File

@@ -7,11 +7,11 @@ import { cn } from "@/lib/utils"
import { Controlled as ControlledZoom } from "react-medium-image-zoom" import { Controlled as ControlledZoom } from "react-medium-image-zoom"
import { ActionButton, ActionWrapper, ImageActions } from "./image-actions" import { ActionButton, ActionWrapper, ImageActions } from "./image-actions"
import { useImageActions } from "../hooks/use-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 { InfoCircledIcon, TrashIcon } from "@radix-ui/react-icons"
import { ImageOverlay } from "./image-overlay" import { ImageOverlay } from "./image-overlay"
import type { UploadReturnType } from "../image" import type { UploadReturnType } from "../image"
import { Spinner } from "@shared/components/spinner" import { Spinner } from "@shared/components/spinner"
import { blobUrlToBase64, randomId } from "@shared/editor/lib/utils"
const MAX_HEIGHT = 600 const MAX_HEIGHT = 600
const MIN_HEIGHT = 120 const MIN_HEIGHT = 120
@@ -42,14 +42,13 @@ export const ImageViewBlock: React.FC<NodeViewProps> = ({
width: initialWidth, width: initialWidth,
height: initialHeight, height: initialHeight,
fileName, fileName,
fileType,
} = node.attrs } = node.attrs
const uploadAttemptedRef = React.useRef(false)
const initSrc = React.useMemo(() => { const initSrc = React.useMemo(() => {
if (typeof initialSrc === "string") { if (typeof initialSrc === "string") {
return initialSrc return initialSrc
} }
return initialSrc.src return initialSrc.src
}, [initialSrc]) }, [initialSrc])
@@ -165,54 +164,55 @@ export const ImageViewBlock: React.FC<NodeViewProps> = ({
React.useEffect(() => { React.useEffect(() => {
const handleImage = async () => { const handleImage = async () => {
if (!initSrc.startsWith("blob:") || uploadAttemptedRef.current) {
return
}
uploadAttemptedRef.current = true
const imageExtension = editor.options.extensions.find( const imageExtension = editor.options.extensions.find(
(ext) => ext.name === "image", (ext) => ext.name === "image",
) )
const { uploadFn } = imageExtension?.options ?? {} const { uploadFn } = imageExtension?.options ?? {}
if (initSrc.startsWith("blob:")) { if (!uploadFn) {
if (!uploadFn) { try {
try { const base64 = await blobUrlToBase64(initSrc)
const base64 = await blobUrlToBase64(initSrc) setImageState((prev) => ({ ...prev, src: base64 }))
setImageState((prev) => ({ ...prev, src: base64 })) updateAttributes({ src: base64 })
updateAttributes({ src: base64 }) } catch {
} catch { setImageState((prev) => ({ ...prev, error: true }))
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,
}))
}
} }
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() handleImage()
}, [editor, fileName, fileType, initSrc, updateAttributes]) }, [editor, fileName, initSrc, updateAttributes])
return ( return (
<NodeViewWrapper <NodeViewWrapper

View File

@@ -5,10 +5,11 @@ import type { Node } from "@tiptap/pm/model"
import { ReactNodeViewRenderer } from "@tiptap/react" import { ReactNodeViewRenderer } from "@tiptap/react"
import { ImageViewBlock } from "./components/image-view-block" import { ImageViewBlock } from "./components/image-view-block"
import { import {
FileError,
FileValidationOptions,
filterFiles, filterFiles,
isUrl,
randomId, randomId,
type FileError,
type FileValidationOptions,
} from "@shared/editor/lib/utils" } from "@shared/editor/lib/utils"
type ImageAction = "download" | "copyImage" | "copyLink" type ImageAction = "download" | "copyImage" | "copyLink"
@@ -57,7 +58,7 @@ interface CustomImageOptions
onToggle?: (editor: Editor, files: File[], pos: number) => void onToggle?: (editor: Editor, files: File[], pos: number) => void
} }
declare module "@tiptap/core" { declare module "@tiptap/react" {
interface Commands<ReturnType> { interface Commands<ReturnType> {
setImages: { setImages: {
setImages: ( setImages: (
@@ -204,9 +205,6 @@ export const Image = TiptapImage.extend<CustomImageOptions>({
fileName: { fileName: {
default: undefined, default: undefined,
}, },
fileType: {
default: undefined,
},
} }
}, },
@@ -238,7 +236,6 @@ export const Image = TiptapImage.extend<CustomImageOptions>({
alt: image.alt, alt: image.alt,
title: image.title, title: image.title,
fileName: image.src.name, fileName: image.src.name,
fileType: image.src.type,
}, },
} }
} else { } else {
@@ -250,7 +247,6 @@ export const Image = TiptapImage.extend<CustomImageOptions>({
alt: image.alt, alt: image.alt,
title: image.title, title: image.title,
fileName: null, fileName: null,
fileType: null,
}, },
} }
} }
@@ -342,7 +338,8 @@ export const Image = TiptapImage.extend<CustomImageOptions>({
if ( if (
!imageInfo.src.startsWith("blob:") && !imageInfo.src.startsWith("blob:") &&
!imageInfo.src.startsWith("data:") !imageInfo.src.startsWith("data:") &&
isUrl(imageInfo.src)
) { ) {
this.options.onImageRemoved?.({ this.options.onImageRemoved?.({
id: imageInfo.id, id: imageInfo.id,

View 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 })
},
},
}),
]
},
})

View File

@@ -23,10 +23,9 @@ import { Dropcursor } from "@shared/editor/extensions/dropcursor"
import { Image as ImageExt } from "../extensions/image" import { Image as ImageExt } from "../extensions/image"
import { FileHandler } from "../extensions/file-handler" import { FileHandler } from "../extensions/file-handler"
import { toast } from "sonner" import { toast } from "sonner"
import { ImageLists } from "~/lib/schema/folder"
import { LaAccount, Image as LaImage, PersonalPage } from "~/lib/schema"
import { deleteImageFn, storeImageFn } from "@shared/actions" import { deleteImageFn, storeImageFn } from "@shared/actions"
import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE } from "@shared/constants" import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE } from "@shared/constants"
import { TrailingNode } from "../extensions/trailing-node"
export interface UseLaEditorProps export interface UseLaEditorProps
extends Omit<UseEditorOptions, "editorProps"> { extends Omit<UseEditorOptions, "editorProps"> {
@@ -40,15 +39,7 @@ export interface UseLaEditorProps
editorProps?: EditorOptions["editorProps"] editorProps?: EditorOptions["editorProps"]
} }
const createExtensions = ({ const createExtensions = ({ placeholder }: { placeholder: string }) => [
personalPage,
me,
placeholder,
}: {
personalPage: PersonalPage
me: LaAccount
placeholder: string
}) => [
Heading, Heading,
Code, Code,
Link, Link,
@@ -56,6 +47,7 @@ const createExtensions = ({
TaskItem, TaskItem,
Selection, Selection,
Paragraph, Paragraph,
TrailingNode,
ImageExt.configure({ ImageExt.configure({
allowedMimeTypes: ALLOWED_FILE_TYPES, allowedMimeTypes: ALLOWED_FILE_TYPES,
maxFileSize: MAX_FILE_SIZE, maxFileSize: MAX_FILE_SIZE,
@@ -90,36 +82,9 @@ const createExtensions = ({
const store = await storeImageFn(formData) const store = await storeImageFn(formData)
if (!me.root?.images) {
me.root!.images = ImageLists.create([], { owner: me })
}
const img = LaImage.create(
{
fileName: store.fileModel.name,
fileSize: store.fileModel.size,
width: store.fileModel.width,
height: store.fileModel.height,
page: personalPage,
referenceId: store.fileModel.id,
url: store.fileModel.content.src,
createdAt: new Date(),
updatedAt: new Date(),
},
{ owner: me },
)
me.root!.images.push(img)
return { id: store.fileModel.id, src: store.fileModel.content.src } return { id: store.fileModel.id, src: store.fileModel.content.src }
}, },
onImageRemoved({ id }) { onImageRemoved({ id }) {
const index = me.root?.images?.findIndex((item) => item?.id === id)
if (index !== undefined && index !== -1) {
me.root?.images?.splice(index, 1)
}
if (id) { if (id) {
deleteImageFn({ id: id?.toString() }) deleteImageFn({ id: id?.toString() })
} }
@@ -211,10 +176,7 @@ const createExtensions = ({
}), }),
] ]
type Props = UseLaEditorProps & { type Props = UseLaEditorProps
me: LaAccount
personalPage: PersonalPage
}
export const useLaEditor = ({ export const useLaEditor = ({
value, value,
@@ -225,8 +187,6 @@ export const useLaEditor = ({
onUpdate, onUpdate,
onBlur, onBlur,
editorProps, editorProps,
me,
personalPage,
...props ...props
}: Props) => { }: Props) => {
const throttledSetValue = useThrottleCallback( const throttledSetValue = useThrottleCallback(
@@ -279,7 +239,7 @@ export const useLaEditor = ({
const editorOptions: UseEditorOptions = React.useMemo( const editorOptions: UseEditorOptions = React.useMemo(
() => ({ () => ({
extensions: createExtensions({ personalPage, me, placeholder }), extensions: createExtensions({ placeholder }),
editorProps: mergedEditorProps, editorProps: mergedEditorProps,
onUpdate: ({ editor }) => throttledSetValue(editor), onUpdate: ({ editor }) => throttledSetValue(editor),
onCreate: ({ editor }) => handleCreate(editor), onCreate: ({ editor }) => handleCreate(editor),
@@ -287,8 +247,6 @@ export const useLaEditor = ({
...props, ...props,
}), }),
[ [
personalPage,
me,
placeholder, placeholder,
mergedEditorProps, mergedEditorProps,
props, props,