feat(ai): add resizable maximized chat panel
This commit is contained in:
1 parent
add44cf39e
commit
2927e01591
14 files changed
+182
-11
No files matched your search
@@ -237,6 +237,7 @@ const tabSwitcherIndex = ref(0);
|
||||
const agentDriverUpdateCount = ref(0);
|
||||
const showHistory = ref(false);
|
||||
const showAiPanel = ref(safeLocalStorageGet("dbx-ai-panel-open") === "true");
|
||||
const isAiPanelMaximized = ref(false);
|
||||
const showSqlLibraryPanel = ref(safeLocalStorageGet("dbx-sql-library-open") === "true");
|
||||
const showSqlFilePanel = ref(safeLocalStorageGet("dbx-sql-file-panel-open") === "true");
|
||||
const rightSidebarPanelRefs: Record<RightSidebarPanelId, typeof showAiPanel> = {
|
||||
@@ -826,6 +827,13 @@ function applyRightSidebarPanelState(next: RightSidebarPanelState) {
|
||||
}
|
||||
|
||||
function setRightSidebarPanelOpen(panelId: RightSidebarPanelId, open: boolean) {
|
||||
if (panelId === "ai" && !open) {
|
||||
isAiPanelMaximized.value = false;
|
||||
} else if (open && panelId !== "ai" && isAiPanelMaximized.value) {
|
||||
// Opening another right-side panel should make the hidden panel visible again
|
||||
// instead of leaving it behind the maximized AI surface.
|
||||
isAiPanelMaximized.value = false;
|
||||
}
|
||||
const exclusive = settingsStore.isEditorSettingsLoaded && settingsStore.editorSettings.toolbarItems.exclusiveRightSidebarPanels;
|
||||
applyRightSidebarPanelState(transitionRightSidebarPanels(currentRightSidebarPanelState(), panelId, open, exclusive));
|
||||
if (open) {
|
||||
@@ -847,6 +855,11 @@ function closeRightSidebarPanel(panelId: RightSidebarPanelId) {
|
||||
setRightSidebarPanelOpen(panelId, false);
|
||||
}
|
||||
|
||||
function toggleAiPanelMaximized() {
|
||||
if (!showAiPanel.value) return;
|
||||
isAiPanelMaximized.value = !isAiPanelMaximized.value;
|
||||
}
|
||||
|
||||
function invokeWhenAiReady(invoke: (handle: AiAssistantHandle) => void) {
|
||||
if (aiAssistantRef.value) {
|
||||
invoke(aiAssistantRef.value);
|
||||
@@ -2847,7 +2860,7 @@ onUnmounted(() => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div :class="isClassicLayout ? 'flex-1 min-w-0 overflow-hidden' : 'flex-1 min-w-0 overflow-hidden rounded-md border border-border/80 bg-background'">
|
||||
<div v-show="!isAiPanelMaximized" :class="isClassicLayout ? 'flex-1 min-w-0 overflow-hidden' : 'flex-1 min-w-0 overflow-hidden rounded-md border border-border/80 bg-background'">
|
||||
<div class="h-full flex flex-col min-w-0">
|
||||
<AppTabBar
|
||||
ref="appTabBarRef"
|
||||
@@ -3015,14 +3028,19 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showAiPanel" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: aiPanelWidth + 'px' }">
|
||||
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startAiPanelResize" />
|
||||
<div
|
||||
v-if="showAiPanel"
|
||||
:class="[isClassicLayout ? 'h-full relative z-30 isolate bg-background' : 'h-full relative z-30 isolate rounded-md border border-border/80 bg-background', isAiPanelMaximized ? 'min-w-0 flex-1' : 'min-w-[180px] max-w-full']"
|
||||
:style="isAiPanelMaximized ? {} : { width: aiPanelWidth + 'px' }"
|
||||
>
|
||||
<div v-if="!isAiPanelMaximized" class="panel-resize-handle panel-resize-handle--left" @mousedown="startAiPanelResize" />
|
||||
<div class="h-full min-h-0 overflow-hidden rounded-[inherit]">
|
||||
<AiAssistant
|
||||
v-if="aiPanelReady"
|
||||
ref="aiAssistantRef"
|
||||
:tab="activeTab"
|
||||
:connection="activeConnection"
|
||||
:maximized="isAiPanelMaximized"
|
||||
@replace-sql="onAiReplaceSql"
|
||||
@execute-sql="onAiExecuteSql"
|
||||
@temp-run-sql="onAiTempRunSql"
|
||||
@@ -3030,26 +3048,27 @@ onUnmounted(() => {
|
||||
@insert-redis-command="(command: string) => routeAiRedisCommand(command, false)"
|
||||
@execute-redis-command="(command: string) => routeAiRedisCommand(command, true)"
|
||||
@open-explain-plan="onAiOpenExplainPlan"
|
||||
@toggle-maximize="toggleAiPanelMaximized"
|
||||
@close="closeRightSidebarPanel('ai')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showHistory" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: historyWidth + 'px' }">
|
||||
<div v-if="showHistory" v-show="!isAiPanelMaximized" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: historyWidth + 'px' }">
|
||||
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startHistoryResize" />
|
||||
<div class="h-full min-h-0 overflow-hidden rounded-[inherit]">
|
||||
<QueryHistory :current-connection-id="activeTab?.connectionId" :current-database="activeTab?.database" @restore="restoreHistorySql" @analyze-ai="analyzeHistoryWithAi" @close="closeRightSidebarPanel('history')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showSqlLibraryPanel" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: sqlLibraryWidth + 'px' }">
|
||||
<div v-if="showSqlLibraryPanel" v-show="!isAiPanelMaximized" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: sqlLibraryWidth + 'px' }">
|
||||
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startSqlLibraryResize" />
|
||||
<div class="h-full min-h-0 overflow-hidden rounded-[inherit]">
|
||||
<SqlLibraryPanel @close="closeRightSidebarPanel('sqlLibrary')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showSqlFilePanel" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: sqlFilePanelWidth + 'px' }">
|
||||
<div v-if="showSqlFilePanel" v-show="!isAiPanelMaximized" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: sqlFilePanelWidth + 'px' }">
|
||||
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startSqlFilePanelResize" />
|
||||
<div class="h-full min-h-0 overflow-hidden rounded-[inherit]">
|
||||
<SqlFilePanel @close="closeRightSidebarPanel('sqlFile')" />
|
||||
|
||||
@@ -23,7 +23,9 @@ import {
|
||||
HelpCircle,
|
||||
History,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
MessageSquarePlus,
|
||||
Minimize2,
|
||||
Pencil,
|
||||
Plus,
|
||||
Replace,
|
||||
@@ -213,6 +215,7 @@ interface ChatMessage {
|
||||
const props = defineProps<{
|
||||
tab?: QueryTab;
|
||||
connection?: ConnectionConfig;
|
||||
maximized?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -223,6 +226,7 @@ const emit = defineEmits<{
|
||||
insertRedisCommand: [command: string];
|
||||
executeRedisCommand: [command: string];
|
||||
openExplainPlan: [sql: string];
|
||||
toggleMaximize: [];
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
@@ -3127,7 +3131,11 @@ async function openExternalUrl(url: string) {
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" @click="clearMessages" :title="t('ai.clear')">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" @click="emit('close')">
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" :title="props.maximized ? t('ai.restore') : t('ai.maximize')" :aria-label="props.maximized ? t('ai.restore') : t('ai.maximize')" :aria-pressed="props.maximized" @click="emit('toggleMaximize')">
|
||||
<Minimize2 v-if="props.maximized" class="h-3.5 w-3.5" />
|
||||
<Maximize2 v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" :title="t('common.close')" :aria-label="t('common.close')" @click="emit('close')">
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("AI assistant maximize control", () => {
|
||||
it("exposes a parent-controlled maximize toggle with accessible labels", () => {
|
||||
expect(source).toContain("maximized?: boolean;");
|
||||
expect(source).toContain("toggleMaximize: [];");
|
||||
expect(source).toContain(":title=\"props.maximized ? t('ai.restore') : t('ai.maximize')\"");
|
||||
expect(source).toContain(":aria-label=\"props.maximized ? t('ai.restore') : t('ai.maximize')\"");
|
||||
expect(source).toContain(':aria-pressed="props.maximized"');
|
||||
expect(source).toContain("@click=\"emit('toggleMaximize')\"");
|
||||
expect(source).toContain('<Minimize2 v-if="props.maximized"');
|
||||
expect(source).toContain("<Maximize2 v-else");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { usePanelResize } from "@/composables/usePanelResize";
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
const localStorageMock = {
|
||||
clear: () => storage.clear(),
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
};
|
||||
|
||||
function rect(left: number, width: number): DOMRect {
|
||||
return {
|
||||
bottom: 600,
|
||||
height: 600,
|
||||
left,
|
||||
right: left + width,
|
||||
top: 0,
|
||||
width,
|
||||
x: left,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
describe("usePanelResize", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", localStorageMock);
|
||||
localStorageMock.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("lets the AI panel consume the available editor width beyond the legacy 800px cap", () => {
|
||||
localStorage.setItem("dbx-ai-panel-width", "360");
|
||||
|
||||
const editor = document.createElement("div");
|
||||
const panel = document.createElement("div");
|
||||
const handle = document.createElement("div");
|
||||
panel.append(handle);
|
||||
document.body.append(editor, panel);
|
||||
|
||||
vi.spyOn(editor, "getBoundingClientRect").mockReturnValue(rect(300, 700));
|
||||
vi.spyOn(panel, "getBoundingClientRect").mockReturnValue(rect(1000, 360));
|
||||
|
||||
const { aiPanelWidth, startAiPanelResize } = usePanelResize();
|
||||
handle.addEventListener("mousedown", startAiPanelResize);
|
||||
handle.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: 1000 }));
|
||||
document.dispatchEvent(new MouseEvent("mousemove", { clientX: -1000 }));
|
||||
document.dispatchEvent(new MouseEvent("mouseup"));
|
||||
|
||||
expect(aiPanelWidth.value).toBe(1060);
|
||||
expect(localStorage.getItem("dbx-ai-panel-width")).toBe("1060");
|
||||
});
|
||||
|
||||
it("resizes from the flex-shrunk width after a wide panel is restored in a narrow window", () => {
|
||||
localStorage.setItem("dbx-ai-panel-width", "1060");
|
||||
|
||||
const editor = document.createElement("div");
|
||||
const panel = document.createElement("div");
|
||||
const handle = document.createElement("div");
|
||||
panel.append(handle);
|
||||
document.body.append(editor, panel);
|
||||
|
||||
vi.spyOn(editor, "getBoundingClientRect").mockReturnValue(rect(300, 0));
|
||||
vi.spyOn(panel, "getBoundingClientRect").mockReturnValue(rect(300, 700));
|
||||
|
||||
const { aiPanelWidth, startAiPanelResize } = usePanelResize();
|
||||
handle.addEventListener("mousedown", startAiPanelResize);
|
||||
handle.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: 300 }));
|
||||
document.dispatchEvent(new MouseEvent("mousemove", { clientX: 400 }));
|
||||
document.dispatchEvent(new MouseEvent("mouseup"));
|
||||
|
||||
expect(aiPanelWidth.value).toBe(600);
|
||||
expect(localStorage.getItem("dbx-ai-panel-width")).toBe("600");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,20 @@
|
||||
import { ref, type Ref } from "vue";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
|
||||
const PANEL_MIN_WIDTH = 180;
|
||||
const DEFAULT_PANEL_MAX_WIDTH = 800;
|
||||
type PanelMaxWidth = number | ((handle: HTMLElement | null) => number);
|
||||
|
||||
function availableAiPanelMaxWidth(handle: HTMLElement | null) {
|
||||
const panel = handle?.parentElement;
|
||||
const flexibleContent = panel?.previousElementSibling as HTMLElement | null;
|
||||
if (!panel || !flexibleContent) return DEFAULT_PANEL_MAX_WIDTH;
|
||||
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const contentRect = flexibleContent.getBoundingClientRect();
|
||||
return Math.max(PANEL_MIN_WIDTH, panelRect.width + panelRect.left - contentRect.left);
|
||||
}
|
||||
|
||||
export function usePanelResize() {
|
||||
const sidebarWidth = ref(Number(safeLocalStorageGet("dbx-sidebar-width")) || 260);
|
||||
const aiPanelWidth = ref(Number(safeLocalStorageGet("dbx-ai-panel-width")) || 360);
|
||||
@@ -8,15 +22,21 @@ export function usePanelResize() {
|
||||
const sqlLibraryWidth = ref(Number(safeLocalStorageGet("dbx-sql-library-width")) || 288);
|
||||
const sqlFilePanelWidth = ref(Number(safeLocalStorageGet("dbx-sql-file-panel-width")) || 288);
|
||||
|
||||
function startPanelResize(widthRef: Ref<number>, storageKey: string, direction: "left" | "right") {
|
||||
function startPanelResize(widthRef: Ref<number>, storageKey: string, direction: "left" | "right", maxWidth: PanelMaxWidth = DEFAULT_PANEL_MAX_WIDTH) {
|
||||
return (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startWidth = widthRef.value;
|
||||
const resizeHandle = e.currentTarget as HTMLElement | null;
|
||||
const resolvedMaxWidth = typeof maxWidth === "function" ? maxWidth(resizeHandle) : maxWidth;
|
||||
const upperBound = Number.isFinite(resolvedMaxWidth) ? Math.max(PANEL_MIN_WIDTH, resolvedMaxWidth) : DEFAULT_PANEL_MAX_WIDTH;
|
||||
const renderedWidth = resizeHandle?.parentElement?.getBoundingClientRect().width;
|
||||
const requestedStartWidth = typeof renderedWidth === "number" && Number.isFinite(renderedWidth) && renderedWidth > 0 ? renderedWidth : widthRef.value;
|
||||
const startWidth = Math.max(PANEL_MIN_WIDTH, Math.min(upperBound, requestedStartWidth));
|
||||
widthRef.value = startWidth;
|
||||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
const delta = ev.clientX - startX;
|
||||
widthRef.value = Math.max(180, Math.min(800, startWidth + (direction === "right" ? delta : -delta)));
|
||||
widthRef.value = Math.max(PANEL_MIN_WIDTH, Math.min(upperBound, startWidth + (direction === "right" ? delta : -delta)));
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
@@ -31,7 +51,7 @@ export function usePanelResize() {
|
||||
}
|
||||
|
||||
const startSidebarResize = startPanelResize(sidebarWidth, "dbx-sidebar-width", "right");
|
||||
const startAiPanelResize = startPanelResize(aiPanelWidth, "dbx-ai-panel-width", "left");
|
||||
const startAiPanelResize = startPanelResize(aiPanelWidth, "dbx-ai-panel-width", "left", availableAiPanelMaxWidth);
|
||||
const startHistoryResize = startPanelResize(historyWidth, "dbx-history-width", "left");
|
||||
const startSqlLibraryResize = startPanelResize(sqlLibraryWidth, "dbx-sql-library-width", "left");
|
||||
const startSqlFilePanelResize = startPanelResize(sqlFilePanelWidth, "dbx-sql-file-panel-width", "left");
|
||||
|
||||
@@ -2476,6 +2476,8 @@ export default {
|
||||
append: "Append to Editor",
|
||||
apply: "Apply to Editor",
|
||||
clear: "Clear Chat",
|
||||
maximize: "Maximize",
|
||||
restore: "Restore",
|
||||
welcome: "Tell me what you'd like to query, and I'll write the SQL",
|
||||
requestFailed: "AI request failed",
|
||||
requestCancelled: "Request cancelled",
|
||||
|
||||
@@ -2516,6 +2516,8 @@ export default withEnglishFallback({
|
||||
append: "Agregar al editor",
|
||||
apply: "Aplicar al editor",
|
||||
clear: "Limpiar chat",
|
||||
maximize: "Maximizar",
|
||||
restore: "Restaurar",
|
||||
welcome: "Dime qué quieres consultar y escribiré el SQL",
|
||||
requestFailed: "La solicitud de AI falló",
|
||||
requestCancelled: "Solicitud de AI cancelada",
|
||||
|
||||
@@ -2410,6 +2410,8 @@ export default withEnglishFallback({
|
||||
append: "Accoda all'Editor",
|
||||
apply: "Applica all'Editor",
|
||||
clear: "Cancella Chat",
|
||||
maximize: "Massimizza",
|
||||
restore: "Ripristina",
|
||||
welcome: "Dimmi cosa vorresti interrogare e io scriverò il codice SQL",
|
||||
requestFailed: "Richiesta AI non riuscita",
|
||||
requestCancelled: "Richiesta AI annullata",
|
||||
|
||||
@@ -2549,6 +2549,8 @@ export default withEnglishFallback({
|
||||
append: "エディタに追加",
|
||||
apply: "エディタに適用",
|
||||
clear: "チャットをクリア",
|
||||
maximize: "最大化",
|
||||
restore: "元に戻す",
|
||||
welcome: "クエリしたい内容を教えてください。SQLを作成します",
|
||||
requestFailed: "AIリクエストに失敗しました",
|
||||
requestCancelled: "AIリクエストがキャンセルされました",
|
||||
|
||||
@@ -2307,6 +2307,8 @@ export default withEnglishFallback({
|
||||
append: "편집기에 추가",
|
||||
apply: "편집기에 적용",
|
||||
clear: "채팅 지우기",
|
||||
maximize: "최대화",
|
||||
restore: "복원",
|
||||
welcome: "쿼리할 내용을 말씀해 주세요. SQL을 작성해 드릴게요",
|
||||
requestFailed: "AI 요청 실패",
|
||||
requestCancelled: "AI 요청이 취소되었습니다",
|
||||
|
||||
@@ -2518,6 +2518,8 @@ export default withEnglishFallback({
|
||||
append: "Anexar ao Editor",
|
||||
apply: "Aplicar ao Editor",
|
||||
clear: "Limpar Conversa",
|
||||
maximize: "Maximizar",
|
||||
restore: "Restaurar",
|
||||
welcome: "Diga o que você gostaria de consultar e eu escreverei o SQL",
|
||||
requestFailed: "Falha na solicitação de AI",
|
||||
requestCancelled: "Solicitação de AI cancelada",
|
||||
|
||||
@@ -2400,6 +2400,8 @@ export default withEnglishFallback({
|
||||
append: "追加到编辑器",
|
||||
apply: "应用到编辑器",
|
||||
clear: "清空对话",
|
||||
maximize: "最大化",
|
||||
restore: "还原",
|
||||
welcome: "聊聊你想查什么,我来写 SQL",
|
||||
requestFailed: "AI 请求失败",
|
||||
requestCancelled: "请求已取消",
|
||||
|
||||
@@ -2410,6 +2410,8 @@ export default withEnglishFallback({
|
||||
append: "追加到編輯器",
|
||||
apply: "套用到編輯器",
|
||||
clear: "清空對話",
|
||||
maximize: "最大化",
|
||||
restore: "還原",
|
||||
welcome: "告訴我你想查詢什麼,我會寫出 SQL",
|
||||
requestFailed: "AI 請求失敗",
|
||||
requestCancelled: "AI 請求已取消",
|
||||
|
||||
@@ -49,4 +49,13 @@ describe("right sidebar panel entry points", () => {
|
||||
}
|
||||
expect(appSource).not.toMatch(/watch\([\s\S]{0,180}toolbarItems\.(ai|history|sqlLibrary|sqlFileTree)[\s\S]{0,180}closeRightSidebarPanel/);
|
||||
});
|
||||
|
||||
it("keeps the maximized AI surface in the parent layout", () => {
|
||||
expect(appSource).toContain("const isAiPanelMaximized = ref(false);");
|
||||
expect(appSource).toContain(':maximized="isAiPanelMaximized"');
|
||||
expect(appSource).toContain('@toggle-maximize="toggleAiPanelMaximized"');
|
||||
expect(appSource).toContain("isAiPanelMaximized ? 'min-w-0 flex-1' : 'min-w-[180px] max-w-full'");
|
||||
expect(appSource).toContain('v-show="!isAiPanelMaximized"');
|
||||
expect(functionSource("setRightSidebarPanelOpen", "toggleRightSidebarPanel")).toContain("isAiPanelMaximized.value = false;");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user