diff --git a/web/shared/editor/editor.tsx b/web/shared/editor/editor.tsx index 8e06503c..d1d975f8 100644 --- a/web/shared/editor/editor.tsx +++ b/web/shared/editor/editor.tsx @@ -5,23 +5,16 @@ import { BubbleMenu } from "./components/bubble-menu" import { cn } from "@/lib/utils" import { useLaEditor, UseLaEditorProps } from "./hooks/use-la-editor" import { MeasuredContainer } from "./components/measured-container" -import { LaAccount, PersonalPage } from "~/lib/schema" export interface LaEditorProps extends UseLaEditorProps { value?: Content className?: string editorContentClassName?: string - me: LaAccount - personalPage: PersonalPage } export const LaEditor = React.forwardRef( - ({ className, editorContentClassName, me, personalPage, ...props }, ref) => { - const editor = useLaEditor({ - ...props, - me, - personalPage, - }) + ({ className, editorContentClassName, ...props }, ref) => { + const editor = useLaEditor(props) if (!editor) { return null diff --git a/web/shared/editor/extensions/image/components/image-view-block.tsx b/web/shared/editor/extensions/image/components/image-view-block.tsx index 8a33d8d4..99bd3465 100644 --- a/web/shared/editor/extensions/image/components/image-view-block.tsx +++ b/web/shared/editor/extensions/image/components/image-view-block.tsx @@ -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 = ({ 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 = ({ 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 ( void } -declare module "@tiptap/core" { +declare module "@tiptap/react" { interface Commands { setImages: { setImages: ( @@ -204,9 +205,6 @@ export const Image = TiptapImage.extend({ fileName: { default: undefined, }, - fileType: { - default: undefined, - }, } }, @@ -238,7 +236,6 @@ export const Image = TiptapImage.extend({ alt: image.alt, title: image.title, fileName: image.src.name, - fileType: image.src.type, }, } } else { @@ -250,7 +247,6 @@ export const Image = TiptapImage.extend({ alt: image.alt, title: image.title, fileName: null, - fileType: null, }, } } @@ -342,7 +338,8 @@ export const Image = TiptapImage.extend({ if ( !imageInfo.src.startsWith("blob:") && - !imageInfo.src.startsWith("data:") + !imageInfo.src.startsWith("data:") && + isUrl(imageInfo.src) ) { this.options.onImageRemoved?.({ id: imageInfo.id, diff --git a/web/shared/editor/extensions/trailing-node/index.ts b/web/shared/editor/extensions/trailing-node/index.ts new file mode 100644 index 00000000..889bce46 --- /dev/null +++ b/web/shared/editor/extensions/trailing-node/index.ts @@ -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({ + 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 }) + }, + }, + }), + ] + }, +}) diff --git a/web/shared/editor/hooks/use-la-editor.ts b/web/shared/editor/hooks/use-la-editor.ts index 93fdd456..f1413d6c 100644 --- a/web/shared/editor/hooks/use-la-editor.ts +++ b/web/shared/editor/hooks/use-la-editor.ts @@ -23,10 +23,9 @@ import { Dropcursor } from "@shared/editor/extensions/dropcursor" import { Image as ImageExt } from "../extensions/image" import { FileHandler } from "../extensions/file-handler" 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 { ALLOWED_FILE_TYPES, MAX_FILE_SIZE } from "@shared/constants" +import { TrailingNode } from "../extensions/trailing-node" export interface UseLaEditorProps extends Omit { @@ -40,15 +39,7 @@ export interface UseLaEditorProps editorProps?: EditorOptions["editorProps"] } -const createExtensions = ({ - personalPage, - me, - placeholder, -}: { - personalPage: PersonalPage - me: LaAccount - placeholder: string -}) => [ +const createExtensions = ({ placeholder }: { placeholder: string }) => [ Heading, Code, Link, @@ -56,6 +47,7 @@ const createExtensions = ({ TaskItem, Selection, Paragraph, + TrailingNode, ImageExt.configure({ allowedMimeTypes: ALLOWED_FILE_TYPES, maxFileSize: MAX_FILE_SIZE, @@ -90,36 +82,9 @@ const createExtensions = ({ 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 } }, 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) { deleteImageFn({ id: id?.toString() }) } @@ -211,10 +176,7 @@ const createExtensions = ({ }), ] -type Props = UseLaEditorProps & { - me: LaAccount - personalPage: PersonalPage -} +type Props = UseLaEditorProps export const useLaEditor = ({ value, @@ -225,8 +187,6 @@ export const useLaEditor = ({ onUpdate, onBlur, editorProps, - me, - personalPage, ...props }: Props) => { const throttledSetValue = useThrottleCallback( @@ -279,7 +239,7 @@ export const useLaEditor = ({ const editorOptions: UseEditorOptions = React.useMemo( () => ({ - extensions: createExtensions({ personalPage, me, placeholder }), + extensions: createExtensions({ placeholder }), editorProps: mergedEditorProps, onUpdate: ({ editor }) => throttledSetValue(editor), onCreate: ({ editor }) => handleCreate(editor), @@ -287,8 +247,6 @@ export const useLaEditor = ({ ...props, }), [ - personalPage, - me, placeholder, mergedEditorProps, props,