mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
chore: editor images
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
7
web/shared/constants.ts
Normal 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",
|
||||||
|
]
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user