chore(start): update to alpha and fix editor image removed (#187)

* chore(start): update to alpha and fix editor image removed

* chore: fix image editor
This commit is contained in:
Aslam
2024-11-19 02:22:46 +07:00
committed by GitHub
parent 5e60b2d293
commit e5a0332ec9
19 changed files with 312 additions and 303 deletions

View File

@@ -1,36 +1,38 @@
import { defineConfig } from "@tanstack/start/config"
import tsConfigPaths from "vite-tsconfig-paths"
const is_tauri = process.env.TAURI_ENV_TARGET_TRIPLE !== undefined;
const is_tauri = process.env.TAURI_ENV_TARGET_TRIPLE !== undefined
let config = is_tauri ? defineConfig({
vite: {
envPrefix: ['VITE_', 'TAURI_ENV_*'],
build: {
target:
process.env.TAURI_ENV_PLATFORM == 'windows'
? 'chrome105'
: 'safari13',
minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false,
sourcemap: !!process.env.TAURI_ENV_DEBUG,
},
plugins: [
tsConfigPaths({
projects: ["./tsconfig.json"],
}),
],
},
server: {
preset: "static"
}
}) : defineConfig({
vite: {
plugins: [
tsConfigPaths({
projects: ["./tsconfig.json"],
}),
],
},
})
const config = is_tauri
? defineConfig({
vite: {
envPrefix: ["VITE_", "TAURI_ENV_*"],
build: {
target:
process.env.TAURI_ENV_PLATFORM == "windows"
? "chrome105"
: "safari13",
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
sourcemap: !!process.env.TAURI_ENV_DEBUG,
},
plugins: [
tsConfigPaths({
projects: ["./tsconfig.json"],
}),
],
},
server: {
preset: "static",
},
})
: defineConfig({
vite: {
plugins: [
tsConfigPaths({
projects: ["./tsconfig.json"],
}),
],
},
})
export default config;
export default config

View File

@@ -1,13 +1,12 @@
import { createServerFn } from "@tanstack/start"
import { get } from "ronin"
export const getFeatureFlag = createServerFn(
"GET",
async (data: { name: string }) => {
export const getFeatureFlag = createServerFn({ method: "GET" })
.validator((input: string) => input)
.handler(async ({ data }) => {
const response = await get.featureFlag.with({
name: data.name,
name: data,
})
return response
},
)
})

View File

@@ -5,4 +5,4 @@ import { createRouter } from "./router"
const router = createRouter()
hydrateRoot(document.getElementById("root")!, <StartClient router={router} />)
hydrateRoot(document!, <StartClient router={router} />)

View File

@@ -32,11 +32,12 @@ import { Editor } from "@tiptap/react"
import { createServerFn } from "@tanstack/start"
import { clerkClient, getAuth } from "@clerk/tanstack-start/server"
import { create } from "ronin"
import { getWebRequest } from "vinxi/http"
export const sendFeedbackFn = createServerFn(
"POST",
async (data: { content: string }, { request }) => {
const auth = await getAuth(request)
export const sendFeedbackFn = createServerFn({ method: "POST" })
.validator((content: string) => content)
.handler(async ({ data }) => {
const auth = await getAuth(getWebRequest())
if (!auth.userId) {
throw new Error("User not authenticated")
@@ -46,11 +47,10 @@ export const sendFeedbackFn = createServerFn(
telemetry: { disabled: true },
}).users.getUser(auth.userId)
await create.feedback.with({
message: data.content,
message: data,
emailFrom: user.emailAddresses[0].emailAddress,
})
},
)
})
const formSchema = z.object({
content: z.string().min(1, {
@@ -83,7 +83,7 @@ export function Feedback() {
async function onSubmit(values: z.infer<typeof formSchema>) {
try {
setIsPending(true)
await sendFeedbackFn(values)
await sendFeedbackFn({ data: values.content })
form.reset({ content: "" })
editorRef.current?.commands.clearContent()

View File

@@ -20,7 +20,7 @@ export const JournalSection: React.FC = () => {
setIsFetching(true)
if (isLoaded && isSignedIn) {
const response = await getFeatureFlag({ name: "JOURNAL" })
const response = await getFeatureFlag({ data: "JOURNAL" })
if (
user?.emailAddresses.some((email) =>

View File

@@ -32,7 +32,7 @@ export const TaskSection: React.FC = () => {
setIsFetching(true)
if (isLoaded && isSignedIn) {
const response = await getFeatureFlag({ name: "TASK" })
const response = await getFeatureFlag({ data: "TASK" })
if (
user?.emailAddresses.some((email) =>

View File

@@ -5,15 +5,9 @@ import {
ScrollRestoration,
createRootRouteWithContext,
} from "@tanstack/react-router"
import {
Body,
createServerFn,
Head,
Html,
Meta,
Scripts,
} from "@tanstack/start"
import { createServerFn, Meta, Scripts } from "@tanstack/start"
import * as React from "react"
import { getWebRequest } from "vinxi/http"
import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary.js"
import { NotFound } from "~/components/NotFound.js"
import { seo } from "~/lib/utils/seo"
@@ -28,8 +22,8 @@ export const TanStackRouterDevtools =
})),
)
export const fetchClerkAuth = createServerFn("GET", async (_, ctx) => {
const auth = await getAuth(ctx.request)
export const fetchClerkAuth = createServerFn().handler(async () => {
const auth = await getAuth(getWebRequest())
return auth
})
@@ -37,44 +31,49 @@ export const fetchClerkAuth = createServerFn("GET", async (_, ctx) => {
export const Route = createRootRouteWithContext<{
auth?: ReturnType<typeof getAuth> | null
}>()({
meta: () => [
{
charSet: "utf-8",
},
{
name: "viewport",
content: "width=device-width, initial-scale=1",
},
...seo({
title: "Learn Anything",
description:
"Discover and learn about any topic with Learn-Anything. Our free, comprehensive platform connects you to the best resources for every subject. Start learning today!",
keywords:
"learn anything, online learning, free education, educational resources, self-study, knowledge discovery, topic exploration, skill development, lifelong learning",
}),
],
links: () => [
{ rel: "stylesheet", href: appCss },
{
rel: "apple-touch-icon",
sizes: "180x180",
href: "/apple-touch-icon.png",
},
{
rel: "icon",
type: "image/png",
sizes: "32x32",
href: "/favicon-32x32.png",
},
{
rel: "icon",
type: "image/png",
sizes: "16x16",
href: "/favicon-16x16.png",
},
{ rel: "manifest", href: "/site.webmanifest", color: "#fffff" },
{ rel: "icon", href: "/favicon.ico" },
],
head() {
return {
meta: [
{
charSet: "utf-8",
},
{
name: "viewport",
content: "width=device-width, initial-scale=1",
},
...seo({
title: "Learn Anything",
description:
"Discover and learn about any topic with Learn-Anything. Our free, comprehensive platform connects you to the best resources for every subject. Start learning today!",
keywords:
"learn anything, online learning, free education, educational resources, self-study, knowledge discovery, topic exploration, skill development, lifelong learning",
}),
],
links: [
{ rel: "stylesheet", href: appCss },
{
rel: "apple-touch-icon",
sizes: "180x180",
href: "/apple-touch-icon.png",
},
{
rel: "icon",
type: "image/png",
sizes: "32x32",
href: "/favicon-32x32.png",
},
{
rel: "icon",
type: "image/png",
sizes: "16x16",
href: "/favicon-16x16.png",
},
{ rel: "manifest", href: "/site.webmanifest", color: "#fffff" },
{ rel: "icon", href: "/favicon.ico" },
],
}
},
beforeLoad: async (ctx) => {
try {
// Handle explicit null auth (logged out state)
@@ -121,20 +120,17 @@ function RootComponent() {
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<Html>
<Head>
<html lang="en" suppressHydrationWarning={true}>
<head>
<Meta />
</Head>
<Body>
</head>
<body>
{children}
<React.Suspense>
<TanStackRouterDevtools position="bottom-right" />
</React.Suspense>
<ScrollRestoration />
<TanStackRouterDevtools position="bottom-right" />
<Scripts />
</Body>
</Html>
</body>
</html>
)
}

View File

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

View File

@@ -228,7 +228,7 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
<Link
to={ensureUrlProtocol(link.url)}
target="_blank"
onClick={(e) => e.stopPropagation()}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
className="text-xs text-muted-foreground hover:text-primary"
>
<span className="line-clamp-1">{link.url}</span>

View File

@@ -191,7 +191,9 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
<Link
to={ensureUrlProtocol(personalLink.url)}
target="_blank"
onClick={(e) => e.stopPropagation()}
onClick={(e: React.MouseEvent<HTMLAnchorElement>) =>
e.stopPropagation()
}
className="mr-1 truncate text-xs hover:text-primary"
>
{personalLink.url}

View File

@@ -44,70 +44,72 @@ export const globalLinkFormExceptionRefsAtom = atom<
React.RefObject<HTMLElement>[]
>([])
export const getMetadata = createServerFn("GET", async (url: string) => {
if (!url) {
return new Response('Missing "url" query parameter', {
status: 400,
})
}
const result = urlSchema.safeParse(decodeURIComponent(url))
if (!result.success) {
throw new Error(
result.error.issues.map((issue) => issue.message).join(", "),
)
}
url = ensureUrlProtocol(decodeURIComponent(url))
try {
const response = await fetch(url, {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
export const getMetadata = createServerFn()
.validator((url: string) => url)
.handler(async ({ data: url }) => {
if (!url) {
return new Response('Missing "url" query parameter', {
status: 400,
})
}
const data = await response.text()
const $ = cheerio.load(data)
const result = urlSchema.safeParse(decodeURIComponent(url))
const metadata: Metadata = {
title:
$("title").text() ||
$('meta[property="og:title"]').attr("content") ||
DEFAULT_VALUES.TITLE,
description:
$('meta[name="description"]').attr("content") ||
$('meta[property="og:description"]').attr("content") ||
DEFAULT_VALUES.DESCRIPTION,
icon:
$('link[rel="icon"]').attr("href") ||
$('link[rel="shortcut icon"]').attr("href") ||
DEFAULT_VALUES.FAVICON,
url: url,
if (!result.success) {
throw new Error(
result.error.issues.map((issue) => issue.message).join(", "),
)
}
if (metadata.icon && !metadata.icon.startsWith("http")) {
metadata.icon = new URL(metadata.icon, url).toString()
}
url = ensureUrlProtocol(decodeURIComponent(url))
return metadata
} catch (error) {
console.error("Error fetching metadata:", error)
const defaultMetadata: Metadata = {
title: DEFAULT_VALUES.TITLE,
description: DEFAULT_VALUES.DESCRIPTION,
icon: DEFAULT_VALUES.FAVICON,
url: url,
try {
const response = await fetch(url, {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.text()
const $ = cheerio.load(data)
const metadata: Metadata = {
title:
$("title").text() ||
$('meta[property="og:title"]').attr("content") ||
DEFAULT_VALUES.TITLE,
description:
$('meta[name="description"]').attr("content") ||
$('meta[property="og:description"]').attr("content") ||
DEFAULT_VALUES.DESCRIPTION,
icon:
$('link[rel="icon"]').attr("href") ||
$('link[rel="shortcut icon"]').attr("href") ||
DEFAULT_VALUES.FAVICON,
url: url,
}
if (metadata.icon && !metadata.icon.startsWith("http")) {
metadata.icon = new URL(metadata.icon, url).toString()
}
return metadata
} catch (error) {
console.error("Error fetching metadata:", error)
const defaultMetadata: Metadata = {
title: DEFAULT_VALUES.TITLE,
description: DEFAULT_VALUES.DESCRIPTION,
icon: DEFAULT_VALUES.FAVICON,
url: url,
}
return defaultMetadata
}
return defaultMetadata
}
})
})
interface LinkFormProps extends React.ComponentPropsWithoutRef<"form"> {
onClose?: () => void
@@ -196,7 +198,7 @@ export const LinkForm: React.FC<LinkFormProps> = ({
const fetchMetadata = async (url: string) => {
setIsFetching(true)
try {
const data = await getMetadata(encodeURIComponent(url))
const data = await getMetadata({ data: encodeURIComponent(url) })
setUrlFetched(data.url)
form.setValue("url", data.url, {
shouldValidate: true,

View File

@@ -67,7 +67,7 @@ export const Route = createFileRoute("/_layout/_pages/_protected/tasks/")({
// throw new Error("Unauthorized")
// }
// const flag = await getFeatureFlag({ name: "TASK" })
// const flag = await getFeatureFlag({ data: "TASK" })
// const canAccess = context.user?.emailAddresses.some((email) =>
// flag?.emails.includes(email.emailAddress),
// )

View File

@@ -49,7 +49,7 @@ const SearchItem: React.FC<SearchItemProps> = ({
<div className="group flex items-center justify-between">
<Link
to={href}
onClick={(e) => e.stopPropagation()}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
className="text-sm font-medium hover:text-primary hover:opacity-70"
>
{title}
@@ -57,7 +57,7 @@ const SearchItem: React.FC<SearchItemProps> = ({
{subtitle && (
<Link
to={href}
onClick={(e) => e.stopPropagation()}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
className="ml-2 truncate text-xs text-muted-foreground hover:underline"
>
{subtitle}

View File

@@ -12,8 +12,8 @@
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix"
},
"dependencies": {
"@clerk/tanstack-start": "^0.4.26",
"@clerk/themes": "^2.1.43",
"@clerk/tanstack-start": "^0.4.28",
"@clerk/themes": "^2.1.45",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
@@ -28,7 +28,7 @@
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dismissable-layer": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-icons": "^1.3.1",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-scroll-area": "^1.2.1",
@@ -39,10 +39,10 @@
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4",
"@tanstack/react-router": "^1.81.4",
"@tanstack/react-router": "^1.82.0",
"@tanstack/react-virtual": "^3.10.9",
"@tanstack/router-zod-adapter": "^1.81.4",
"@tanstack/start": "^1.81.4",
"@tanstack/router-zod-adapter": "^1.81.5",
"@tanstack/start": "^1.82.0",
"@tiptap/core": "^2.9.1",
"@tiptap/extension-code-block-lowlight": "^2.9.1",
"@tiptap/extension-color": "^2.9.1",
@@ -65,11 +65,11 @@
"clsx": "^2.1.1",
"cmdk": "1.0.2",
"date-fns": "^3.6.0",
"framer-motion": "^11.11.15",
"jazz-browser-media-images": "^0.8.20",
"jazz-react": "^0.8.20",
"jazz-react-auth-clerk": "^0.8.20",
"jazz-tools": "^0.8.19",
"framer-motion": "^11.11.17",
"jazz-browser-media-images": "^0.8.22",
"jazz-react": "^0.8.22",
"jazz-react-auth-clerk": "^0.8.22",
"jazz-tools": "^0.8.21",
"jotai": "^2.10.2",
"lowlight": "^3.1.0",
"lucide-react": "^0.446.0",
@@ -92,9 +92,9 @@
"zod": "^3.23.8"
},
"devDependencies": {
"@ronin/learn-anything": "^0.0.0-3461302210631",
"@ronin/learn-anything": "^0.0.0-3461795804931",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/router-devtools": "^1.81.4",
"@tanstack/router-devtools": "^1.82.0",
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
@@ -109,7 +109,7 @@
"postcss": "^8.4.49",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.5.14",
"tailwindcss": "^3.4.14",
"tailwindcss": "^3.4.15",
"typescript": "^5.6.3",
"vite-tsconfig-paths": "^5.1.2"
},

View File

@@ -3,6 +3,7 @@ import { createServerFn } from "@tanstack/start"
import { create, drop } from "ronin"
import { z } from "zod"
import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE } from "./constants"
import { getWebRequest } from "vinxi/http"
const ImageRuleSchema = z.object({
file: z
@@ -16,10 +17,10 @@ const ImageRuleSchema = z.object({
}),
})
export const storeImageFn = createServerFn(
"POST",
async (data: FormData, { request }) => {
const auth = await getAuth(request)
export const storeImageFn = createServerFn({ method: "POST" })
.validator((data: FormData) => data)
.handler(async ({ data }) => {
const auth = await getAuth(getWebRequest())
if (!auth.userId) {
throw new Error("Unauthorized")
@@ -37,18 +38,16 @@ export const storeImageFn = createServerFn(
})
return { fileModel }
},
)
})
export const deleteImageFn = createServerFn(
"POST",
async (data: { id: string }, { request }) => {
const auth = await getAuth(request)
export const deleteImageFn = createServerFn({ method: "POST" })
.validator((id: string) => id)
.handler(async ({ data }) => {
const auth = await getAuth(getWebRequest())
if (!auth.userId) {
throw new Error("Unauthorized")
}
await drop.image.with.id(data.id)
},
)
await drop.image.with.id(data)
})

View File

@@ -202,7 +202,6 @@ export const ImageViewBlock: React.FC<NodeViewProps> = ({
updateAttributes(normalizedData)
} catch (error) {
console.error("Image upload failed:", error)
setImageState((prev) => ({
...prev,
error: true,
@@ -240,7 +239,7 @@ export const ImageViewBlock: React.FC<NodeViewProps> = ({
>
<div className="h-full contain-paint">
<div className="relative h-full">
{!imageState.imageLoaded && !imageState.error && (
{imageState.isServerUploading && !imageState.error && (
<div className="absolute inset-0 flex items-center justify-center">
<Spinner className="size-7" />
</div>
@@ -279,6 +278,8 @@ export const ImageViewBlock: React.FC<NodeViewProps> = ({
onError={handleImageError}
onLoad={handleImageLoad}
alt={node.attrs.alt || ""}
title={node.attrs.title || ""}
id={node.attrs.id}
/>
</ControlledZoom>
</div>

View File

@@ -1,16 +1,16 @@
import type { ImageOptions } from "@tiptap/extension-image"
import { Image as TiptapImage } from "@tiptap/extension-image"
import type { Editor } from "@tiptap/react"
import type { Node } from "@tiptap/pm/model"
import { ReactNodeViewRenderer } from "@tiptap/react"
import { ImageViewBlock } from "./components/image-view-block"
import {
FileError,
FileValidationOptions,
filterFiles,
isUrl,
randomId,
type FileError,
type FileValidationOptions,
} from "@shared/editor/lib/utils"
import { ReplaceStep } from "@tiptap/pm/transform"
import type { Attrs } from "@tiptap/pm/model"
type ImageAction = "download" | "copyImage" | "copyLink"
@@ -23,11 +23,6 @@ interface ImageActionProps extends DownloadImageCommandProps {
action: ImageAction
}
type ImageInfo = {
id?: string | number
src: string
}
export type UploadReturnType =
| string
| {
@@ -39,18 +34,18 @@ interface CustomImageOptions
extends ImageOptions,
Omit<FileValidationOptions, "allowBase64"> {
uploadFn?: (file: File, editor: Editor) => Promise<UploadReturnType>
onImageRemoved?: (props: ImageInfo) => void
onImageRemoved?: (props: Attrs) => void
onActionSuccess?: (props: ImageActionProps) => void
onActionError?: (error: Error, props: ImageActionProps) => void
customDownloadImage?: (
downloadImage?: (
props: ImageActionProps,
options: CustomImageOptions,
) => Promise<void>
customCopyImage?: (
copyImage?: (
props: ImageActionProps,
options: CustomImageOptions,
) => Promise<void>
customCopyLink?: (
copyLink?: (
props: ImageActionProps,
options: CustomImageOptions,
) => Promise<void>
@@ -133,7 +128,7 @@ const saveImage = async (
URL.revokeObjectURL(imageURL)
}
const defaultDownloadImage = async (
const downloadImage = async (
props: ImageActionProps,
options: CustomImageOptions,
): Promise<void> => {
@@ -149,7 +144,7 @@ const defaultDownloadImage = async (
}
}
const defaultCopyImage = async (
const copyImage = async (
props: ImageActionProps,
options: CustomImageOptions,
): Promise<void> => {
@@ -164,7 +159,7 @@ const defaultCopyImage = async (
}
}
const defaultCopyLink = async (
const copyLink = async (
props: ImageActionProps,
options: CustomImageOptions,
): Promise<void> => {
@@ -187,23 +182,34 @@ export const Image = TiptapImage.extend<CustomImageOptions>({
maxFileSize: 0,
uploadFn: undefined,
onToggle: undefined,
downloadImage: undefined,
copyImage: undefined,
copyLink: undefined,
}
},
addAttributes() {
return {
...this.parent?.(),
src: {
default: null,
},
alt: {
default: null,
},
title: {
default: null,
},
id: {
default: undefined,
default: null,
},
width: {
default: undefined,
default: null,
},
height: {
default: undefined,
default: null,
},
fileName: {
default: undefined,
default: null,
},
}
},
@@ -228,10 +234,12 @@ export const Image = TiptapImage.extend<CustomImageOptions>({
validImages.map((image) => {
if (image.src instanceof File) {
const blobUrl = URL.createObjectURL(image.src)
const id = randomId()
return {
type: this.type.name,
attrs: {
id: randomId(),
id,
src: blobUrl,
alt: image.alt,
title: image.title,
@@ -256,96 +264,81 @@ export const Image = TiptapImage.extend<CustomImageOptions>({
return false
},
downloadImage: (attrs) => () => {
const downloadFunc =
this.options.customDownloadImage || defaultDownloadImage
const downloadFunc = this.options.downloadImage || downloadImage
void downloadFunc({ ...attrs, action: "download" }, this.options)
return true
},
copyImage: (attrs) => () => {
const copyImageFunc = this.options.customCopyImage || defaultCopyImage
const copyImageFunc = this.options.copyImage || copyImage
void copyImageFunc({ ...attrs, action: "copyImage" }, this.options)
return true
},
copyLink: (attrs) => () => {
const copyLinkFunc = this.options.customCopyLink || defaultCopyLink
const copyLinkFunc = this.options.copyLink || copyLink
void copyLinkFunc({ ...attrs, action: "copyLink" }, this.options)
return true
},
toggleImage: () => (props) => {
const input = document.createElement("input")
input.type = "file"
input.accept = this.options.allowedMimeTypes.join(",")
input.onchange = () => {
const files = input.files
if (!files) return
const [validImages, errors] = filterFiles(Array.from(files), {
allowedMimeTypes: this.options.allowedMimeTypes,
maxFileSize: this.options.maxFileSize,
allowBase64: this.options.allowBase64,
})
toggleImage:
() =>
({ editor }) => {
const input = document.createElement("input")
input.type = "file"
input.accept = this.options.allowedMimeTypes.join(",")
input.onchange = () => {
const files = input.files
if (!files) return
const [validImages, errors] = filterFiles(Array.from(files), {
allowedMimeTypes: this.options.allowedMimeTypes,
maxFileSize: this.options.maxFileSize,
allowBase64: this.options.allowBase64,
})
if (errors.length > 0 && this.options.onValidationError) {
this.options.onValidationError(errors)
return false
}
if (validImages.length === 0) return false
if (this.options.onToggle) {
this.options.onToggle(
editor,
validImages,
editor.state.selection.from,
)
}
if (errors.length > 0 && this.options.onValidationError) {
this.options.onValidationError(errors)
return false
}
if (validImages.length === 0) return false
if (this.options.onToggle) {
this.options.onToggle(
props.editor,
validImages,
props.editor.state.selection.from,
)
}
}
input.click()
return true
},
input.click()
return true
},
}
},
onTransaction({ transaction }) {
if (!transaction.docChanged) return
transaction.steps.forEach((step) => {
if (step instanceof ReplaceStep && step.slice.size === 0) {
const deletedPages = transaction.before.content.cut(step.from, step.to)
const oldDoc = transaction.before
const newDoc = transaction.doc
deletedPages.forEach((node) => {
if (node.type.name === "image") {
const attrs = node.attrs
const oldImages = new Map<string, ImageInfo>()
const newImages = new Map<string, ImageInfo>()
if (attrs.src.startsWith("blob:")) {
URL.revokeObjectURL(attrs.src)
}
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:") &&
isUrl(imageInfo.src)
) {
this.options.onImageRemoved?.({
id: imageInfo.id,
src: imageInfo.src,
})
}
this.options.onImageRemoved?.(attrs)
}
})
}
})
},

View File

@@ -4,7 +4,7 @@ import type { Content, EditorOptions, UseEditorOptions } from "@tiptap/react"
import { useEditor } from "@tiptap/react"
import { cn } from "@/lib/utils"
import { useThrottleCallback } from "@shared/hooks/use-throttle-callback"
import { getOutput } from "@shared/editor/lib/utils"
import { getOutput, randomId } from "@shared/editor/lib/utils"
import { StarterKit } from "@shared/editor/extensions/starter-kit"
import { TaskList } from "@shared/editor/extensions/task-list"
import { TaskItem } from "@shared/editor/extensions/task-item"
@@ -80,25 +80,39 @@ const createExtensions = ({ placeholder }: { placeholder: string }) => [
formData.append("width", dimensions.width.toString())
formData.append("height", dimensions.height.toString())
const store = await storeImageFn(formData)
const store = await storeImageFn({ data: formData })
return { id: store.fileModel.id, src: store.fileModel.content.src }
},
onImageRemoved({ id }) {
if (id) {
deleteImageFn({ id: id?.toString() })
}
onImageRemoved(props) {
if (props.id) {
deleteImageFn({ data: props.id })
toast.success("Image removed", {
position: "bottom-right",
description: "Image removed successfully",
})
console.log("Image removed", props)
toast.success("Image removed", {
position: "bottom-right",
description: "Image removed successfully",
})
}
},
onToggle(editor, files, pos) {
files.forEach((file) =>
editor.commands.insertContentAt(pos, {
type: "image",
attrs: { src: URL.createObjectURL(file) },
editor.commands.insertContentAt(
pos,
files.map((image) => {
const blobUrl = URL.createObjectURL(image)
const id = randomId()
return {
type: "image",
attrs: {
id,
src: blobUrl,
alt: image.name,
title: image.name,
fileName: image.name,
},
}
}),
)
},

View File

@@ -45,7 +45,7 @@ const ImageEditBlock = ({
formData.append("file", files[0])
try {
const response = await storeImageFn(formData)
const response = await storeImageFn({ data: formData })
editor
.chain()