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"