From 42ab7b51dd345f839f6546ec08596384de4960a3 Mon Sep 17 00:00:00 2001 From: rain9 <15911122312@163.com> Date: Wed, 31 Dec 2025 21:54:36 +0800 Subject: [PATCH] chore: up --- docs/content.en/docs/release-notes/_index.md | 2 + src-tauri/capabilities/default.json | 2 +- src-tauri/src/lib.rs | 25 ++- src/components/Search/InputBox.tsx | 9 +- src/components/Search/Search.tsx | 11 +- src/components/Search/SearchIcons.tsx | 12 +- src/components/Search/ViewExtension.tsx | 84 +++++++--- src/components/Search/ViewExtensionIframe.tsx | 42 ++++- src/components/SearchChat/index.tsx | 10 +- src/hooks/useCanNavigateBack.ts | 3 +- src/hooks/useEscape.ts | 4 +- src/hooks/useViewExtensionUI.ts | 46 ++++++ src/hooks/useViewExtensionWindow.ts | 151 +++++------------- src/pages/main/index.tsx | 4 +- src/pages/view-extension/index.tsx | 18 ++- src/stores/extensionStore.ts | 59 ++++++- src/utils/index.ts | 52 +----- src/utils/tauriAdapter.ts | 4 + src/utils/webAdapter.ts | 8 +- src/utils/wrappers/tauriWrappers.ts | 6 + 20 files changed, 337 insertions(+), 215 deletions(-) create mode 100644 src/hooks/useViewExtensionUI.ts diff --git a/docs/content.en/docs/release-notes/_index.md b/docs/content.en/docs/release-notes/_index.md index 09c2da59..f9ad48a2 100644 --- a/docs/content.en/docs/release-notes/_index.md +++ b/docs/content.en/docs/release-notes/_index.md @@ -15,6 +15,7 @@ Information about release notes of Coco App is provided here. - feat: support app search even if Spotlight is disabled #1028 - feat: read localized names from root InfoPlist.strings #1029 +- feat: view extension supports detachable #1042 ### 🐛 Bug fix @@ -23,6 +24,7 @@ Information about release notes of Coco App is provided here. - fix: fix arrow keys not working after closing the context menu #1035 - fix: fix some filter fields not working #1037 - fix: apply local results weight to scores generated by rerank() #1036 +- fix: prevent window collapse when chat history is open #1039 ### ✈️ Improvements diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 78f0e48f..e7b27e6b 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for the main window", - "windows": ["main", "chat", "settings", "check", "selection", "view_extension"], + "windows": ["main", "chat", "settings", "check", "selection", "view_extension", "view_extension_*"], "permissions": [ "core:default", "core:event:allow-emit", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9c1b2fd1..ac36c44e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -399,11 +399,17 @@ async fn show_settings(app_handle: AppHandle) { } #[tauri::command] -async fn show_view_extension(app_handle: AppHandle) { +async fn show_view_extension( + app_handle: AppHandle, + label: Option, + query: Option, + width: Option, + height: Option, +) { log::debug!("view extension menu item was clicked"); - let label = VIEW_EXTENSION_WINDOW_LABEL; + let window_label = label.unwrap_or_else(|| VIEW_EXTENSION_WINDOW_LABEL.to_string()); - if let Some(window) = app_handle.get_webview_window(label) { + if let Some(window) = app_handle.get_webview_window(&window_label) { window.show().unwrap(); window.unminimize().unwrap(); window.set_focus().unwrap(); @@ -411,13 +417,18 @@ async fn show_view_extension(app_handle: AppHandle) { } // If window doesn't exist (e.g. was closed), create it - let url = WebviewUrl::App("/ui/view-extension".into()); - let build_result = WebviewWindowBuilder::new(&app_handle, label, url) + let url_suffix = query.unwrap_or_else(|| "".to_string()); + let url = WebviewUrl::App(format!("/ui/view-extension{}", url_suffix).into()); + let w = width.unwrap_or(1000.0); + let h = height.unwrap_or(800.0); + + let build_result = WebviewWindowBuilder::new(&app_handle, &window_label, url) .title("View Extension") - .inner_size(1000.0, 800.0) + .inner_size(w, h) .min_inner_size(800.0, 600.0) .resizable(true) - .visible(true) + .center() + .visible(false) .build(); match build_result { diff --git a/src/components/Search/InputBox.tsx b/src/components/Search/InputBox.tsx index 32e6fc05..16b1ad99 100644 --- a/src/components/Search/InputBox.tsx +++ b/src/components/Search/InputBox.tsx @@ -22,9 +22,8 @@ import { canNavigateBack, getUploadedAttachmentsId, isDefaultServer, - visibleFilterBar, - visibleSearchBar, } from "@/utils"; +import { useVisibleSearchBar, useVisibleFilterBar } from "@/hooks/useViewExtensionUI"; import { useTauriFocus } from "@/hooks/useTauriFocus"; import { SendMessageParams } from "../Assistant/Chat"; import { isEmpty } from "lodash-es"; @@ -92,6 +91,8 @@ export default function ChatInput({ getFileIcon, }: ChatInputProps) { const { t } = useTranslation(); + const isVisibleSearchBar = useVisibleSearchBar(); + const isVisibleFilterBar = useVisibleFilterBar(); const { currentAssistant } = useConnectStore(); @@ -311,7 +312,7 @@ export default function ChatInput({ className={`flex items-center dark:text-[#D8D8D8] rounded-md transition-all relative overflow-hidden`} > {lineCount === 1 && renderSearchIcon()} - {visibleSearchBar() && ( + {isVisibleSearchBar && (
- {visibleFilterBar() && ( + {isVisibleFilterBar && ( state.viewExtensionOpened); + const viewExtensionOpened = useExtensionStore((state) => + state.viewExtensions.length > 0 ? state.viewExtensions[state.viewExtensions.length - 1] : undefined + ); useEffect(() => { if (selectedSearchContent?.type === "AI Assistant") { @@ -174,12 +176,13 @@ function Search({ formatUrl, }: SearchProps) { const mainWindowRef = useRef(null); + const isVisibleFooterBar = useVisibleFooterBar(); return (
- {visibleFooterBar() &&
} + {isVisibleFooterBar &&
}
diff --git a/src/components/Search/SearchIcons.tsx b/src/components/Search/SearchIcons.tsx index cefee51c..9dfbf22e 100644 --- a/src/components/Search/SearchIcons.tsx +++ b/src/components/Search/SearchIcons.tsx @@ -9,7 +9,8 @@ import { useThemeStore } from "@/stores/themeStore"; import { useSearchStore } from "@/stores/searchStore"; import { useExtensionStore } from "@/stores/extensionStore"; import platformAdapter from "@/utils/platformAdapter"; -import { navigateBack, visibleSearchBar } from "@/utils"; +import { navigateBack } from "@/utils"; +import { useVisibleSearchBar } from "@/hooks/useViewExtensionUI"; import VisibleKey from "../Common/VisibleKey"; import { cn } from "@/lib/utils"; @@ -21,6 +22,7 @@ interface MultilevelWrapperProps { const MultilevelWrapper: FC = (props) => { const { icon, title = "" } = props; const { isDark } = useThemeStore(); + const isVisibleSearchBar = useVisibleSearchBar(); const renderIcon = () => { if (!icon) { @@ -40,8 +42,8 @@ const MultilevelWrapper: FC = (props) => { className={clsx( "flex items-center h-10 gap-1 px-2 border border-(--border) rounded-l-lg", { - "justify-center": visibleSearchBar(), - "w-[calc(100vw-16px)] rounded-r-lg": !visibleSearchBar(), + "justify-center": isVisibleSearchBar, + "w-[calc(100vw-16px)] rounded-r-lg": !isVisibleSearchBar, } )} > @@ -78,7 +80,9 @@ export default function SearchIcons({ selectedExtension } = useSearchStore(); - const viewExtensionOpened = useExtensionStore((state) => state.viewExtensionOpened); + const viewExtensionOpened = useExtensionStore((state) => + state.viewExtensions.length > 0 ? state.viewExtensions[state.viewExtensions.length - 1] : undefined + ); if (isChatMode) { return null; diff --git a/src/components/Search/ViewExtension.tsx b/src/components/Search/ViewExtension.tsx index 84a3a1a0..530e9c9a 100644 --- a/src/components/Search/ViewExtension.tsx +++ b/src/components/Search/ViewExtension.tsx @@ -3,27 +3,34 @@ import { useState, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Focus, ExternalLink } from "lucide-react"; -import { useExtensionStore } from "@/stores/extensionStore"; +import { useExtensionStore, type ViewExtensionOpened } from "@/stores/extensionStore"; import platformAdapter from "@/utils/platformAdapter"; import { useShortcutsStore } from "@/stores/shortcutsStore"; import { useViewExtensionWindow } from "@/hooks/useViewExtensionWindow"; import ViewExtensionIframe from "./ViewExtensionIframe"; -import { apiPermissionCheck, fsPermissionCheck } from "./viewExtensionPermissions"; +import { + apiPermissionCheck, + fsPermissionCheck, +} from "./viewExtensionPermissions"; type ControlsProps = { showFullscreen?: boolean; showDetach?: boolean; showFocus?: boolean; forceResizable?: boolean; + initialViewExtensionOpened?: ViewExtensionOpened | null; }; const ViewExtensionContent: React.FC = ({ - showFullscreen = true, showDetach = true, showFocus = true, forceResizable = false, + initialViewExtensionOpened = null, }) => { - const viewExtensionOpened = useExtensionStore((state) => state.viewExtensionOpened); + const storeView = useExtensionStore((state) => + state.viewExtensions.length > 0 ? state.viewExtensions[state.viewExtensions.length - 1] : undefined + ); + const viewExtensionOpened = initialViewExtensionOpened ?? storeView; // Complete list of the backend APIs, grouped by their category. const [apis, setApis] = useState | null>(null); @@ -177,45 +184,70 @@ const ViewExtensionContent: React.FC = ({ detachable, hideScrollbar, scale, + baseWidth, + baseHeight, iframeRef, focusIframe, + setBaseSize, } = useViewExtensionWindow({ forceResizable }); + const [iframeReady, setIframeReady] = useState(false); + return ( -
-
- {resizable && showFocus && ( - - )} - {((detachable && showDetach) || (resizable && showFullscreen)) && ( - - )} -
+
+ {iframeReady && ( +
+ {resizable && showFocus && ( + + )} + {detachable && showDetach && ( + + )} +
+ )} setIframeReady(ok)} + onContentSize={(size) => setBaseSize(size.width, size.height)} />
); }; const ViewExtension: React.FC = (props) => { - const viewExtensionOpened = useExtensionStore((state) => state.viewExtensionOpened); + const viewExtensionOpened = useExtensionStore( + (state) => state.viewExtensions.length > 0 ? state.viewExtensions[state.viewExtensions.length - 1] : undefined + ); if (viewExtensionOpened == null) { return ( diff --git a/src/components/Search/ViewExtensionIframe.tsx b/src/components/Search/ViewExtensionIframe.tsx index 124ae183..85318ac9 100644 --- a/src/components/Search/ViewExtensionIframe.tsx +++ b/src/components/Search/ViewExtensionIframe.tsx @@ -44,10 +44,24 @@ type ViewExtensionIframeProps = { iframeRef: React.MutableRefObject; hideScrollbar: boolean; focusIframe: () => void; + onLoaded?: (success: boolean) => void; + onContentSize?: (size: { width: number; height: number }) => void; + baseWidth?: number; + baseHeight?: number; }; export default function ViewExtensionIframe(props: ViewExtensionIframeProps) { - const { fileUrl, scale, iframeRef, hideScrollbar, focusIframe } = props; + const { + fileUrl, + scale, + iframeRef, + hideScrollbar, + focusIframe, + onLoaded, + onContentSize, + baseWidth, + baseHeight, + } = props; const isSameOrigin = () => { try { @@ -84,6 +98,8 @@ export default function ViewExtensionIframe(props: ViewExtensionIframeProps) { className="border-0 w-full h-full" scrolling={hideScrollbar ? "no" : "auto"} style={{ + width: baseWidth ? `${baseWidth}px` : undefined, + height: baseHeight ? `${baseHeight}px` : undefined, transform: `scale(${scale})`, transformOrigin: "center center", outline: "none", @@ -98,7 +114,31 @@ export default function ViewExtensionIframe(props: ViewExtensionIframeProps) { iframeRef.current?.contentWindow?.focus(); } catch {} applyHideScrollbarToIframe(event.currentTarget, hideScrollbar); + try { + const doc = event.currentTarget.contentDocument!; + const root = doc.documentElement; + const body = doc.body; + const width = Math.max( + root?.scrollWidth ?? 0, + body?.scrollWidth ?? 0, + root?.clientWidth ?? 0, + body?.clientWidth ?? 0 + ); + const height = Math.max( + root?.scrollHeight ?? 0, + body?.scrollHeight ?? 0, + root?.clientHeight ?? 0, + body?.clientHeight ?? 0 + ); + if (width > 0 && height > 0) { + onContentSize?.({ width, height }); + } + } catch {} } + onLoaded?.(true); + }} + onError={() => { + onLoaded?.(false); }} />
diff --git a/src/components/SearchChat/index.tsx b/src/components/SearchChat/index.tsx index c66525e7..5a8ceeaa 100644 --- a/src/components/SearchChat/index.tsx +++ b/src/components/SearchChat/index.tsx @@ -31,9 +31,8 @@ import type { StartPage } from "@/types/chat"; import { canNavigateBack, hasUploadingAttachment, - visibleFilterBar, - visibleSearchBar, } from "@/utils"; +import { useVisibleSearchBar, useVisibleFilterBar } from "@/hooks/useViewExtensionUI"; import { useTauriFocus } from "@/hooks/useTauriFocus"; import { POPOVER_PANEL_SELECTOR, @@ -79,6 +78,8 @@ function SearchChat({ formatUrl, }: SearchChatProps) { const currentAssistant = useConnectStore((state) => state.currentAssistant); + const isVisibleSearchBar = useVisibleSearchBar(); + const isVisibleFilterBar = useVisibleFilterBar(); const source = currentAssistant?._source; @@ -121,7 +122,8 @@ function SearchChat({ }); const setWindowSize = useCallback(() => { - const { viewExtensionOpened } = useExtensionStore.getState(); + const { viewExtensions } = useExtensionStore.getState(); + const viewExtensionOpened = viewExtensions.length > 0 ? viewExtensions[viewExtensions.length - 1] : undefined; if (collapseWindowTimer.current) { clearTimeout(collapseWindowTimer.current); @@ -480,7 +482,7 @@ function SearchChat({ className={clsx( "relative p-2 w-full flex justify-center transition-all duration-500", { - "min-h-[84px]": visibleSearchBar() && visibleFilterBar(), + "min-h-[84px]": isVisibleSearchBar && isVisibleFilterBar, } )} > diff --git a/src/hooks/useCanNavigateBack.ts b/src/hooks/useCanNavigateBack.ts index 1da65fd8..0fef30ab 100644 --- a/src/hooks/useCanNavigateBack.ts +++ b/src/hooks/useCanNavigateBack.ts @@ -11,7 +11,8 @@ export const useCanNavigateBack = () => { sourceData, } = useSearchStore(); - const { viewExtensionOpened } = useExtensionStore.getState(); + const { viewExtensions } = useExtensionStore.getState(); + const viewExtensionOpened = viewExtensions.length > 0 ? viewExtensions[viewExtensions.length - 1] : undefined; const canNavigateBack = useMemo(() => { return ( diff --git a/src/hooks/useEscape.ts b/src/hooks/useEscape.ts index 3716edd1..601883e3 100644 --- a/src/hooks/useEscape.ts +++ b/src/hooks/useEscape.ts @@ -8,7 +8,9 @@ import { closeHistoryPanel } from "@/utils"; const useEscape = () => { const { setVisibleContextMenu } = useSearchStore(); - const viewExtensionOpened = useExtensionStore((state) => state.viewExtensionOpened); + const viewExtensionOpened = useExtensionStore((state) => + state.viewExtensions.length > 0 ? state.viewExtensions[state.viewExtensions.length - 1] : undefined + ); useKeyPress("esc", (event) => { event.preventDefault(); diff --git a/src/hooks/useViewExtensionUI.ts b/src/hooks/useViewExtensionUI.ts new file mode 100644 index 00000000..cb1bf4ad --- /dev/null +++ b/src/hooks/useViewExtensionUI.ts @@ -0,0 +1,46 @@ +import { useSearchStore } from "@/stores/searchStore"; +import { useExtensionStore } from "@/stores/extensionStore"; +import { isNil } from "lodash-es"; + +export const useVisibleSearchBar = () => { + const visibleExtensionDetail = useSearchStore((state) => state.visibleExtensionDetail); + const viewExtensions = useExtensionStore((state) => state.viewExtensions); + + const viewExtensionOpened = viewExtensions.length > 0 ? viewExtensions[viewExtensions.length - 1] : undefined; + + if (visibleExtensionDetail) return false; + + if (isNil(viewExtensionOpened)) return true; + + const ui = viewExtensionOpened[4]; + + return ui?.search_bar ?? false; +}; + +export const useVisibleFilterBar = () => { + const visibleExtensionStore = useSearchStore((state) => state.visibleExtensionStore); + const visibleExtensionDetail = useSearchStore((state) => state.visibleExtensionDetail); + const goAskAi = useSearchStore((state) => state.goAskAi); + + const viewExtensions = useExtensionStore((state) => state.viewExtensions); + const viewExtensionOpened = viewExtensions.length > 0 ? viewExtensions[viewExtensions.length - 1] : undefined; + + if (visibleExtensionStore || visibleExtensionDetail || goAskAi) return false; + + if (isNil(viewExtensionOpened)) return true; + + const ui = viewExtensionOpened[4]; + + return ui?.filter_bar ?? false; +}; + +export const useVisibleFooterBar = () => { + const viewExtensions = useExtensionStore((state) => state.viewExtensions); + const viewExtensionOpened = viewExtensions.length > 0 ? viewExtensions[viewExtensions.length - 1] : undefined; + + if (isNil(viewExtensionOpened)) return true; + + const ui = viewExtensionOpened[4]; + + return ui?.footer ?? false; +}; diff --git a/src/hooks/useViewExtensionWindow.ts b/src/hooks/useViewExtensionWindow.ts index f4cb987f..356b3d10 100644 --- a/src/hooks/useViewExtensionWindow.ts +++ b/src/hooks/useViewExtensionWindow.ts @@ -1,10 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import platformAdapter from "@/utils/platformAdapter"; -import { isMac } from "@/utils/platform"; import type { ViewExtensionUISettingsOrNull } from "@/components/Settings/Extensions"; import { useAppStore } from "@/stores/appStore"; -import { useExtensionStore } from "@/stores/extensionStore"; +import { useExtensionStore, type ViewExtensionOpened } from "@/stores/extensionStore"; type WindowSnapshot = { width: number; @@ -14,11 +13,18 @@ type WindowSnapshot = { y: number; }; -export function useViewExtensionWindow(opts?: { forceResizable?: boolean }) { - const isTauri = useAppStore((state) => state.isTauri); - const viewExtensionOpened = useExtensionStore((state) => state.viewExtensionOpened); - - if (viewExtensionOpened == null) { +export function useViewExtensionWindow(opts?: { + forceResizable?: boolean; + ignoreExplicitSize?: boolean; + viewExtension?: ViewExtensionOpened | null; +}) { + const isTauri = useAppStore((state) => state.isTauri); + const storeViewExtension = useExtensionStore((state) => + state.viewExtensions.length > 0 ? state.viewExtensions[state.viewExtensions.length - 1] : undefined + ); + const viewExtensionOpened = opts?.viewExtension ?? storeViewExtension; + + if (viewExtensionOpened == null) { throw new Error( "ViewExtension Error: viewExtensionOpened is null. This should not happen." ); @@ -33,12 +39,11 @@ export function useViewExtensionWindow(opts?: { forceResizable?: boolean }) { const uiWidth = ui && typeof ui.width === "number" ? ui.width : null; const uiHeight = ui && typeof ui.height === "number" ? ui.height : null; - const hasExplicitWindowSize = uiWidth != null && uiHeight != null; + const hasExplicitWindowSize = + uiWidth != null && uiHeight != null && !opts?.ignoreExplicitSize; const iframeRef = useRef(null); - const [isFullscreen, setIsFullscreen] = useState(false); const prevWindowRef = useRef(null); - const fullscreenPrevRef = useRef(null); const [scale, setScale] = useState(1); const [fallbackViewSize, setFallbackViewSize] = useState<{ width: number; @@ -78,93 +83,13 @@ export function useViewExtensionWindow(opts?: { forceResizable?: boolean }) { return; } const size = await platformAdapter.getWindowSize(); - const nextScale = Math.min(size.width / baseWidth, size.height / baseHeight); + const ratioW = size.width / baseWidth; + const ratioH = size.height / baseHeight; + const nextScale = Math.min(ratioW, ratioH); setScale(Math.max(nextScale, 0.1)); }, [baseHeight, baseWidth, hasExplicitWindowSize]); - const applyFullscreen = useCallback( - async (next: boolean, options?: { centerOnExit?: boolean }) => { - const centerOnExit = options?.centerOnExit ?? true; - if (next) { - const size = await platformAdapter.getWindowSize(); - const resizable = await platformAdapter.isWindowResizable(); - const pos = await platformAdapter.getWindowPosition(); - fullscreenPrevRef.current = { - width: size.width, - height: size.height, - resizable, - x: pos.x, - y: pos.y, - }; - if (isMac && isTauri) { - const monitor = await platformAdapter.getMonitorFromCursor(); - if (!monitor) return; - - const window = await platformAdapter.getCurrentWebviewWindow(); - const factor = await window.scaleFactor(); - - const { size, position } = monitor; - const { width, height } = size.toLogical(factor); - const { x, y } = position.toLogical(factor); - - await platformAdapter.setWindowSize(width, height); - await platformAdapter.setWindowPosition(x, y); - await platformAdapter.setWindowResizable(true); - await recomputeScale(); - } else { - await platformAdapter.setWindowFullscreen(true); - await recomputeScale(); - } - } else { - const prevPos = - fullscreenPrevRef.current != null - ? { x: fullscreenPrevRef.current.x, y: fullscreenPrevRef.current.y } - : null; - - if (!isMac) { - await platformAdapter.setWindowFullscreen(false); - } - - if (fullscreenPrevRef.current) { - const prev = fullscreenPrevRef.current; - await platformAdapter.setWindowSize(prev.width, prev.height); - await platformAdapter.setWindowResizable(prev.resizable); - fullscreenPrevRef.current = null; - } else if (hasExplicitWindowSize) { - const nextResizable = - ui && typeof ui.resizable === "boolean" ? ui.resizable : true; - await platformAdapter.setWindowSize(uiWidth, uiHeight); - await platformAdapter.setWindowResizable(nextResizable); - } - - if (centerOnExit) { - await platformAdapter.centerOnCurrentMonitor(); - } else if (prevPos != null) { - await platformAdapter.setWindowPosition(prevPos.x, prevPos.y); - } - - await recomputeScale(); - focusIframeSoon(); - } - }, - [ - focusIframeSoon, - hasExplicitWindowSize, - isTauri, - recomputeScale, - ui, - uiHeight, - uiWidth, - ] - ); - - const toggleFullscreen = useCallback(async () => { - const next = !isFullscreen; - await applyFullscreen(next); - setIsFullscreen(next); - if (next) focusIframe(); - }, [applyFullscreen, focusIframe, isFullscreen]); useEffect(() => { const applyWindowSettings = async () => { @@ -180,6 +105,10 @@ export function useViewExtensionWindow(opts?: { forceResizable?: boolean }) { y: pos.y, }; + if (isTauri && (await platformAdapter.isWindowFullscreen())) { + return; + } + if (hasExplicitWindowSize) { const nextResizable = opts?.forceResizable @@ -194,6 +123,13 @@ export function useViewExtensionWindow(opts?: { forceResizable?: boolean }) { } else { await recomputeScale(); } + + // If we are in the standalone view extension window (not the preview in main window), + // we need to show the window explicitly because it is created hidden to avoid flickering. + if (window.location.pathname.includes("/ui/view-extension")) { + await platformAdapter.showWindow(); + } + focusIframeSoon(); }; @@ -201,15 +137,11 @@ export function useViewExtensionWindow(opts?: { forceResizable?: boolean }) { return () => { if (prevWindowRef.current) { const prev = prevWindowRef.current; - if (!isMac && fullscreenPrevRef.current != null) { - platformAdapter.setWindowFullscreen(false); - } platformAdapter.setWindowSize(prev.width, prev.height); platformAdapter.setWindowResizable(prev.resizable); platformAdapter.setWindowPosition(prev.x, prev.y); prevWindowRef.current = null; - fullscreenPrevRef.current = null; } }; }, [ @@ -222,19 +154,16 @@ export function useViewExtensionWindow(opts?: { forceResizable?: boolean }) { ]); useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape" && isFullscreen) { - applyFullscreen(false); - setIsFullscreen(false); + const handleResize = async () => { + if (hasExplicitWindowSize) { + recomputeScale(); } }; - window.addEventListener("keydown", handleKeyDown, { capture: true }); + window.addEventListener("resize", handleResize); return () => { - window.removeEventListener("keydown", handleKeyDown, { - capture: true, - } as any); + window.removeEventListener("resize", handleResize); }; - }, [applyFullscreen, isFullscreen]); + }, [hasExplicitWindowSize, recomputeScale]); return { ui, @@ -242,9 +171,15 @@ export function useViewExtensionWindow(opts?: { forceResizable?: boolean }) { detachable, hideScrollbar, scale, + baseWidth, + baseHeight, iframeRef, - isFullscreen, - toggleFullscreen, focusIframe, + setBaseSize: (width: number, height: number) => { + if (!hasExplicitWindowSize) { + setFallbackViewSize({ width, height }); + recomputeScale(); + } + }, }; } diff --git a/src/pages/main/index.tsx b/src/pages/main/index.tsx index df028327..6a5ff37f 100644 --- a/src/pages/main/index.tsx +++ b/src/pages/main/index.tsx @@ -12,7 +12,7 @@ import platformAdapter from "@/utils/platformAdapter"; function MainApp() { const { setIsTauri } = useAppStore(); - const setViewExtensionOpened = useExtensionStore((state) => state.setViewExtensionOpened); + const addViewExtension = useExtensionStore((state) => state.addViewExtension); useEffect(() => { setIsTauri(true); @@ -24,7 +24,7 @@ function MainApp() { platformAdapter.listenEvent("open_view_extension", async ({ payload }) => { await platformAdapter.showWindow(); - setViewExtensionOpened(payload); + addViewExtension(payload); }); }, []); diff --git a/src/pages/view-extension/index.tsx b/src/pages/view-extension/index.tsx index 117e830a..3e766348 100644 --- a/src/pages/view-extension/index.tsx +++ b/src/pages/view-extension/index.tsx @@ -1,11 +1,27 @@ import React from "react"; import ViewExtension from "@/components/Search/ViewExtension"; +import type { ViewExtensionOpened } from "@/stores/extensionStore"; const ViewExtensionPage: React.FC = () => { + const params = new URLSearchParams(window.location.search); + const encoded = params.get("ext"); + let initial: ViewExtensionOpened | null = null; + if (encoded) { + try { + const json = decodeURIComponent(atob(encoded)); + initial = JSON.parse(json) as ViewExtensionOpened; + } catch {} + } return (
- +
); }; diff --git a/src/stores/extensionStore.ts b/src/stores/extensionStore.ts index c4d1705e..971fd3db 100644 --- a/src/stores/extensionStore.ts +++ b/src/stores/extensionStore.ts @@ -18,15 +18,68 @@ export type ViewExtensionOpened = [ ]; type ExtensionStore = { - // When we open a View extension, we set this to a non-null value. - viewExtensionOpened?: ViewExtensionOpened; + // A stack of opened view extensions. The last one is the active one. + viewExtensions: ViewExtensionOpened[]; + + // Actions + addViewExtension: (viewExtension: ViewExtensionOpened) => void; + closeViewExtension: () => void; + + // Helper to clear all extensions (optional, for resetting) + clearViewExtensions: () => void; + + // Deprecated: use addViewExtension or closeViewExtension + // We keep the setter signature but it will act on the stack. + // This is to make refactoring easier, but ideally we should rename usages. setViewExtensionOpened: (showViewExtension?: ViewExtensionOpened) => void; } // A Zustand store, like any other. export const useExtensionStore = create((set) => ({ + viewExtensions: [], + + addViewExtension: (viewExtension) => { + set((state) => { + // Ensure uniqueness by name (index 0). + // If it exists, remove it first so the new one is added to the end (top of stack). + const name = viewExtension[0]; + const others = state.viewExtensions.filter((ext) => ext[0] !== name); + + return { + viewExtensions: [...others, viewExtension] + }; + }); + }, + + closeViewExtension: () => { + set((state) => { + if (state.viewExtensions.length === 0) return {}; + return { viewExtensions: state.viewExtensions.slice(0, -1) }; + }); + }, + + clearViewExtensions: () => { + set({ viewExtensions: [] }); + }, + + // Compatibility adapter setViewExtensionOpened: (viewExtensionOpened) => { - return set({ viewExtensionOpened }); + if (viewExtensionOpened) { + set((state) => { + // Same uniqueness logic as addViewExtension + const name = viewExtensionOpened[0]; + const others = state.viewExtensions.filter((ext) => ext[0] !== name); + + return { + viewExtensions: [...others, viewExtensionOpened] + }; + }); + } else { + set((state) => { + if (state.viewExtensions.length === 0) return {}; + return { viewExtensions: state.viewExtensions.slice(0, -1) }; + }); + } }, })); diff --git a/src/utils/index.ts b/src/utils/index.ts index 444da67e..61d43d65 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -232,13 +232,13 @@ export const canNavigateBack = () => { visibleExtensionDetail, sourceData, } = useSearchStore.getState(); - const { viewExtensionOpened } = useExtensionStore.getState(); + const { viewExtensions } = useExtensionStore.getState(); return ( goAskAi || visibleExtensionStore || visibleExtensionDetail || - viewExtensionOpened || + viewExtensions.length > 0 || sourceData ); }; @@ -254,7 +254,7 @@ export const navigateBack = () => { setSourceData, } = useSearchStore.getState(); - const { viewExtensionOpened, setViewExtensionOpened } = useExtensionStore.getState(); + const { viewExtensions, closeViewExtension } = useExtensionStore.getState(); if (goAskAi) { return setGoAskAi(false); @@ -268,8 +268,8 @@ export const navigateBack = () => { return setVisibleExtensionStore(false); } - if (viewExtensionOpened) { - setViewExtensionOpened(void 0); + if (viewExtensions.length > 0) { + closeViewExtension(); platformAdapter.emitEvent("refresh-window-size"); return; } @@ -304,48 +304,6 @@ export const dispatchEvent = ( target.dispatchEvent(event); }; -export const visibleSearchBar = () => { - const { visibleExtensionDetail } = useSearchStore.getState(); - - const { viewExtensionOpened } = useExtensionStore.getState(); - - if (visibleExtensionDetail) return false; - - if (isNil(viewExtensionOpened)) return true; - - const ui = viewExtensionOpened[4]; - - return ui?.search_bar ?? false; -}; - -export const visibleFilterBar = () => { - const { - visibleExtensionStore, - visibleExtensionDetail, - goAskAi, - } = useSearchStore.getState(); - - const { viewExtensionOpened } = useExtensionStore.getState(); - - if (visibleExtensionStore || visibleExtensionDetail || goAskAi) return false; - - if (isNil(viewExtensionOpened)) return true; - - const ui = viewExtensionOpened[4]; - - return ui?.filter_bar ?? false; -}; - -export const visibleFooterBar = () => { - const { viewExtensionOpened } = useExtensionStore.getState(); - - if (isNil(viewExtensionOpened)) return true; - - const ui = viewExtensionOpened[4]; - - return ui?.footer ?? false; -}; - export const installExtensionError = (error: any) => { console.log(error); diff --git a/src/utils/tauriAdapter.ts b/src/utils/tauriAdapter.ts index 97463096..2b9c4f76 100644 --- a/src/utils/tauriAdapter.ts +++ b/src/utils/tauriAdapter.ts @@ -43,6 +43,7 @@ export interface TauriPlatformAdapter extends BasePlatformAdapter { isWindowResizable: () => Promise; getWindowSize: () => Promise<{ width: number; height: number }>; setWindowFullscreen: (enable: boolean) => Promise; + isWindowFullscreen: () => Promise; isWindowMaximized: () => Promise; setWindowMaximized: (enable: boolean) => Promise; getWindowPosition: () => Promise<{ x: number; y: number }>; @@ -70,6 +71,9 @@ export const createTauriAdapter = (): TauriPlatformAdapter => { async setWindowFullscreen(enable) { return windowWrapper.setFullscreen(enable); }, + async isWindowFullscreen() { + return windowWrapper.isFullscreen(); + }, async isWindowMaximized() { return windowWrapper.isMaximized(); }, diff --git a/src/utils/webAdapter.ts b/src/utils/webAdapter.ts index b41f8f14..1e37de14 100644 --- a/src/utils/webAdapter.ts +++ b/src/utils/webAdapter.ts @@ -16,6 +16,7 @@ export interface WebPlatformAdapter extends BasePlatformAdapter { isWindowResizable: () => Promise; getWindowSize: () => Promise<{ width: number; height: number }>; setWindowFullscreen: (enable: boolean) => Promise; + isWindowFullscreen: () => Promise; getMonitorFromCursor: () => Promise; getWindowPosition: () => Promise<{ x: number; y: number }>; setWindowPosition: (x: number, y: number) => Promise; @@ -54,8 +55,13 @@ export const createWebAdapter = (): WebPlatformAdapter => { return true; }, async setWindowFullscreen(enable) { - console.log("Web mode simulated fullscreen:", enable); + console.log("Web mode simulated set window fullscreen:", enable); }, + + async isWindowFullscreen() { + return false; + }, + async getMonitorFromCursor() { return { size: { diff --git a/src/utils/wrappers/tauriWrappers.ts b/src/utils/wrappers/tauriWrappers.ts index 5a68dd7f..e6422637 100644 --- a/src/utils/wrappers/tauriWrappers.ts +++ b/src/utils/wrappers/tauriWrappers.ts @@ -48,6 +48,12 @@ export const windowWrapper = { return win.setFullscreen(enable); }, + async isFullscreen() { + const { getCurrentWindow } = await import("@tauri-apps/api/window"); + const win = getCurrentWindow(); + return win.isFullscreen(); + }, + async setLogicalPosition(x: number, y: number) { const { LogicalPosition } = await import("@tauri-apps/api/dpi"); const window = await this.getCurrentWebviewWindow();