From 8a4e5cf65b886f84212e2ac73c7a4fbbbafae371 Mon Sep 17 00:00:00 2001 From: 3xpyth0n Date: Wed, 25 Mar 2026 13:35:32 +0100 Subject: [PATCH 01/13] fix(vercel): remove auth.config.ts and inline NextAuth config Consolidate NextAuth configuration directly into proxy.ts and auth.ts. Remove auth.config.ts to simplify the auth setup. Fix Vercel deployment issues caused by split config files. --- commitlint.config.ts => commitlint.config.mts | 0 eslint.config.ts => eslint.config.mts | 0 src/auth.config.ts | 37 --------- src/auth.ts | 27 +++++-- src/package.json | 3 + src/proxy.ts | 78 ++++++++++++++++--- tailwind.config.ts => tailwind.config.mts | 0 vitest.config.ts => vitest.config.mts | 0 8 files changed, 90 insertions(+), 55 deletions(-) rename commitlint.config.ts => commitlint.config.mts (100%) rename eslint.config.ts => eslint.config.mts (100%) delete mode 100644 src/auth.config.ts create mode 100644 src/package.json rename tailwind.config.ts => tailwind.config.mts (100%) rename vitest.config.ts => vitest.config.mts (100%) diff --git a/commitlint.config.ts b/commitlint.config.mts similarity index 100% rename from commitlint.config.ts rename to commitlint.config.mts diff --git a/eslint.config.ts b/eslint.config.mts similarity index 100% rename from eslint.config.ts rename to eslint.config.mts diff --git a/src/auth.config.ts b/src/auth.config.ts deleted file mode 100644 index a78dc51..0000000 --- a/src/auth.config.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { NextAuthConfig } from "next-auth"; - -const secret = process.env.SECRET_KEY || process.env.AUTH_SECRET; - -// During build time (Next.js static generation), it's acceptable to not have the secret. -const isBuild = process.env.IS_NEXT_BUILD === "1"; - -if (!secret && !isBuild) { - throw new Error( - "CRITICAL: SECRET_KEY or AUTH_SECRET environment variable must be set. Application cannot start without a valid secret.", - ); -} - -export const authConfig = { - session: { - strategy: "jwt", - }, - cookies: { - sessionToken: { - name: `authjs.session-token`, - options: { - httpOnly: true, - sameSite: "lax", - path: "/", - secure: - process.env.NODE_ENV === "production" && - process.env.APP_URL?.startsWith("https"), - }, - }, - }, - pages: { - signIn: "/login", - error: "/login", - }, - secret, - providers: [], -} satisfies NextAuthConfig; diff --git a/src/auth.ts b/src/auth.ts index db86992..1dfbc80 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -49,15 +49,31 @@ const getRateLimiter = () => { }); }; -import { authConfig } from "./auth.config"; - // Auth configuration export const { handlers, signIn, signOut, auth } = NextAuth(async () => { const freshConfig = await getAuthProviders(); const db = getDb(); return { - ...authConfig, + session: { strategy: "jwt" }, + cookies: { + sessionToken: { + name: `authjs.session-token`, + options: { + httpOnly: true, + sameSite: "lax", + path: "/", + secure: + process.env.NODE_ENV === "production" && + (process.env.APP_URL?.startsWith("https") ?? false), + }, + }, + }, + pages: { + signIn: "/login", + error: "/login", + }, + secret: process.env.SECRET_KEY || process.env.AUTH_SECRET, trustHost: true, adapter: KyselyAdapter(), providers: [ @@ -458,10 +474,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth(async () => { return session; }, }, - pages: { - signIn: "/login", - error: "/login", - }, + logger: { error(error) { // Suppress CredentialsSignin stack trace but keep the 401 status in logs diff --git a/src/package.json b/src/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/src/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/src/proxy.ts b/src/proxy.ts index b0bca8f..3da0919 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,22 +1,78 @@ import { NextRequest, NextResponse } from "next/server"; -import { v4 as uuidv4 } from "uuid"; -import { getSecurityHeaders } from "@lib/utils"; import NextAuth from "next-auth"; -import { authConfig } from "./auth.config"; + +const secret = process.env.SECRET_KEY || process.env.AUTH_SECRET; const { auth } = NextAuth({ - ...authConfig, + session: { strategy: "jwt" }, + cookies: { + sessionToken: { + name: `authjs.session-token`, + options: { + httpOnly: true, + sameSite: "lax", + path: "/", + secure: + process.env.NODE_ENV === "production" && + (process.env.APP_URL?.startsWith("https") ?? false), + }, + }, + }, + pages: { + signIn: "/login", + error: "/login", + }, + secret, + providers: [], }); -export async function proxy(req: NextRequest) { - // Use a stable nonce for CSP - let nonce: string; - try { - nonce = uuidv4(); - } catch { - nonce = Math.random().toString(36).substring(2); +function getSecurityHeaders(nonce: string): Record { + const appUrl = process.env.APP_URL || ""; + const isSecure = appUrl.startsWith("https://"); + + const cspHeader = [ + "default-src 'self';", + `script-src 'self' 'nonce-${nonce}' ${ + process.env.NODE_ENV === "development" ? "'unsafe-eval'" : "" + };`, + "style-src 'self' 'unsafe-inline' fonts.googleapis.com;", + "img-src 'self' data: blob: https:;", + "font-src 'self' https://fonts.gstatic.com https://fonts.googleapis.com https://esm.sh;", + "connect-src 'self' ws: wss: https:;", + "frame-src 'self' https:;", + "frame-ancestors 'none';", + "base-uri 'self';", + "form-action 'self';", + "object-src 'none';", + isSecure ? "upgrade-insecure-requests;" : "", + ] + .filter(Boolean) + .join(" "); + + const headers: Record = { + "Content-Security-Policy": cspHeader, + "X-Frame-Options": "DENY", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Permissions-Policy": "camera=(), microphone=(), geolocation=()", + "X-XSS-Protection": "1; mode=block", + }; + + if (isSecure) { + headers["Strict-Transport-Security"] = + "max-age=31536000; includeSubDomains; preload"; } + return headers; +} + +export async function proxy(req: NextRequest) { + const array = new Uint8Array(16); + crypto.getRandomValues(array); + const nonce = Array.from(array, (b) => b.toString(16).padStart(2, "0")).join( + "", + ); + const url = req.nextUrl; const pathname = url.pathname; const baseUrl = diff --git a/tailwind.config.ts b/tailwind.config.mts similarity index 100% rename from tailwind.config.ts rename to tailwind.config.mts diff --git a/vitest.config.ts b/vitest.config.mts similarity index 100% rename from vitest.config.ts rename to vitest.config.mts From 7a804fc0833c9d001069537953f2c0f961bf9b9b Mon Sep 17 00:00:00 2001 From: 3xpyth0n Date: Wed, 25 Mar 2026 15:25:53 +0100 Subject: [PATCH 02/13] fix: blurry content when zooming out in the canvas --- CHANGELOG.md | 6 + src/app/components/project/ChecklistBlock.tsx | 2 +- src/app/components/project/GitBlock.tsx | 2 +- src/app/components/project/NoteBlock.tsx | 1013 +++++++++-------- src/app/components/project/ProjectCanvas.tsx | 839 +++++++------- src/app/components/project/shell-block.css | 2 - src/app/global.css | 3 +- src/app/styles/editor.css | 38 +- src/app/styles/forms.css | 4 +- 9 files changed, 953 insertions(+), 956 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88cd6ca..9f24431 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The Ideon project follows the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.6] - 2026-03-XX + +### Fixed + +- Fixed blurry content when zooming out in the canvas. + ## [0.7.5] - 2026-03-23 ### Fixed diff --git a/src/app/components/project/ChecklistBlock.tsx b/src/app/components/project/ChecklistBlock.tsx index ad3537d..fb53cb2 100644 --- a/src/app/components/project/ChecklistBlock.tsx +++ b/src/app/components/project/ChecklistBlock.tsx @@ -809,7 +809,7 @@ const ChecklistBlock = memo(({ id, data, selected }: ChecklistBlockProps) => { onResizeEnd={handleResizeEnd} /> -
+
diff --git a/src/app/components/project/GitBlock.tsx b/src/app/components/project/GitBlock.tsx index 0d8c7e9..6a5a742 100644 --- a/src/app/components/project/GitBlock.tsx +++ b/src/app/components/project/GitBlock.tsx @@ -668,7 +668,7 @@ const GitBlock = (props: CanvasBlockProps) => { onResizeEnd={handleResizeEnd} /> -
+
diff --git a/src/app/components/project/NoteBlock.tsx b/src/app/components/project/NoteBlock.tsx index d472f2d..f7a90d8 100644 --- a/src/app/components/project/NoteBlock.tsx +++ b/src/app/components/project/NoteBlock.tsx @@ -336,560 +336,563 @@ const BubbleMenuComponent = forwardRef( BubbleMenuComponent.displayName = "BubbleMenuComponent"; -const NoteBlock = memo( - ({ - data, - selected, +const NoteBlock = memo(({ data, selected, id }: NoteBlockProps) => { + const { dict, lang } = useI18n(); + const { getEdges } = useReactFlow(); + + const currentUser = data.currentUser; + const projectOwnerId = data.projectOwnerId; + const ownerId = data.ownerId; + const isPreviewMode = data.isPreviewMode; + const isLocked = data.isLocked; + + const isProjectOwner = currentUser?.id && projectOwnerId === currentUser.id; + const isOwner = currentUser?.id && ownerId === currentUser.id; + const isViewer = data.userRole === "viewer"; + const isReadOnly = + isPreviewMode || + isViewer || + (isLocked ? !isOwner && !isProjectOwner : false); + const canReact = !isPreviewMode || isViewer; + + const { handleReact, handleRemoveReaction } = useBlockReactions({ id, - positionAbsoluteX, - positionAbsoluteY, - width, - height, - }: NoteBlockProps) => { - const { dict, lang } = useI18n(); - const { getEdges } = useReactFlow(); - const viewport = useViewport(); - - const currentUser = data.currentUser; - const projectOwnerId = data.projectOwnerId; - const ownerId = data.ownerId; - const isPreviewMode = data.isPreviewMode; - const isLocked = data.isLocked; - - const isProjectOwner = currentUser?.id && projectOwnerId === currentUser.id; - const isOwner = currentUser?.id && ownerId === currentUser.id; - const isViewer = data.userRole === "viewer"; - const isReadOnly = - isPreviewMode || - isViewer || - (isLocked ? !isOwner && !isProjectOwner : false); - const canReact = !isPreviewMode || isViewer; - - const { handleReact, handleRemoveReaction } = useBlockReactions({ - id, - data, - currentUser, - isReadOnly, - canReact, - }); - - const [editor, setEditor] = useState(null); - const [isEditing, setIsEditing] = useState(false); - const [showBubbleMenu, setShowBubbleMenu] = useState(false); - const [isTitleEditing, setIsTitleEditing] = useState(false); - const [isEditingLink, setIsEditingLink] = useState(false); - const [linkUrl, setLinkUrl] = useState(""); - const blockRef = useRef(null); - const menuRef = useRef(null); - const [blockRect, setBlockRect] = useState(null); - - useEffect(() => { - if (isReadOnly || !isEditing) { - setShowBubbleMenu(false); - } - }, [isReadOnly, isEditing]); - - useEffect(() => { - if (!currentUser?.vimMode && isEditing && !isReadOnly) { - setShowBubbleMenu(true); - } - }, [currentUser?.vimMode, isEditing, isReadOnly]); - - useEffect(() => { - const isNonVimEdit = !currentUser?.vimMode && isEditing && !isReadOnly; - - if (!editor) { - if (!isNonVimEdit) setShowBubbleMenu(false); - return; - } - - const handleSelectionUpdate = () => { - if (isNonVimEdit) return; - const { from, head } = editor.state.selection; - const hasSelection = from !== head; - setShowBubbleMenu( - hasSelection && !isTitleEditing && !isReadOnly && isEditing, - ); - }; - - const handleFocus = () => { - if (isNonVimEdit) return; - if (isReadOnly || !isEditing) return; - const { from, head } = editor.state.selection; - if (from !== head) setShowBubbleMenu(true); - }; + data, + currentUser, + isReadOnly, + canReact, + }); + + const [editor, setEditor] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [showBubbleMenu, setShowBubbleMenu] = useState(false); + const [isTitleEditing, setIsTitleEditing] = useState(false); + const [isEditingLink, setIsEditingLink] = useState(false); + const [linkUrl, setLinkUrl] = useState(""); + const blockRef = useRef(null); + + useEffect(() => { + if (isReadOnly || !isEditing) { + setShowBubbleMenu(false); + } + }, [isReadOnly, isEditing]); + + useEffect(() => { + if (!currentUser?.vimMode && isEditing && !isReadOnly) { + setShowBubbleMenu(true); + } + }, [currentUser?.vimMode, isEditing, isReadOnly]); + + useEffect(() => { + const isNonVimEdit = !currentUser?.vimMode && isEditing && !isReadOnly; + + if (!editor) { + if (!isNonVimEdit) setShowBubbleMenu(false); + return; + } + + const handleSelectionUpdate = () => { + if (isNonVimEdit) return; + const { from, head } = editor.state.selection; + const hasSelection = from !== head; + setShowBubbleMenu( + hasSelection && !isTitleEditing && !isReadOnly && isEditing, + ); + }; - const handleDomBlur = (e: FocusEvent) => { - if (isNonVimEdit) return; + const handleFocus = () => { + if (isNonVimEdit) return; + if (isReadOnly || !isEditing) return; + const { from, head } = editor.state.selection; + if (from !== head) setShowBubbleMenu(true); + }; - const relatedTarget = e.relatedTarget; - if ( - menuRef.current && - relatedTarget instanceof Node && - menuRef.current.contains(relatedTarget) - ) { - return; - } - setShowBubbleMenu(false); - }; + editor.on("selectionUpdate", handleSelectionUpdate); + editor.on("transaction", handleSelectionUpdate); + editor.on("focus", handleFocus); - editor.on("selectionUpdate", handleSelectionUpdate); - editor.on("transaction", handleSelectionUpdate); - editor.on("focus", handleFocus); - if (editor.view && !editor.isDestroyed) { - editor.view.dom.addEventListener("blur", handleDomBlur); - } + return () => { + editor.off("selectionUpdate", handleSelectionUpdate); + editor.off("transaction", handleSelectionUpdate); + editor.off("focus", handleFocus); + }; + }, [editor, isTitleEditing, isReadOnly, isEditing, currentUser?.vimMode]); - return () => { - editor.off("selectionUpdate", handleSelectionUpdate); - editor.off("transaction", handleSelectionUpdate); - editor.off("focus", handleFocus); - if (editor.view && !editor.isDestroyed && editor.view.dom) { - editor.view.dom.removeEventListener("blur", handleDomBlur); - } - }; - }, [editor, isTitleEditing, isReadOnly, isEditing, currentUser?.vimMode]); + // Moved viewport listener to BubbleMenuContainer to avoid NoteBlock re-renders during zoom - useLayoutEffect(() => { - const updateRect = () => { - if (showBubbleMenu && blockRef.current) { - setBlockRect(blockRef.current.getBoundingClientRect()); - } - }; + const [title, setTitle] = useState(data.title || ""); - const handleSidebarToggle = () => { - // Run updateRect for 350ms to cover the 300ms transition - const startTime = Date.now(); - const duration = 350; + const edges = getEdges(); + const isHandleConnected = (handleId: string) => + edges.some( + (e) => + (e.source === id && e.sourceHandle === handleId) || + (e.target === id && e.targetHandle === handleId), + ); - const loop = () => { - updateRect(); - if (Date.now() - startTime < duration) { - requestAnimationFrame(loop); - } - }; - requestAnimationFrame(loop); - }; + const isLeftSourceConnected = isHandleConnected("left"); + const isRightSourceConnected = isHandleConnected("right"); + const isTopSourceConnected = isHandleConnected("top"); + const isBottomSourceConnected = isHandleConnected("bottom"); - updateRect(); + const noteVimExtensions = useMemo(() => [markdown()], []); - window.addEventListener("resize", updateRect); - window.addEventListener("sidebar-toggle", handleSidebarToggle); + const lastSyncedTextRef = useRef(null); + const syncTimeoutRef = useRef | null>(null); - return () => { - window.removeEventListener("resize", updateRect); - window.removeEventListener("sidebar-toggle", handleSidebarToggle); - }; - }, [ - showBubbleMenu, - viewport, - positionAbsoluteX, - positionAbsoluteY, - width, - height, - ]); - - const [title, setTitle] = useState(data.title || ""); - - const edges = getEdges(); - const isHandleConnected = (handleId: string) => - edges.some( - (e) => - (e.source === id && e.sourceHandle === handleId) || - (e.target === id && e.targetHandle === handleId), - ); + const syncToYjs = useCallback( + (text: string) => { + if (!data.yText) return; - const isLeftSourceConnected = isHandleConnected("left"); - const isRightSourceConnected = isHandleConnected("right"); - const isTopSourceConnected = isHandleConnected("top"); - const isBottomSourceConnected = isHandleConnected("bottom"); + if (text.length > 1000000) { + text = text.slice(0, 1000000) + "\n\n[Truncated for performance]"; + } - const noteVimExtensions = useMemo(() => [markdown()], []); + if (lastSyncedTextRef.current === text) return; - const lastSyncedTextRef = useRef(null); - const syncTimeoutRef = useRef | null>(null); + if (syncTimeoutRef.current) { + clearTimeout(syncTimeoutRef.current); + } - const syncToYjs = useCallback( - (text: string) => { + syncTimeoutRef.current = setTimeout(() => { + syncTimeoutRef.current = null; if (!data.yText) return; - if (text.length > 1000000) { - text = text.slice(0, 1000000) + "\n\n[Truncated for performance]"; - } - - if (lastSyncedTextRef.current === text) return; - - if (syncTimeoutRef.current) { - clearTimeout(syncTimeoutRef.current); + const currentText = data.yText.toString(); + if (currentText === text) { + lastSyncedTextRef.current = text; + return; } - syncTimeoutRef.current = setTimeout(() => { - syncTimeoutRef.current = null; - if (!data.yText) return; - - const currentText = data.yText.toString(); - if (currentText === text) { - lastSyncedTextRef.current = text; - return; - } - - data.yText.doc?.transact(() => { - data.yText?.delete(0, data.yText.length); - data.yText?.insert(0, text); - }); - lastSyncedTextRef.current = text; - }, 500); // 500ms debounce - }, - [data.yText], - ); + data.yText.doc?.transact(() => { + data.yText?.delete(0, data.yText.length); + data.yText?.insert(0, text); + }); + lastSyncedTextRef.current = text; + }, 500); // 500ms debounce + }, + [data.yText], + ); - useEffect(() => { - return () => { - if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current); - }; - }, []); - - useEffect(() => { - setTitle(data.title || ""); - }, [data.title]); - - const handleTitleChange = useCallback( - (e: React.ChangeEvent) => { - const newTitle = e.target.value; - setTitle(newTitle); - const now = new Date().toISOString(); - const editor = - currentUser?.displayName || - currentUser?.username || - dict.project.anonymous; - - data.onContentChange?.( - id, - data.content || "", - now, - editor, - data.metadata ? JSON.stringify(data.metadata) : undefined, - newTitle, - data.reactions, - ); - }, - [id, data, currentUser, dict], - ); + useEffect(() => { + return () => { + if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current); + }; + }, []); + + useEffect(() => { + setTitle(data.title || ""); + }, [data.title]); + + const handleTitleChange = useCallback( + (e: React.ChangeEvent) => { + const newTitle = e.target.value; + setTitle(newTitle); + const now = new Date().toISOString(); + const editor = + currentUser?.displayName || + currentUser?.username || + dict.project.anonymous; + + data.onContentChange?.( + id, + data.content || "", + now, + editor, + data.metadata ? JSON.stringify(data.metadata) : undefined, + newTitle, + data.reactions, + ); + }, + [id, data, currentUser, dict], + ); - const handleContentChange = useCallback( - (newContent: string) => { - syncToYjs(newContent); - data.onContentChange?.( - id, - newContent, - new Date().toISOString(), - data.lastEditor, - data.metadata ? JSON.stringify(data.metadata) : undefined, - title, - data.reactions, - ); - }, - [ + const handleContentChange = useCallback( + (newContent: string) => { + syncToYjs(newContent); + data.onContentChange?.( id, - data.onContentChange, + newContent, + new Date().toISOString(), data.lastEditor, - data.metadata, + data.metadata ? JSON.stringify(data.metadata) : undefined, title, - syncToYjs, - ], - ); - - const handleVimChange = useCallback( - (value: string) => { - syncToYjs(value); - data.onContentChange?.( - id, - value, - new Date().toISOString(), - data.lastEditor, - data.metadata ? JSON.stringify(data.metadata) : undefined, - title, - data.reactions, - ); - }, - [ + data.reactions, + ); + }, + [ + id, + data.onContentChange, + data.lastEditor, + data.metadata, + title, + syncToYjs, + ], + ); + + const handleVimChange = useCallback( + (value: string) => { + syncToYjs(value); + data.onContentChange?.( id, - data.onContentChange, + value, + new Date().toISOString(), data.lastEditor, - data.metadata, + data.metadata ? JSON.stringify(data.metadata) : undefined, title, - syncToYjs, - ], - ); - - const openLinkModal = useCallback(() => { - if (!editor || isReadOnly) return; - const previousUrl = editor.getAttributes("link").href; - setLinkUrl(previousUrl || ""); - setIsEditingLink(true); - setShowBubbleMenu(true); - }, [editor]); - - const applyLink = useCallback(() => { - if (!editor) return; - if (linkUrl) { - let finalUrl = linkUrl.trim(); - // If the URL doesn't start with a protocol (http://, https://, mailto:, etc.), prepend https:// - if ( - finalUrl && - !/^https?:\/\//i.test(finalUrl) && - !/^mailto:/i.test(finalUrl) && - !/^tel:/i.test(finalUrl) - ) { - finalUrl = `https://${finalUrl}`; - } - - editor - .chain() - .focus() - .extendMarkRange("link") - .setLink({ href: finalUrl }) - .run(); - } else { - editor.chain().focus().extendMarkRange("link").unsetLink().run(); + data.reactions, + ); + }, + [ + id, + data.onContentChange, + data.lastEditor, + data.metadata, + title, + syncToYjs, + ], + ); + + const openLinkModal = useCallback(() => { + if (!editor || isReadOnly) return; + const previousUrl = editor.getAttributes("link").href; + setLinkUrl(previousUrl || ""); + setIsEditingLink(true); + setShowBubbleMenu(true); + }, [editor]); + + const applyLink = useCallback(() => { + if (!editor) return; + if (linkUrl) { + let finalUrl = linkUrl.trim(); + // If the URL doesn't start with a protocol (http://, https://, mailto:, etc.), prepend https:// + if ( + finalUrl && + !/^https?:\/\//i.test(finalUrl) && + !/^mailto:/i.test(finalUrl) && + !/^tel:/i.test(finalUrl) + ) { + finalUrl = `https://${finalUrl}`; } - setIsEditingLink(false); - }, [editor, linkUrl]); - const removeLink = useCallback(() => { - if (!editor) return; + editor + .chain() + .focus() + .extendMarkRange("link") + .setLink({ href: finalUrl }) + .run(); + } else { editor.chain().focus().extendMarkRange("link").unsetLink().run(); - setIsEditingLink(false); - }, [editor]); - - const cancelLink = useCallback(() => { - setIsEditingLink(false); - setLinkUrl(""); - editor?.commands.focus(); - }, [editor]); - - const handleResize = useCallback( - ( - _evt: unknown, - params: { width: number; height: number; x: number; y: number }, - ) => { - const { width, height, x, y } = params; - const onResize = data.onResize; - onResize?.(id, { - width: Math.round(width), - height: Math.round(height), - x: Math.round(x), - y: Math.round(y), - }); - }, - [id, data], - ); - - const handleResizeEnd = useCallback( - ( - _evt: unknown, - params: { width: number; height: number; x: number; y: number }, - ) => { - const { width, height, x, y } = params; - const onResizeEnd = data.onResizeEnd; - onResizeEnd?.(id, { - width: Math.round(width), - height: Math.round(height), - x: Math.round(x), - y: Math.round(y), - }); - }, - [id, data], - ); - - return ( - <> - -
-
-
-
- - - {dict.blocks.blockTypeText || "Note"} - -
-
- setIsTitleEditing(true)} - onBlur={() => setIsTitleEditing(false)} - className="block-title nodrag" - placeholder={dict.blocks.title || "..."} - disabled={isReadOnly} - /> -
+ } + setIsEditingLink(false); + }, [editor, linkUrl]); + + const removeLink = useCallback(() => { + if (!editor) return; + editor.chain().focus().extendMarkRange("link").unsetLink().run(); + setIsEditingLink(false); + }, [editor]); + + const cancelLink = useCallback(() => { + setIsEditingLink(false); + setLinkUrl(""); + editor?.commands.focus(); + }, [editor]); + + const handleResize = useCallback( + ( + _evt: unknown, + params: { width: number; height: number; x: number; y: number }, + ) => { + const { width, height, x, y } = params; + const onResize = data.onResize; + onResize?.(id, { + width: Math.round(width), + height: Math.round(height), + x: Math.round(x), + y: Math.round(y), + }); + }, + [id, data], + ); + + const handleResizeEnd = useCallback( + ( + _evt: unknown, + params: { width: number; height: number; x: number; y: number }, + ) => { + const { width, height, x, y } = params; + const onResizeEnd = data.onResizeEnd; + onResizeEnd?.(id, { + width: Math.round(width), + height: Math.round(height), + x: Math.round(x), + y: Math.round(y), + }); + }, + [id, data], + ); + + return ( + <> + +
+
+
+
+ + + {dict.blocks.blockTypeText || "Note"} + +
+
+ setIsTitleEditing(true)} + onBlur={() => setIsTitleEditing(false)} + className="block-title nodrag" + placeholder={dict.blocks.title || "..."} + disabled={isReadOnly} + />
+
-
e.preventDefault()} - onWheel={(e) => e.stopPropagation()} - > - {isEditing && !isReadOnly ? ( - currentUser?.vimMode ? ( - - ) : ( - - ) +
e.preventDefault()} + onWheel={(e) => e.stopPropagation()} + > + {isEditing && !isReadOnly ? ( + currentUser?.vimMode ? ( + ) : ( - )} -
- - - {!isReadOnly && ( -
- - -
- )} -
+ ) + ) : ( + + )}
- - - {/* Handles for connections - Left Side */} - - {!isLeftSourceConnected &&
} - - - {/* Handles for connections - Right Side */} - - {!isRightSourceConnected &&
} - - - {/* Handles for connections - Top Side */} - - {!isTopSourceConnected &&
} - - - {/* Handles for connections - Bottom Side */} - - {!isBottomSourceConnected &&
} - + {!isReadOnly && ( +
+ + +
+ )} +
- {showBubbleMenu && - editor && - blockRect && - createPortal( - , - document.getElementById("app-main-container") || document.body, - )} - + + + {/* Handles for connections - Left Side */} + + {!isLeftSourceConnected &&
} + + + {/* Handles for connections - Right Side */} + + {!isRightSourceConnected &&
} + + + {/* Handles for connections - Top Side */} + + {!isTopSourceConnected &&
} + + + {/* Handles for connections - Bottom Side */} + + {!isBottomSourceConnected &&
} + +
+ + {showBubbleMenu && editor && ( + + )} + + ); +}); + +const NoteBubbleMenu = memo( + ({ + editor, + isEditingLink, + linkUrl, + setLinkUrl, + openLinkModal, + applyLink, + removeLink, + cancelLink, + blockRef, + showBubbleMenu, + }: { + editor: Editor; + isEditingLink: boolean; + linkUrl: string; + setLinkUrl: (url: string) => void; + openLinkModal: () => void; + applyLink: () => void; + removeLink: () => void; + cancelLink: () => void; + blockRef: React.RefObject; + showBubbleMenu: boolean; + }) => { + const viewport = useViewport(); + const [blockRect, setBlockRect] = useState(null); + const menuRef = useRef(null); + + useLayoutEffect(() => { + const updateRect = () => { + if (showBubbleMenu && blockRef.current) { + setBlockRect(blockRef.current.getBoundingClientRect()); + } + }; + + const handleSidebarToggle = () => { + const startTime = Date.now(); + const duration = 350; + const loop = () => { + updateRect(); + if (Date.now() - startTime < duration) { + requestAnimationFrame(loop); + } + }; + requestAnimationFrame(loop); + }; + + updateRect(); + window.addEventListener("resize", updateRect); + window.addEventListener("sidebar-toggle", handleSidebarToggle); + + return () => { + window.removeEventListener("resize", updateRect); + window.removeEventListener("sidebar-toggle", handleSidebarToggle); + }; + }, [showBubbleMenu, viewport, blockRef]); + + if (!blockRect) return null; + + return createPortal( + , + document.getElementById("app-main-container") || document.body, ); }, ); +NoteBubbleMenu.displayName = "NoteBubbleMenu"; + NoteBlock.displayName = "NoteBlock"; export default NoteBlock; diff --git a/src/app/components/project/ProjectCanvas.tsx b/src/app/components/project/ProjectCanvas.tsx index fd9e5e5..46b322e 100644 --- a/src/app/components/project/ProjectCanvas.tsx +++ b/src/app/components/project/ProjectCanvas.tsx @@ -6,8 +6,6 @@ import { ReactFlowProvider, ControlButton, Panel, - Background, - BackgroundVariant, ConnectionMode, useReactFlow as useReactFlowHook, Node, @@ -506,7 +504,6 @@ function ProjectCanvasContent({ initialProjectId }: ProjectCanvasProps) { canRedo, hasSeenOnboarding, helperLines, - isReady, } = useProjectCanvasState( initialProjectId, currentUser, @@ -1274,470 +1271,456 @@ function ProjectCanvasContent({ initialProjectId }: ProjectCanvasProps) { deleteDraft, }} > -
+ !blocks.find((b) => b.id === edge.source && b.hidden) && + !blocks.find((b) => b.id === edge.target && b.hidden) && + visibleBlockIds.has(edge.source) && + visibleBlockIds.has(edge.target), + )} + onNodesChange={isPreviewMode ? undefined : onBlocksChange} + onEdgesChange={isPreviewMode ? undefined : onLinksChange} + onNodeDragStart={isPreviewMode ? undefined : onBlockDragStart} + onNodeDrag={isPreviewMode ? undefined : onBlockDrag} + onNodeDragStop={isPreviewMode ? undefined : onBlockDragStop} + onConnect={isPreviewMode ? undefined : onConnectWithSnapshot} + onConnectStart={isPreviewMode ? undefined : onConnectStart} + onConnectEnd={isPreviewMode ? undefined : onConnectEnd} + isValidConnection={isValidConnection} + onPointerMove={onPointerMove} + onPointerLeave={onPointerLeave} + onPaneContextMenu={(e) => { + if ( + pointerTypeRef.current === "touch" || + pointerTypeRef.current === "pen" + ) { + e.preventDefault(); + return; + } + onPaneContextMenu(e); + }} + onNodeContextMenu={onBlockContextMenu} + onEdgeContextMenu={onEdgeContextMenu} + onPaneClick={handlePaneClick} + onNodeClick={handleNodeClick} + onEdgeClick={onLinkClick} + onEdgeDoubleClick={onLinkDoubleClick} + onMove={onMove} + onViewportChange={onViewportChange} + zoomOnPinch={true} + zoomOnDoubleClick={false} + nodeTypes={blockTypes} + edgeTypes={linkTypes} + defaultViewport={DEFAULT_VIEWPORT} + connectionMode={ConnectionMode.Loose} + connectionRadius={30} + translateExtent={FIXED_EXTENT} + minZoom={0.1} + maxZoom={4} + deleteKeyCode={null} + selectionOnDrag={!isReadOnly} + selectionKeyCode={null} + nodesDraggable={!isReadOnly} + nodesConnectable={!isReadOnly} + elementsSelectable={true} + edgesReconnectable={!isReadOnly} + panOnScroll + panOnDrag={true} + multiSelectionKeyCode="Control" + fitView + className={`project-canvas ${isReadOnly ? "read-only" : ""}`} + proOptions={{ hideAttribution: true }} > - - !blocks.find((b) => b.id === edge.source && b.hidden) && - !blocks.find((b) => b.id === edge.target && b.hidden) && - visibleBlockIds.has(edge.source) && - visibleBlockIds.has(edge.target), - )} - onNodesChange={isPreviewMode ? undefined : onBlocksChange} - onEdgesChange={isPreviewMode ? undefined : onLinksChange} - onNodeDragStart={isPreviewMode ? undefined : onBlockDragStart} - onNodeDrag={isPreviewMode ? undefined : onBlockDrag} - onNodeDragStop={isPreviewMode ? undefined : onBlockDragStop} - onConnect={isPreviewMode ? undefined : onConnectWithSnapshot} - onConnectStart={isPreviewMode ? undefined : onConnectStart} - onConnectEnd={isPreviewMode ? undefined : onConnectEnd} - isValidConnection={isValidConnection} - onPointerMove={onPointerMove} - onPointerLeave={onPointerLeave} - onPaneContextMenu={(e) => { - if ( - pointerTypeRef.current === "touch" || - pointerTypeRef.current === "pen" - ) { - e.preventDefault(); - return; - } - onPaneContextMenu(e); - }} - onNodeContextMenu={onBlockContextMenu} - onEdgeContextMenu={onEdgeContextMenu} - onPaneClick={handlePaneClick} - onNodeClick={handleNodeClick} - onEdgeClick={onLinkClick} - onEdgeDoubleClick={onLinkDoubleClick} - onMove={onMove} - onViewportChange={onViewportChange} - zoomOnPinch={true} - zoomOnDoubleClick={false} - nodeTypes={blockTypes} - edgeTypes={linkTypes} - defaultViewport={DEFAULT_VIEWPORT} - connectionMode={ConnectionMode.Loose} - connectionRadius={30} - translateExtent={FIXED_EXTENT} - minZoom={0.1} - maxZoom={4} - deleteKeyCode={null} - selectionOnDrag={!isReadOnly} - selectionKeyCode={null} - nodesDraggable={!isReadOnly} - nodesConnectable={!isReadOnly} - elementsSelectable={true} - edgesReconnectable={!isReadOnly} - panOnScroll - panOnDrag={true} - multiSelectionKeyCode="Control" - fitView - className={`project-canvas ${isReadOnly ? "read-only" : ""}`} - proOptions={{ hideAttribution: true }} +
+ -
+ + {!isReadOnly && ( - + - {!isReadOnly && ( - - - - )} - + )} + {/* Background disabled to prevent global rasterization blur */} - {!hasSeenOnboarding && isCoreOnly && !isPreviewMode && ( - -
-
- -
- -
- -
-
-

Magic Paste

-

{dict.project.onboardingHint}

-
-
- - )} + {!hasSeenOnboarding && isCoreOnly && !isPreviewMode && ( - {!isPreviewMode && !isMobileTopbar && ( -
- - {dict.project.shareCursor} - - { - e.stopPropagation(); - setShareCursor(e.target.checked); - }} - /> - +
+
+ +
+ +
+ +
+
+

Magic Paste

+

{dict.project.onboardingHint}

+
+
+ + )} + + {!isPreviewMode && !isMobileTopbar && ( +
+ + {dict.project.shareCursor} + + { + e.stopPropagation(); + setShareCursor(e.target.checked); + }} + /> + + +
+ )} +
+ + + {isPreviewMode && ( +
+ + {dict.canvas.previewMode} + +
-
- )} - - - - {isPreviewMode && ( -
- - {dict.canvas.previewMode} - -
+ {currentUser?.id === projectOwnerId && ( - {currentUser?.id === projectOwnerId && ( - - )} -
+ )}
- )} +
+ )} -
- {!isPreviewMode && ( -
- -
- {presenceUsers.map((u) => ( +
+ {!isPreviewMode && ( +
+ +
+ {presenceUsers.map((u) => ( +
-
- {u.displayName -
- -
- {u.displayName || u.username} -
-
+ {u.displayName
- ))} -
+ +
+ {u.displayName || u.username} +
+
+
+ ))}
- )} +
+ )} - {isMobileTopbar ? ( -
+ + + - {isMobileActionsOpen && ( -
- {!isPreviewMode && ( - - )} - - {!isPreviewMode && ( - - )} - - {!isPreviewMode && ( - - )} - - {currentUserRole !== "viewer" && ( - - )} - - {currentUser?.id === projectOwnerId && ( - - )} - - {!isPreviewMode && ( - - )} -
- )} -
- ) : ( -
- {currentUserRole !== "viewer" && ( -
- + )} + + {!isPreviewMode && ( + + )} + + {currentUserRole !== "viewer" && ( + - {pendingRequestsCount > 0 && ( - - {pendingRequestsCount} - - )} -
- )} - {currentUser?.id === projectOwnerId && ( + {dict.project.access || "Access"} + {pendingRequestsCount > 0 && ( + + {pendingRequestsCount} + + )} + + )} + + {currentUser?.id === projectOwnerId && ( + + )} + + {!isPreviewMode && ( + + )} +
+ )} +
+ ) : ( +
+ {currentUserRole !== "viewer" && ( +
- )} - -
- )} -
- - - {isMobileTopbar && ( - - )} + {pendingRequestsCount > 0 && ( + + {pendingRequestsCount} + + )} +
+ )} + {currentUser?.id === projectOwnerId && ( + + )} + +
+ )} +
+ + + {isMobileTopbar && ( + + )} -
-
- - {zoom}% - -
+
+
+ + {zoom}% +
+
- + - - - - - - - - - - - - - - - - - - -
+ + + + + + + + + + + + + + + + + {contextMenu && (