chore: editor images

This commit is contained in:
Aslam H
2024-10-30 04:08:59 +07:00
parent fa03facf64
commit 41743d6a22
12 changed files with 364 additions and 188 deletions

View File

@@ -1,7 +1,10 @@
import { co, CoList, ImageDefinition } from "jazz-tools" import { co, CoList, ImageDefinition } from "jazz-tools"
import { BaseModel } from "./base" import { BaseModel } from "./base"
import { PersonalPage } from "./personal-page"
export class Image extends BaseModel { export class Image extends BaseModel {
page = co.optional.ref(PersonalPage)
referenceId = co.optional.string
fileName = co.optional.string fileName = co.optional.string
fileSize = co.optional.number fileSize = co.optional.number
width = co.optional.number width = co.optional.number

View File

@@ -20,6 +20,7 @@ export class UserRoot extends CoMap {
website = co.optional.string website = co.optional.string
bio = co.optional.string bio = co.optional.string
is_public = co.optional.boolean is_public = co.optional.boolean
subscription_tier = co.optional.literal("free", "premium")
personalLinks = co.ref(PersonalLinkLists) personalLinks = co.ref(PersonalLinkLists)
personalPages = co.ref(PersonalPageLists) personalPages = co.ref(PersonalPageLists)

View File

@@ -2,7 +2,7 @@ import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
export const Route = createFileRoute("/_layout/(auth)/_auth")({ export const Route = createFileRoute("/_layout/(auth)/_auth")({
beforeLoad({ context }) { beforeLoad({ context }) {
if (context.auth) { if (context.auth.userId) {
throw redirect({ to: "/links", replace: true }) throw redirect({ to: "/links", replace: true })
} }
}, },

View File

@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react"
import { createFileRoute, useNavigate } from "@tanstack/react-router" import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { ID } from "jazz-tools" import { ID } from "jazz-tools"
import { PersonalPage } from "@/lib/schema" import { LaAccount, PersonalPage } from "@/lib/schema"
import { Content, EditorContent, useEditor } from "@tiptap/react" import { Content, EditorContent, useEditor } from "@tiptap/react"
import { useAccount, useCoState } from "@/lib/providers/jazz-provider" import { useAccount, useCoState } from "@/lib/providers/jazz-provider"
import { EditorView } from "@tiptap/pm/view" import { EditorView } from "@tiptap/pm/view"
@@ -52,7 +52,7 @@ function PageDetailComponent() {
} }
}, [confirm, deletePage, me, pageId, navigate]) }, [confirm, deletePage, me, pageId, navigate])
if (!page) return null if (!page || !me) return null
return ( return (
<div className="absolute inset-0 flex flex-row overflow-hidden"> <div className="absolute inset-0 flex flex-row overflow-hidden">
@@ -63,7 +63,7 @@ function PageDetailComponent() {
handleDelete={handleDelete} handleDelete={handleDelete}
isMobile={isMobile} isMobile={isMobile}
/> />
<DetailPageForm key={pageId} page={page} /> <DetailPageForm key={pageId} page={page} me={me} />
</div> </div>
{!isMobile && ( {!isMobile && (
<SidebarActions page={page} handleDelete={handleDelete} /> <SidebarActions page={page} handleDelete={handleDelete} />
@@ -120,7 +120,13 @@ const SidebarActions = React.memo(
SidebarActions.displayName = "SidebarActions" SidebarActions.displayName = "SidebarActions"
const DetailPageForm = React.memo(({ page }: { page: PersonalPage }) => { const DetailPageForm = ({
page,
me,
}: {
page: PersonalPage
me: LaAccount
}) => {
const titleEditorRef = React.useRef<Editor | null>(null) const titleEditorRef = React.useRef<Editor | null>(null)
const contentEditorRef = React.useRef<Editor | null>(null) const contentEditorRef = React.useRef<Editor | null>(null)
@@ -264,6 +270,8 @@ const DetailPageForm = React.memo(({ page }: { page: PersonalPage }) => {
<div className="flex flex-auto flex-col"> <div className="flex flex-auto flex-col">
<div className="relative flex h-full max-w-full grow flex-col items-stretch p-0"> <div className="relative flex h-full max-w-full grow flex-col items-stretch p-0">
<LaEditor <LaEditor
me={me}
personalPage={page}
editorClassName="-mx-3.5 px-3.5 py-2.5 flex-auto focus:outline-none" editorClassName="-mx-3.5 px-3.5 py-2.5 flex-auto focus:outline-none"
value={page.content as Content} value={page.content as Content}
placeholder="Add content..." placeholder="Add content..."
@@ -280,6 +288,6 @@ const DetailPageForm = React.memo(({ page }: { page: PersonalPage }) => {
</div> </div>
</div> </div>
) )
}) }
DetailPageForm.displayName = "DetailPageForm" DetailPageForm.displayName = "DetailPageForm"

View File

@@ -12,16 +12,16 @@
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix" "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix"
}, },
"dependencies": { "dependencies": {
"@clerk/tanstack-start": "^0.4.15", "@clerk/tanstack-start": "^0.4.17",
"@clerk/themes": "^2.1.37", "@clerk/themes": "^2.1.39",
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0", "@dnd-kit/sortable": "^8.0.0",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.1",
"@nothing-but/force-graph": "^0.9.5", "@nothing-but/force-graph": "^0.9.5",
"@nothing-but/utils": "^0.17.0", "@nothing-but/utils": "^0.17.0",
"@omit/react-confirm-dialog": "^1.1.5", "@omit/react-confirm-dialog": "^1.1.5",
"@omit/react-fancy-switch": "^1.0.0", "@omit/react-fancy-switch": "^1.0.2",
"@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.2",
@@ -39,35 +39,35 @@
"@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/react-query": "^5.59.15", "@tanstack/react-query": "^5.59.16",
"@tanstack/react-router": "^1.74.0", "@tanstack/react-router": "^1.77.8",
"@tanstack/react-router-with-query": "^1.74.0", "@tanstack/react-router-with-query": "^1.77.8",
"@tanstack/react-virtual": "^3.10.8", "@tanstack/react-virtual": "^3.10.8",
"@tanstack/router-zod-adapter": "^1.74.0", "@tanstack/router-zod-adapter": "^1.77.8",
"@tanstack/start": "^1.74.0", "@tanstack/start": "^1.77.8",
"@tiptap/core": "^2.8.0", "@tiptap/core": "^2.9.1",
"@tiptap/extension-code-block-lowlight": "^2.8.0", "@tiptap/extension-code-block-lowlight": "^2.9.1",
"@tiptap/extension-color": "^2.8.0", "@tiptap/extension-color": "^2.9.1",
"@tiptap/extension-focus": "^2.8.0", "@tiptap/extension-focus": "^2.9.1",
"@tiptap/extension-heading": "^2.8.0", "@tiptap/extension-heading": "^2.9.1",
"@tiptap/extension-horizontal-rule": "^2.8.0", "@tiptap/extension-horizontal-rule": "^2.9.1",
"@tiptap/extension-image": "^2.8.0", "@tiptap/extension-image": "^2.9.1",
"@tiptap/extension-link": "^2.8.0", "@tiptap/extension-link": "^2.9.1",
"@tiptap/extension-placeholder": "^2.8.0", "@tiptap/extension-placeholder": "^2.9.1",
"@tiptap/extension-task-item": "^2.8.0", "@tiptap/extension-task-item": "^2.9.1",
"@tiptap/extension-task-list": "^2.8.0", "@tiptap/extension-task-list": "^2.9.1",
"@tiptap/extension-text-style": "^2.8.0", "@tiptap/extension-text-style": "^2.9.1",
"@tiptap/extension-typography": "^2.8.0", "@tiptap/extension-typography": "^2.9.1",
"@tiptap/pm": "^2.8.0", "@tiptap/pm": "^2.9.1",
"@tiptap/react": "^2.8.0", "@tiptap/react": "^2.9.1",
"@tiptap/starter-kit": "^2.8.0", "@tiptap/starter-kit": "^2.9.1",
"@tiptap/suggestion": "^2.8.0", "@tiptap/suggestion": "^2.9.1",
"cheerio": "^1.0.0", "cheerio": "^1.0.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"framer-motion": "^11.11.9", "framer-motion": "^11.11.10",
"jazz-browser-media-images": "^0.8.7", "jazz-browser-media-images": "^0.8.7",
"jazz-react": "^0.8.7", "jazz-react": "^0.8.7",
"jazz-react-auth-clerk": "^0.8.7", "jazz-react-auth-clerk": "^0.8.7",
@@ -80,10 +80,11 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-day-picker": "8.10.1", "react-day-picker": "8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-grid-gallery": "^1.0.1",
"react-hook-form": "^7.53.1", "react-hook-form": "^7.53.1",
"react-medium-image-zoom": "^5.2.10", "react-medium-image-zoom": "^5.2.10",
"react-textarea-autosize": "^8.5.4", "react-textarea-autosize": "^8.5.4",
"ronin": "^4.3.1", "ronin": "^4.4.1",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"sonner": "^1.5.0", "sonner": "^1.5.0",
"streaming-markdown": "^0.0.14", "streaming-markdown": "^0.0.14",
@@ -94,12 +95,12 @@
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@ronin/learn-anything": "^0.0.0-3457754034220", "@ronin/learn-anything": "^0.0.0-3460430517579",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@tanstack/react-query-devtools": "^5.59.15", "@tanstack/react-query-devtools": "^5.59.16",
"@tanstack/router-devtools": "^1.74.0", "@tanstack/router-devtools": "^1.77.8",
"@types/node": "^22.7.7", "@types/node": "^22.8.4",
"@types/react": "^18.3.11", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/eslint-plugin": "^7.18.0",

View File

@@ -1,15 +1,8 @@
import { getAuth } from "@clerk/tanstack-start/server" import { getAuth } from "@clerk/tanstack-start/server"
import { createServerFn } from "@tanstack/start" import { createServerFn } from "@tanstack/start"
import { create } from "ronin" import { create, drop } from "ronin"
import { z } from "zod" import { z } from "zod"
import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE } from "./constants"
const MAX_FILE_SIZE = 1 * 1024 * 1024
const ALLOWED_FILE_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
]
const ImageRuleSchema = z.object({ const ImageRuleSchema = z.object({
file: z file: z
@@ -39,8 +32,23 @@ export const storeImageFn = createServerFn(
name: file.name, name: file.name,
type: file.type, type: file.type,
size: file.size, size: file.size,
width: data.get("width") ? Number(data.get("width")) : undefined,
height: data.get("height") ? Number(data.get("height")) : undefined,
}) })
return { fileModel } return { fileModel }
}, },
) )
export const deleteImageFn = createServerFn(
"POST",
async (data: { id: string }, { request }) => {
const auth = await getAuth(request)
if (!auth.userId) {
throw new Error("Unauthorized")
}
await drop.image.with.id(data.id)
},
)

7
web/shared/constants.ts Normal file
View File

@@ -0,0 +1,7 @@
export const MAX_FILE_SIZE = 5 * 1024 * 1024
export const ALLOWED_FILE_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
]

View File

@@ -5,38 +5,39 @@ 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.memo( export const LaEditor = React.forwardRef<HTMLDivElement, LaEditorProps>(
React.forwardRef<HTMLDivElement, LaEditorProps>( ({ className, editorContentClassName, me, personalPage, ...props }, ref) => {
({ className, editorContentClassName, ...props }, ref) => { const editor = useLaEditor({ ...props, me, personalPage })
const editor = useLaEditor(props)
if (!editor) { if (!editor) {
return null return null
} }
return ( return (
<MeasuredContainer <MeasuredContainer
as="div" as="div"
name="editor" name="editor"
className={cn("relative flex h-full w-full grow flex-col", className)} className={cn("relative flex h-full w-full grow flex-col", className)}
ref={ref} ref={ref}
> >
<EditorContent <EditorContent
editor={editor} editor={editor}
className={cn("la-editor", editorContentClassName)} className={cn("la-editor", editorContentClassName)}
/> />
<BubbleMenu editor={editor} /> <BubbleMenu editor={editor} />
</MeasuredContainer> </MeasuredContainer>
) )
}, },
),
) )
LaEditor.displayName = "LaEditor" LaEditor.displayName = "LaEditor"

View File

@@ -9,8 +9,9 @@ import { ActionButton, ActionWrapper, ImageActions } from "./image-actions"
import { useImageActions } from "../hooks/use-image-actions" import { useImageActions } from "../hooks/use-image-actions"
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 { blobUrlToBase64 } from "@shared/editor/lib/utils" 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
@@ -25,6 +26,11 @@ interface ImageState {
naturalSize: ElementDimensions naturalSize: ElementDimensions
} }
const normalizeUploadResponse = (res: UploadReturnType) => ({
src: typeof res === "string" ? res : res.src,
id: typeof res === "string" ? randomId() : res.id,
})
export const ImageViewBlock: React.FC<NodeViewProps> = ({ export const ImageViewBlock: React.FC<NodeViewProps> = ({
editor, editor,
node, node,
@@ -35,9 +41,20 @@ export const ImageViewBlock: React.FC<NodeViewProps> = ({
src: initialSrc, src: initialSrc,
width: initialWidth, width: initialWidth,
height: initialHeight, height: initialHeight,
fileName,
fileType,
} = node.attrs } = node.attrs
const initSrc = React.useMemo(() => {
if (typeof initialSrc === "string") {
return initialSrc
}
return initialSrc.src
}, [initialSrc])
const [imageState, setImageState] = React.useState<ImageState>({ const [imageState, setImageState] = React.useState<ImageState>({
src: initialSrc, src: initSrc,
isServerUploading: false, isServerUploading: false,
imageLoaded: false, imageLoaded: false,
isZoomed: false, isZoomed: false,
@@ -153,10 +170,10 @@ export const ImageViewBlock: React.FC<NodeViewProps> = ({
) )
const { uploadFn } = imageExtension?.options ?? {} const { uploadFn } = imageExtension?.options ?? {}
if (initialSrc.startsWith("blob:")) { if (initSrc.startsWith("blob:")) {
if (!uploadFn) { if (!uploadFn) {
try { try {
const base64 = await blobUrlToBase64(initialSrc) const base64 = await blobUrlToBase64(initSrc)
setImageState((prev) => ({ ...prev, src: base64 })) setImageState((prev) => ({ ...prev, src: base64 }))
updateAttributes({ src: base64 }) updateAttributes({ src: base64 })
} catch { } catch {
@@ -165,13 +182,24 @@ export const ImageViewBlock: React.FC<NodeViewProps> = ({
} else { } else {
try { try {
setImageState((prev) => ({ ...prev, isServerUploading: true })) setImageState((prev) => ({ ...prev, isServerUploading: true }))
const url = await uploadFn(initialSrc, editor)
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) => ({ setImageState((prev) => ({
...prev, ...prev,
src: url, ...normalizedData,
isServerUploading: false, isServerUploading: false,
})) }))
updateAttributes({ src: url })
updateAttributes(normalizedData)
} catch { } catch {
setImageState((prev) => ({ setImageState((prev) => ({
...prev, ...prev,
@@ -180,13 +208,11 @@ export const ImageViewBlock: React.FC<NodeViewProps> = ({
})) }))
} }
} }
URL.revokeObjectURL(initialSrc)
} }
} }
handleImage() handleImage()
}, [editor, initialSrc, updateAttributes]) }, [editor, fileName, fileType, initSrc, updateAttributes])
return ( return (
<NodeViewWrapper <NodeViewWrapper

View File

@@ -1,53 +1,64 @@
import type { ImageOptions } from "@tiptap/extension-image" import type { ImageOptions } from "@tiptap/extension-image"
import { Image as TiptapImage } from "@tiptap/extension-image" import { Image as TiptapImage } from "@tiptap/extension-image"
import type { Editor } from "@tiptap/react" import type { Editor } from "@tiptap/react"
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 {
filterFiles, filterFiles,
sanitizeUrl, randomId,
type FileError, type FileError,
type FileValidationOptions, type FileValidationOptions,
} from "@shared/editor/lib/utils" } from "@shared/editor/lib/utils"
type ImageAction = "download" | "copyImage" | "copyLink"
interface DownloadImageCommandProps { interface DownloadImageCommandProps {
src: string src: string
alt?: string alt?: string
} }
interface ImageActionProps { interface ImageActionProps extends DownloadImageCommandProps {
src: string action: ImageAction
alt?: string
action: "download" | "copyImage" | "copyLink"
} }
type ImageInfo = {
id?: string | number
src: string
}
export type UploadReturnType =
| string
| {
id: string | number
src: string
}
interface CustomImageOptions interface CustomImageOptions
extends ImageOptions, extends ImageOptions,
Omit<FileValidationOptions, "allowBase64"> { Omit<FileValidationOptions, "allowBase64"> {
uploadFn?: (blobUrl: string, editor: Editor) => Promise<string> uploadFn?: (file: File, editor: Editor) => Promise<UploadReturnType>
onToggle?: (editor: Editor, files: File[], pos: number) => void onImageRemoved?: (props: ImageInfo) => void
onActionSuccess?: (props: ImageActionProps) => void onActionSuccess?: (props: ImageActionProps) => void
onActionError?: (error: Error, props: ImageActionProps) => void onActionError?: (error: Error, props: ImageActionProps) => void
customDownloadImage?: ( customDownloadImage?: (
props: ImageActionProps, props: ImageActionProps,
options: CustomImageOptions, options: CustomImageOptions,
) => void ) => Promise<void>
customCopyImage?: ( customCopyImage?: (
props: ImageActionProps, props: ImageActionProps,
options: CustomImageOptions, options: CustomImageOptions,
) => void ) => Promise<void>
customCopyLink?: ( customCopyLink?: (
props: ImageActionProps, props: ImageActionProps,
options: CustomImageOptions, options: CustomImageOptions,
) => void ) => Promise<void>
onValidationError?: (errors: FileError[]) => void onValidationError?: (errors: FileError[]) => void
onToggle?: (editor: Editor, files: File[], pos: number) => void
} }
declare module "@tiptap/core" { declare module "@tiptap/core" {
interface Commands<ReturnType> { interface Commands<ReturnType> {
toggleImage: {
toggleImage: () => ReturnType
}
setImages: { setImages: {
setImages: ( setImages: (
attrs: { src: string | File; alt?: string; title?: string }[], attrs: { src: string | File; alt?: string; title?: string }[],
@@ -62,6 +73,9 @@ declare module "@tiptap/core" {
copyLink: { copyLink: {
copyLink: (attrs: DownloadImageCommandProps) => ReturnType copyLink: (attrs: DownloadImageCommandProps) => ReturnType
} }
toggleImage: {
toggleImage: () => ReturnType
}
} }
} }
@@ -69,7 +83,7 @@ const handleError = (
error: unknown, error: unknown,
props: ImageActionProps, props: ImageActionProps,
errorHandler?: (error: Error, props: ImageActionProps) => void, errorHandler?: (error: Error, props: ImageActionProps) => void,
) => { ): void => {
const typedError = error instanceof Error ? error : new Error("Unknown error") const typedError = error instanceof Error ? error : new Error("Unknown error")
errorHandler?.(typedError, props) errorHandler?.(typedError, props)
} }
@@ -100,11 +114,7 @@ const handleImageUrl = async (
const fetchImageBlob = async ( const fetchImageBlob = async (
src: string, src: string,
): Promise<{ blob: Blob; extension: string }> => { ): Promise<{ blob: Blob; extension: string }> => {
if (src.startsWith("data:")) { return src.startsWith("data:") ? handleDataUrl(src) : handleImageUrl(src)
return handleDataUrl(src)
} else {
return handleImageUrl(src)
}
} }
const saveImage = async ( const saveImage = async (
@@ -141,7 +151,7 @@ const defaultDownloadImage = async (
const defaultCopyImage = async ( const defaultCopyImage = async (
props: ImageActionProps, props: ImageActionProps,
options: CustomImageOptions, options: CustomImageOptions,
) => { ): Promise<void> => {
const { src } = props const { src } = props
try { try {
const res = await fetch(src) const res = await fetch(src)
@@ -156,7 +166,7 @@ const defaultCopyImage = async (
const defaultCopyLink = async ( const defaultCopyLink = async (
props: ImageActionProps, props: ImageActionProps,
options: CustomImageOptions, options: CustomImageOptions,
) => { ): Promise<void> => {
const { src } = props const { src } = props
try { try {
await navigator.clipboard.writeText(src) await navigator.clipboard.writeText(src)
@@ -182,17 +192,90 @@ export const Image = TiptapImage.extend<CustomImageOptions>({
addAttributes() { addAttributes() {
return { return {
...this.parent?.(), ...this.parent?.(),
id: {
default: undefined,
},
width: { width: {
default: undefined, default: undefined,
}, },
height: { height: {
default: undefined, default: undefined,
}, },
fileName: {
default: undefined,
},
fileType: {
default: undefined,
},
} }
}, },
addCommands() { addCommands() {
return { return {
setImages:
(attrs) =>
({ commands }) => {
const [validImages, errors] = filterFiles(attrs, {
allowedMimeTypes: this.options.allowedMimeTypes,
maxFileSize: this.options.maxFileSize,
allowBase64: this.options.allowBase64,
})
if (errors.length > 0 && this.options.onValidationError) {
this.options.onValidationError(errors)
}
if (validImages.length > 0) {
return commands.insertContent(
validImages.map((image) => {
if (image.src instanceof File) {
const blobUrl = URL.createObjectURL(image.src)
return {
type: this.type.name,
attrs: {
id: randomId(),
src: blobUrl,
alt: image.alt,
title: image.title,
fileName: image.src.name,
fileType: image.src.type,
},
}
} else {
return {
type: this.type.name,
attrs: {
id: randomId(),
src: image.src,
alt: image.alt,
title: image.title,
fileName: null,
fileType: null,
},
}
}
}),
)
}
return false
},
downloadImage: (attrs) => () => {
const downloadFunc =
this.options.customDownloadImage || defaultDownloadImage
void downloadFunc({ ...attrs, action: "download" }, this.options)
return true
},
copyImage: (attrs) => () => {
const copyImageFunc = this.options.customCopyImage || defaultCopyImage
void copyImageFunc({ ...attrs, action: "copyImage" }, this.options)
return true
},
copyLink: (attrs) => () => {
const copyLinkFunc = this.options.customCopyLink || defaultCopyLink
void copyLinkFunc({ ...attrs, action: "copyLink" }, this.options)
return true
},
toggleImage: () => (props) => { toggleImage: () => (props) => {
const input = document.createElement("input") const input = document.createElement("input")
input.type = "file" input.type = "file"
@@ -226,60 +309,50 @@ export const Image = TiptapImage.extend<CustomImageOptions>({
input.click() input.click()
return true return true
}, },
setImages:
(attrs) =>
({ commands }) => {
const [validImages, errors] = filterFiles(attrs, {
allowedMimeTypes: this.options.allowedMimeTypes,
maxFileSize: this.options.maxFileSize,
allowBase64: this.options.allowBase64,
})
if (errors.length > 0 && this.options.onValidationError) {
this.options.onValidationError(errors)
}
if (validImages.length > 0) {
return commands.insertContent(
validImages.map((image) => {
return {
type: this.name,
attrs: {
src:
image.src instanceof File
? sanitizeUrl(URL.createObjectURL(image.src), {
allowBase64: this.options.allowBase64,
})
: image.src,
alt: image.alt,
title: image.title,
},
}
}),
)
}
return false
},
downloadImage: (attrs) => () => {
const downloadFunc =
this.options.customDownloadImage || defaultDownloadImage
void downloadFunc({ ...attrs, action: "download" }, this.options)
return true
},
copyImage: (attrs) => () => {
const copyImageFunc = this.options.customCopyImage || defaultCopyImage
void copyImageFunc({ ...attrs, action: "copyImage" }, this.options)
return true
},
copyLink: (attrs) => () => {
const copyLinkFunc = this.options.customCopyLink || defaultCopyLink
void copyLinkFunc({ ...attrs, action: "copyLink" }, this.options)
return true
},
} }
}, },
onTransaction({ transaction }) {
if (!transaction.docChanged) return
const oldDoc = transaction.before
const newDoc = transaction.doc
const oldImages = new Map<string, ImageInfo>()
const newImages = new Map<string, ImageInfo>()
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:")
) {
this.options.onImageRemoved?.({
id: imageInfo.id,
src: imageInfo.src,
})
}
}
})
},
addNodeView() { addNodeView() {
return ReactNodeViewRenderer(ImageViewBlock, { return ReactNodeViewRenderer(ImageViewBlock, {
className: "block-node", className: "block-node",

View File

@@ -20,13 +20,13 @@ import { Paragraph } from "@shared/editor/extensions/paragraph"
import { BulletList } from "@shared/editor/extensions/bullet-list" import { BulletList } from "@shared/editor/extensions/bullet-list"
import { OrderedList } from "@shared/editor/extensions/ordered-list" import { OrderedList } from "@shared/editor/extensions/ordered-list"
import { Dropcursor } from "@shared/editor/extensions/dropcursor" import { Dropcursor } from "@shared/editor/extensions/dropcursor"
import { Image } 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 { useAccount } from "~/lib/providers/jazz-provider"
import { ImageLists } from "~/lib/schema/folder" import { ImageLists } from "~/lib/schema/folder"
import { LaAccount, Image as LaImage } from "~/lib/schema" import { LaAccount, Image as LaImage, PersonalPage } from "~/lib/schema"
import { storeImageFn } from "@shared/actions" import { deleteImageFn, storeImageFn } from "@shared/actions"
import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE } from "@shared/constants"
export interface UseLaEditorProps export interface UseLaEditorProps
extends Omit<UseEditorOptions, "editorProps"> { extends Omit<UseEditorOptions, "editorProps"> {
@@ -41,10 +41,12 @@ export interface UseLaEditorProps
} }
const createExtensions = ({ const createExtensions = ({
personalPage,
me, me,
placeholder, placeholder,
}: { }: {
me?: LaAccount personalPage: PersonalPage
me: LaAccount
placeholder: string placeholder: string
}) => [ }) => [
Heading, Heading,
@@ -54,40 +56,78 @@ const createExtensions = ({
TaskItem, TaskItem,
Selection, Selection,
Paragraph, Paragraph,
Image.configure({ ImageExt.configure({
allowedMimeTypes: ["image/*"], allowedMimeTypes: ALLOWED_FILE_TYPES,
maxFileSize: 5 * 1024 * 1024, maxFileSize: MAX_FILE_SIZE,
allowBase64: true, allowBase64: true,
uploadFn: async (blobUrl) => { uploadFn: async (file) => {
const uniqueId = Math.random().toString(36).substring(7) const dimensions = await new Promise<{ width: number; height: number }>(
const response = await fetch(blobUrl) (resolve, reject) => {
const blob = await response.blob() const img = new Image()
const objectUrl = URL.createObjectURL(file)
const file = new File([blob], `${uniqueId}`, { type: blob.type }) img.onload = () => {
resolve({
width: img.naturalWidth,
height: img.naturalHeight,
})
URL.revokeObjectURL(objectUrl)
}
img.onerror = () => {
URL.revokeObjectURL(objectUrl)
reject(new Error("Failed to load image"))
}
img.src = objectUrl
},
)
const formData = new FormData() const formData = new FormData()
formData.append("file", file) formData.append("file", file)
formData.append("width", dimensions.width.toString())
formData.append("height", dimensions.height.toString())
const store = await storeImageFn(formData) const store = await storeImageFn(formData)
if (me) { if (!me.root?.images) {
if (!me.root?.images) { me.root!.images = ImageLists.create([], { owner: me })
me.root!.images = ImageLists.create([], { owner: me })
}
const img = LaImage.create(
{
url: store.fileModel.content.src,
createdAt: new Date(),
updatedAt: new Date(),
},
{ owner: me },
)
me.root!.images.push(img)
} }
return store.fileModel.content.src 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() })
}
toast.success("Image removed", {
position: "bottom-right",
description: "Image removed successfully",
})
}, },
onToggle(editor, files, pos) { onToggle(editor, files, pos) {
files.forEach((file) => files.forEach((file) =>
@@ -130,8 +170,8 @@ const createExtensions = ({
}), }),
FileHandler.configure({ FileHandler.configure({
allowBase64: true, allowBase64: true,
allowedMimeTypes: ["image/*"], allowedMimeTypes: ALLOWED_FILE_TYPES,
maxFileSize: 5 * 1024 * 1024, maxFileSize: MAX_FILE_SIZE,
onDrop: (editor, files, pos) => { onDrop: (editor, files, pos) => {
files.forEach((file) => files.forEach((file) =>
editor.commands.insertContentAt(pos, { editor.commands.insertContentAt(pos, {
@@ -168,6 +208,11 @@ const createExtensions = ({
}), }),
] ]
type Props = UseLaEditorProps & {
me: LaAccount
personalPage: PersonalPage
}
export const useLaEditor = ({ export const useLaEditor = ({
value, value,
output = "html", output = "html",
@@ -177,10 +222,10 @@ export const useLaEditor = ({
onUpdate, onUpdate,
onBlur, onBlur,
editorProps, editorProps,
me,
personalPage,
...props ...props
}: UseLaEditorProps) => { }: Props) => {
const { me } = useAccount({ root: { images: [] } })
const throttledSetValue = useThrottleCallback( const throttledSetValue = useThrottleCallback(
(editor: Editor) => { (editor: Editor) => {
const content = getOutput(editor, output) const content = getOutput(editor, output)
@@ -231,7 +276,7 @@ export const useLaEditor = ({
const editorOptions: UseEditorOptions = React.useMemo( const editorOptions: UseEditorOptions = React.useMemo(
() => ({ () => ({
extensions: createExtensions({ me, placeholder }), extensions: createExtensions({ personalPage, me, placeholder }),
editorProps: mergedEditorProps, editorProps: mergedEditorProps,
onUpdate: ({ editor }) => throttledSetValue(editor), onUpdate: ({ editor }) => throttledSetValue(editor),
onCreate: ({ editor }) => handleCreate(editor), onCreate: ({ editor }) => handleCreate(editor),
@@ -239,6 +284,7 @@ export const useLaEditor = ({
...props, ...props,
}), }),
[ [
personalPage,
me, me,
placeholder, placeholder,
mergedEditorProps, mergedEditorProps,

View File

@@ -183,4 +183,6 @@ export const filterFiles = <T extends FileInput>(
return [validFiles, errors] return [validFiles, errors]
} }
export const randomId = (): string => Math.random().toString(36).slice(2, 11)
export * from "./isTextSelected" export * from "./isTextSelected"