diff --git a/web/app.config.ts b/web/app.config.ts index 4e197727..f33d0139 100644 --- a/web/app.config.ts +++ b/web/app.config.ts @@ -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 diff --git a/web/app/actions.ts b/web/app/actions.ts index a2d7926a..b9150aac 100644 --- a/web/app/actions.ts +++ b/web/app/actions.ts @@ -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 - }, -) + }) diff --git a/web/app/client.tsx b/web/app/client.tsx index caafdbd5..d5ea3d31 100644 --- a/web/app/client.tsx +++ b/web/app/client.tsx @@ -5,4 +5,4 @@ import { createRouter } from "./router" const router = createRouter() -hydrateRoot(document.getElementById("root")!, ) +hydrateRoot(document!, ) diff --git a/web/app/components/sidebar/partials/feedback.tsx b/web/app/components/sidebar/partials/feedback.tsx index 100505dc..d70e924a 100644 --- a/web/app/components/sidebar/partials/feedback.tsx +++ b/web/app/components/sidebar/partials/feedback.tsx @@ -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) { try { setIsPending(true) - await sendFeedbackFn(values) + await sendFeedbackFn({ data: values.content }) form.reset({ content: "" }) editorRef.current?.commands.clearContent() diff --git a/web/app/components/sidebar/partials/journal-section.tsx b/web/app/components/sidebar/partials/journal-section.tsx index 2efdcc94..81e8fed9 100644 --- a/web/app/components/sidebar/partials/journal-section.tsx +++ b/web/app/components/sidebar/partials/journal-section.tsx @@ -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) => diff --git a/web/app/components/sidebar/partials/task-section.tsx b/web/app/components/sidebar/partials/task-section.tsx index 81e01c7d..9cabe01d 100644 --- a/web/app/components/sidebar/partials/task-section.tsx +++ b/web/app/components/sidebar/partials/task-section.tsx @@ -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) => diff --git a/web/app/routes/__root.tsx b/web/app/routes/__root.tsx index 985eaa0e..bcb2a427 100644 --- a/web/app/routes/__root.tsx +++ b/web/app/routes/__root.tsx @@ -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 | 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 ( - - + + - - + + {children} - - - - + - - + + ) } diff --git a/web/app/routes/_layout/(auth)/_auth.tsx b/web/app/routes/_layout/(auth)/_auth.tsx index 4c90fe89..bf5132dd 100644 --- a/web/app/routes/_layout/(auth)/_auth.tsx +++ b/web/app/routes/_layout/(auth)/_auth.tsx @@ -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 }) } }, diff --git a/web/app/routes/_layout/_pages/(topic)/-item.tsx b/web/app/routes/_layout/_pages/(topic)/-item.tsx index a0048a53..dc346cb9 100644 --- a/web/app/routes/_layout/_pages/(topic)/-item.tsx +++ b/web/app/routes/_layout/_pages/(topic)/-item.tsx @@ -228,7 +228,7 @@ export const LinkItem = React.forwardRef( e.stopPropagation()} + onClick={(e: React.MouseEvent) => e.stopPropagation()} className="text-xs text-muted-foreground hover:text-primary" > {link.url} diff --git a/web/app/routes/_layout/_pages/_protected/links/-item.tsx b/web/app/routes/_layout/_pages/_protected/links/-item.tsx index 437a9bd5..cd36cc21 100644 --- a/web/app/routes/_layout/_pages/_protected/links/-item.tsx +++ b/web/app/routes/_layout/_pages/_protected/links/-item.tsx @@ -191,7 +191,9 @@ export const LinkItem = React.forwardRef( e.stopPropagation()} + onClick={(e: React.MouseEvent) => + e.stopPropagation() + } className="mr-1 truncate text-xs hover:text-primary" > {personalLink.url} diff --git a/web/app/routes/_layout/_pages/_protected/links/-link-form.tsx b/web/app/routes/_layout/_pages/_protected/links/-link-form.tsx index 487d16bb..500752f7 100644 --- a/web/app/routes/_layout/_pages/_protected/links/-link-form.tsx +++ b/web/app/routes/_layout/_pages/_protected/links/-link-form.tsx @@ -44,70 +44,72 @@ export const globalLinkFormExceptionRefsAtom = atom< React.RefObject[] >([]) -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 = ({ 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, diff --git a/web/app/routes/_layout/_pages/_protected/tasks/index.tsx b/web/app/routes/_layout/_pages/_protected/tasks/index.tsx index 028fb146..3770fb7a 100644 --- a/web/app/routes/_layout/_pages/_protected/tasks/index.tsx +++ b/web/app/routes/_layout/_pages/_protected/tasks/index.tsx @@ -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), // ) diff --git a/web/app/routes/_layout/_pages/search/index.tsx b/web/app/routes/_layout/_pages/search/index.tsx index d927da2b..9207f87c 100644 --- a/web/app/routes/_layout/_pages/search/index.tsx +++ b/web/app/routes/_layout/_pages/search/index.tsx @@ -49,7 +49,7 @@ const SearchItem: React.FC = ({
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 = ({ {subtitle && ( e.stopPropagation()} + onClick={(e: React.MouseEvent) => e.stopPropagation()} className="ml-2 truncate text-xs text-muted-foreground hover:underline" > {subtitle} diff --git a/web/package.json b/web/package.json index a3a67178..f9c79884 100644 --- a/web/package.json +++ b/web/package.json @@ -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" }, diff --git a/web/shared/actions.ts b/web/shared/actions.ts index 9995bf66..9417c033 100644 --- a/web/shared/actions.ts +++ b/web/shared/actions.ts @@ -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) + }) 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 99bd3465..a0e48893 100644 --- a/web/shared/editor/extensions/image/components/image-view-block.tsx +++ b/web/shared/editor/extensions/image/components/image-view-block.tsx @@ -202,7 +202,6 @@ export const ImageViewBlock: React.FC = ({ updateAttributes(normalizedData) } catch (error) { - console.error("Image upload failed:", error) setImageState((prev) => ({ ...prev, error: true, @@ -240,7 +239,7 @@ export const ImageViewBlock: React.FC = ({ >
- {!imageState.imageLoaded && !imageState.error && ( + {imageState.isServerUploading && !imageState.error && (
@@ -279,6 +278,8 @@ export const ImageViewBlock: React.FC = ({ onError={handleImageError} onLoad={handleImageLoad} alt={node.attrs.alt || ""} + title={node.attrs.title || ""} + id={node.attrs.id} />
diff --git a/web/shared/editor/extensions/image/image.ts b/web/shared/editor/extensions/image/image.ts index 349278e7..7f428809 100644 --- a/web/shared/editor/extensions/image/image.ts +++ b/web/shared/editor/extensions/image/image.ts @@ -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 { uploadFn?: (file: File, editor: Editor) => Promise - 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 - customCopyImage?: ( + copyImage?: ( props: ImageActionProps, options: CustomImageOptions, ) => Promise - customCopyLink?: ( + copyLink?: ( props: ImageActionProps, options: CustomImageOptions, ) => Promise @@ -133,7 +128,7 @@ const saveImage = async ( URL.revokeObjectURL(imageURL) } -const defaultDownloadImage = async ( +const downloadImage = async ( props: ImageActionProps, options: CustomImageOptions, ): Promise => { @@ -149,7 +144,7 @@ const defaultDownloadImage = async ( } } -const defaultCopyImage = async ( +const copyImage = async ( props: ImageActionProps, options: CustomImageOptions, ): Promise => { @@ -164,7 +159,7 @@ const defaultCopyImage = async ( } } -const defaultCopyLink = async ( +const copyLink = async ( props: ImageActionProps, options: CustomImageOptions, ): Promise => { @@ -187,23 +182,34 @@ export const Image = TiptapImage.extend({ 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({ 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({ 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() - const newImages = new Map() + if (attrs.src.startsWith("blob:")) { + URL.revokeObjectURL(attrs.src) + } - 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:") && - isUrl(imageInfo.src) - ) { - this.options.onImageRemoved?.({ - id: imageInfo.id, - src: imageInfo.src, - }) - } + this.options.onImageRemoved?.(attrs) + } + }) } }) }, diff --git a/web/shared/editor/hooks/use-la-editor.ts b/web/shared/editor/hooks/use-la-editor.ts index f1413d6c..4162d8f4 100644 --- a/web/shared/editor/hooks/use-la-editor.ts +++ b/web/shared/editor/hooks/use-la-editor.ts @@ -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, + }, + } }), ) }, diff --git a/web/shared/minimal-tiptap/components/image/image-edit-block.tsx b/web/shared/minimal-tiptap/components/image/image-edit-block.tsx index 88121adf..b8664f65 100644 --- a/web/shared/minimal-tiptap/components/image/image-edit-block.tsx +++ b/web/shared/minimal-tiptap/components/image/image-edit-block.tsx @@ -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()