diff --git a/web/app/lib/schema/folder.ts b/web/app/lib/schema/folder.ts index 08aa22d6..af1cdf6c 100644 --- a/web/app/lib/schema/folder.ts +++ b/web/app/lib/schema/folder.ts @@ -1,7 +1,10 @@ import { co, CoList, ImageDefinition } from "jazz-tools" import { BaseModel } from "./base" +import { PersonalPage } from "./personal-page" export class Image extends BaseModel { + page = co.optional.ref(PersonalPage) + referenceId = co.optional.string fileName = co.optional.string fileSize = co.optional.number width = co.optional.number diff --git a/web/app/lib/schema/index.ts b/web/app/lib/schema/index.ts index 3302359f..1593853f 100644 --- a/web/app/lib/schema/index.ts +++ b/web/app/lib/schema/index.ts @@ -20,6 +20,7 @@ export class UserRoot extends CoMap { website = co.optional.string bio = co.optional.string is_public = co.optional.boolean + subscription_tier = co.optional.literal("free", "premium") personalLinks = co.ref(PersonalLinkLists) personalPages = co.ref(PersonalPageLists) diff --git a/web/app/routes/_layout/(auth)/_auth.tsx b/web/app/routes/_layout/(auth)/_auth.tsx index 35032f48..6a00b686 100644 --- a/web/app/routes/_layout/(auth)/_auth.tsx +++ b/web/app/routes/_layout/(auth)/_auth.tsx @@ -2,7 +2,7 @@ import { createFileRoute, Outlet, redirect } from "@tanstack/react-router" export const Route = createFileRoute("/_layout/(auth)/_auth")({ beforeLoad({ context }) { - if (context.auth) { + if (context.auth.userId) { throw redirect({ to: "/links", replace: true }) } }, diff --git a/web/app/routes/_layout/_pages/_protected/pages/$pageId/index.tsx b/web/app/routes/_layout/_pages/_protected/pages/$pageId/index.tsx index f097df78..a7ee2a47 100644 --- a/web/app/routes/_layout/_pages/_protected/pages/$pageId/index.tsx +++ b/web/app/routes/_layout/_pages/_protected/pages/$pageId/index.tsx @@ -1,7 +1,7 @@ import * as React from "react" import { createFileRoute, useNavigate } from "@tanstack/react-router" import { ID } from "jazz-tools" -import { PersonalPage } from "@/lib/schema" +import { LaAccount, PersonalPage } from "@/lib/schema" import { Content, EditorContent, useEditor } from "@tiptap/react" import { useAccount, useCoState } from "@/lib/providers/jazz-provider" import { EditorView } from "@tiptap/pm/view" @@ -52,7 +52,7 @@ function PageDetailComponent() { } }, [confirm, deletePage, me, pageId, navigate]) - if (!page) return null + if (!page || !me) return null return (
@@ -63,7 +63,7 @@ function PageDetailComponent() { handleDelete={handleDelete} isMobile={isMobile} /> - +
{!isMobile && ( @@ -120,7 +120,13 @@ const SidebarActions = React.memo( SidebarActions.displayName = "SidebarActions" -const DetailPageForm = React.memo(({ page }: { page: PersonalPage }) => { +const DetailPageForm = ({ + page, + me, +}: { + page: PersonalPage + me: LaAccount +}) => { const titleEditorRef = React.useRef(null) const contentEditorRef = React.useRef(null) @@ -264,6 +270,8 @@ const DetailPageForm = React.memo(({ page }: { page: PersonalPage }) => {
{
) -}) +} DetailPageForm.displayName = "DetailPageForm" diff --git a/web/package.json b/web/package.json index df0502ab..56ef268c 100644 --- a/web/package.json +++ b/web/package.json @@ -12,16 +12,16 @@ "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix" }, "dependencies": { - "@clerk/tanstack-start": "^0.4.15", - "@clerk/themes": "^2.1.37", + "@clerk/tanstack-start": "^0.4.17", + "@clerk/themes": "^2.1.39", "@dnd-kit/core": "^6.1.0", "@dnd-kit/modifiers": "^7.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/utils": "^0.17.0", "@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-avatar": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.2", @@ -39,35 +39,35 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.3", - "@tanstack/react-query": "^5.59.15", - "@tanstack/react-router": "^1.74.0", - "@tanstack/react-router-with-query": "^1.74.0", + "@tanstack/react-query": "^5.59.16", + "@tanstack/react-router": "^1.77.8", + "@tanstack/react-router-with-query": "^1.77.8", "@tanstack/react-virtual": "^3.10.8", - "@tanstack/router-zod-adapter": "^1.74.0", - "@tanstack/start": "^1.74.0", - "@tiptap/core": "^2.8.0", - "@tiptap/extension-code-block-lowlight": "^2.8.0", - "@tiptap/extension-color": "^2.8.0", - "@tiptap/extension-focus": "^2.8.0", - "@tiptap/extension-heading": "^2.8.0", - "@tiptap/extension-horizontal-rule": "^2.8.0", - "@tiptap/extension-image": "^2.8.0", - "@tiptap/extension-link": "^2.8.0", - "@tiptap/extension-placeholder": "^2.8.0", - "@tiptap/extension-task-item": "^2.8.0", - "@tiptap/extension-task-list": "^2.8.0", - "@tiptap/extension-text-style": "^2.8.0", - "@tiptap/extension-typography": "^2.8.0", - "@tiptap/pm": "^2.8.0", - "@tiptap/react": "^2.8.0", - "@tiptap/starter-kit": "^2.8.0", - "@tiptap/suggestion": "^2.8.0", + "@tanstack/router-zod-adapter": "^1.77.8", + "@tanstack/start": "^1.77.8", + "@tiptap/core": "^2.9.1", + "@tiptap/extension-code-block-lowlight": "^2.9.1", + "@tiptap/extension-color": "^2.9.1", + "@tiptap/extension-focus": "^2.9.1", + "@tiptap/extension-heading": "^2.9.1", + "@tiptap/extension-horizontal-rule": "^2.9.1", + "@tiptap/extension-image": "^2.9.1", + "@tiptap/extension-link": "^2.9.1", + "@tiptap/extension-placeholder": "^2.9.1", + "@tiptap/extension-task-item": "^2.9.1", + "@tiptap/extension-task-list": "^2.9.1", + "@tiptap/extension-text-style": "^2.9.1", + "@tiptap/extension-typography": "^2.9.1", + "@tiptap/pm": "^2.9.1", + "@tiptap/react": "^2.9.1", + "@tiptap/starter-kit": "^2.9.1", + "@tiptap/suggestion": "^2.9.1", "cheerio": "^1.0.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", "date-fns": "^3.6.0", - "framer-motion": "^11.11.9", + "framer-motion": "^11.11.10", "jazz-browser-media-images": "^0.8.7", "jazz-react": "^0.8.7", "jazz-react-auth-clerk": "^0.8.7", @@ -80,10 +80,11 @@ "react": "^18.3.1", "react-day-picker": "8.10.1", "react-dom": "^18.3.1", + "react-grid-gallery": "^1.0.1", "react-hook-form": "^7.53.1", "react-medium-image-zoom": "^5.2.10", "react-textarea-autosize": "^8.5.4", - "ronin": "^4.3.1", + "ronin": "^4.4.1", "slugify": "^1.6.6", "sonner": "^1.5.0", "streaming-markdown": "^0.0.14", @@ -94,12 +95,12 @@ "zod": "^3.23.8" }, "devDependencies": { - "@ronin/learn-anything": "^0.0.0-3457754034220", + "@ronin/learn-anything": "^0.0.0-3460430517579", "@tailwindcss/typography": "^0.5.15", - "@tanstack/react-query-devtools": "^5.59.15", - "@tanstack/router-devtools": "^1.74.0", - "@types/node": "^22.7.7", - "@types/react": "^18.3.11", + "@tanstack/react-query-devtools": "^5.59.16", + "@tanstack/router-devtools": "^1.77.8", + "@types/node": "^22.8.4", + "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^7.18.0", diff --git a/web/shared/actions.ts b/web/shared/actions.ts index c9906d86..9995bf66 100644 --- a/web/shared/actions.ts +++ b/web/shared/actions.ts @@ -1,15 +1,8 @@ import { getAuth } from "@clerk/tanstack-start/server" import { createServerFn } from "@tanstack/start" -import { create } from "ronin" +import { create, drop } from "ronin" import { z } from "zod" - -const MAX_FILE_SIZE = 1 * 1024 * 1024 -const ALLOWED_FILE_TYPES = [ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", -] +import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE } from "./constants" const ImageRuleSchema = z.object({ file: z @@ -39,8 +32,23 @@ export const storeImageFn = createServerFn( name: file.name, type: file.type, size: file.size, + width: data.get("width") ? Number(data.get("width")) : undefined, + height: data.get("height") ? Number(data.get("height")) : undefined, }) 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) + }, +) diff --git a/web/shared/constants.ts b/web/shared/constants.ts new file mode 100644 index 00000000..a5151fc5 --- /dev/null +++ b/web/shared/constants.ts @@ -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", +] diff --git a/web/shared/editor/editor.tsx b/web/shared/editor/editor.tsx index ff775eb9..9b0946eb 100644 --- a/web/shared/editor/editor.tsx +++ b/web/shared/editor/editor.tsx @@ -5,38 +5,39 @@ 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.memo( - React.forwardRef( - ({ className, editorContentClassName, ...props }, ref) => { - const editor = useLaEditor(props) +export const LaEditor = React.forwardRef( + ({ className, editorContentClassName, me, personalPage, ...props }, ref) => { + const editor = useLaEditor({ ...props, me, personalPage }) - if (!editor) { - return null - } + if (!editor) { + return null + } - return ( - - - - - ) - }, - ), + return ( + + + + + ) + }, ) LaEditor.displayName = "LaEditor" 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 0242235f..8a33d8d4 100644 --- a/web/shared/editor/extensions/image/components/image-view-block.tsx +++ b/web/shared/editor/extensions/image/components/image-view-block.tsx @@ -9,8 +9,9 @@ import { ActionButton, ActionWrapper, ImageActions } from "./image-actions" import { useImageActions } from "../hooks/use-image-actions" import { InfoCircledIcon, TrashIcon } from "@radix-ui/react-icons" import { ImageOverlay } from "./image-overlay" -import { blobUrlToBase64 } from "@shared/editor/lib/utils" +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 @@ -25,6 +26,11 @@ interface ImageState { 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 = ({ editor, node, @@ -35,9 +41,20 @@ export const ImageViewBlock: React.FC = ({ src: initialSrc, width: initialWidth, height: initialHeight, + fileName, + fileType, } = node.attrs + + const initSrc = React.useMemo(() => { + if (typeof initialSrc === "string") { + return initialSrc + } + + return initialSrc.src + }, [initialSrc]) + const [imageState, setImageState] = React.useState({ - src: initialSrc, + src: initSrc, isServerUploading: false, imageLoaded: false, isZoomed: false, @@ -153,10 +170,10 @@ export const ImageViewBlock: React.FC = ({ ) const { uploadFn } = imageExtension?.options ?? {} - if (initialSrc.startsWith("blob:")) { + if (initSrc.startsWith("blob:")) { if (!uploadFn) { try { - const base64 = await blobUrlToBase64(initialSrc) + const base64 = await blobUrlToBase64(initSrc) setImageState((prev) => ({ ...prev, src: base64 })) updateAttributes({ src: base64 }) } catch { @@ -165,13 +182,24 @@ export const ImageViewBlock: React.FC = ({ } else { try { 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) => ({ ...prev, - src: url, + ...normalizedData, isServerUploading: false, })) - updateAttributes({ src: url }) + + updateAttributes(normalizedData) } catch { setImageState((prev) => ({ ...prev, @@ -180,13 +208,11 @@ export const ImageViewBlock: React.FC = ({ })) } } - - URL.revokeObjectURL(initialSrc) } } handleImage() - }, [editor, initialSrc, updateAttributes]) + }, [editor, fileName, fileType, initSrc, updateAttributes]) return ( { - uploadFn?: (blobUrl: string, editor: Editor) => Promise - onToggle?: (editor: Editor, files: File[], pos: number) => void + uploadFn?: (file: File, editor: Editor) => Promise + onImageRemoved?: (props: ImageInfo) => void onActionSuccess?: (props: ImageActionProps) => void onActionError?: (error: Error, props: ImageActionProps) => void customDownloadImage?: ( props: ImageActionProps, options: CustomImageOptions, - ) => void + ) => Promise customCopyImage?: ( props: ImageActionProps, options: CustomImageOptions, - ) => void + ) => Promise customCopyLink?: ( props: ImageActionProps, options: CustomImageOptions, - ) => void + ) => Promise onValidationError?: (errors: FileError[]) => void + onToggle?: (editor: Editor, files: File[], pos: number) => void } declare module "@tiptap/core" { interface Commands { - toggleImage: { - toggleImage: () => ReturnType - } setImages: { setImages: ( attrs: { src: string | File; alt?: string; title?: string }[], @@ -62,6 +73,9 @@ declare module "@tiptap/core" { copyLink: { copyLink: (attrs: DownloadImageCommandProps) => ReturnType } + toggleImage: { + toggleImage: () => ReturnType + } } } @@ -69,7 +83,7 @@ const handleError = ( error: unknown, props: ImageActionProps, errorHandler?: (error: Error, props: ImageActionProps) => void, -) => { +): void => { const typedError = error instanceof Error ? error : new Error("Unknown error") errorHandler?.(typedError, props) } @@ -100,11 +114,7 @@ const handleImageUrl = async ( const fetchImageBlob = async ( src: string, ): Promise<{ blob: Blob; extension: string }> => { - if (src.startsWith("data:")) { - return handleDataUrl(src) - } else { - return handleImageUrl(src) - } + return src.startsWith("data:") ? handleDataUrl(src) : handleImageUrl(src) } const saveImage = async ( @@ -141,7 +151,7 @@ const defaultDownloadImage = async ( const defaultCopyImage = async ( props: ImageActionProps, options: CustomImageOptions, -) => { +): Promise => { const { src } = props try { const res = await fetch(src) @@ -156,7 +166,7 @@ const defaultCopyImage = async ( const defaultCopyLink = async ( props: ImageActionProps, options: CustomImageOptions, -) => { +): Promise => { const { src } = props try { await navigator.clipboard.writeText(src) @@ -182,17 +192,90 @@ export const Image = TiptapImage.extend({ addAttributes() { return { ...this.parent?.(), + id: { + default: undefined, + }, width: { default: undefined, }, height: { default: undefined, }, + fileName: { + default: undefined, + }, + fileType: { + default: undefined, + }, } }, addCommands() { 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) => { const input = document.createElement("input") input.type = "file" @@ -226,60 +309,50 @@ export const Image = TiptapImage.extend({ input.click() 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() + const newImages = new Map() + + const addToMap = (node: Node, map: Map) => { + 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() { return ReactNodeViewRenderer(ImageViewBlock, { className: "block-node", diff --git a/web/shared/editor/hooks/use-la-editor.ts b/web/shared/editor/hooks/use-la-editor.ts index 3da71786..e9ff2713 100644 --- a/web/shared/editor/hooks/use-la-editor.ts +++ b/web/shared/editor/hooks/use-la-editor.ts @@ -20,13 +20,13 @@ import { Paragraph } from "@shared/editor/extensions/paragraph" import { BulletList } from "@shared/editor/extensions/bullet-list" import { OrderedList } from "@shared/editor/extensions/ordered-list" 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 { toast } from "sonner" -import { useAccount } from "~/lib/providers/jazz-provider" import { ImageLists } from "~/lib/schema/folder" -import { LaAccount, Image as LaImage } from "~/lib/schema" -import { storeImageFn } from "@shared/actions" +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" export interface UseLaEditorProps extends Omit { @@ -41,10 +41,12 @@ export interface UseLaEditorProps } const createExtensions = ({ + personalPage, me, placeholder, }: { - me?: LaAccount + personalPage: PersonalPage + me: LaAccount placeholder: string }) => [ Heading, @@ -54,40 +56,78 @@ const createExtensions = ({ TaskItem, Selection, Paragraph, - Image.configure({ - allowedMimeTypes: ["image/*"], - maxFileSize: 5 * 1024 * 1024, + ImageExt.configure({ + allowedMimeTypes: ALLOWED_FILE_TYPES, + maxFileSize: MAX_FILE_SIZE, allowBase64: true, - uploadFn: async (blobUrl) => { - const uniqueId = Math.random().toString(36).substring(7) - const response = await fetch(blobUrl) - const blob = await response.blob() + uploadFn: async (file) => { + const dimensions = await new Promise<{ width: number; height: number }>( + (resolve, reject) => { + 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() formData.append("file", file) + formData.append("width", dimensions.width.toString()) + formData.append("height", dimensions.height.toString()) const store = await storeImageFn(formData) - if (me) { - if (!me.root?.images) { - 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) + if (!me.root?.images) { + me.root!.images = ImageLists.create([], { owner: me }) } - 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) { files.forEach((file) => @@ -130,8 +170,8 @@ const createExtensions = ({ }), FileHandler.configure({ allowBase64: true, - allowedMimeTypes: ["image/*"], - maxFileSize: 5 * 1024 * 1024, + allowedMimeTypes: ALLOWED_FILE_TYPES, + maxFileSize: MAX_FILE_SIZE, onDrop: (editor, files, pos) => { files.forEach((file) => editor.commands.insertContentAt(pos, { @@ -168,6 +208,11 @@ const createExtensions = ({ }), ] +type Props = UseLaEditorProps & { + me: LaAccount + personalPage: PersonalPage +} + export const useLaEditor = ({ value, output = "html", @@ -177,10 +222,10 @@ export const useLaEditor = ({ onUpdate, onBlur, editorProps, + me, + personalPage, ...props -}: UseLaEditorProps) => { - const { me } = useAccount({ root: { images: [] } }) - +}: Props) => { const throttledSetValue = useThrottleCallback( (editor: Editor) => { const content = getOutput(editor, output) @@ -231,7 +276,7 @@ export const useLaEditor = ({ const editorOptions: UseEditorOptions = React.useMemo( () => ({ - extensions: createExtensions({ me, placeholder }), + extensions: createExtensions({ personalPage, me, placeholder }), editorProps: mergedEditorProps, onUpdate: ({ editor }) => throttledSetValue(editor), onCreate: ({ editor }) => handleCreate(editor), @@ -239,6 +284,7 @@ export const useLaEditor = ({ ...props, }), [ + personalPage, me, placeholder, mergedEditorProps, diff --git a/web/shared/editor/lib/utils/index.ts b/web/shared/editor/lib/utils/index.ts index 8fa6be01..e20bfb93 100644 --- a/web/shared/editor/lib/utils/index.ts +++ b/web/shared/editor/lib/utils/index.ts @@ -183,4 +183,6 @@ export const filterFiles = ( return [validFiles, errors] } +export const randomId = (): string => Math.random().toString(36).slice(2, 11) + export * from "./isTextSelected"