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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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 })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user