Fix detached chart window close and attach behavior

This commit is contained in:
litoral05
2026-05-28 08:37:00 +01:00
parent ffe3c64cfa
commit c54c2c6518
8 changed files with 1214 additions and 320 deletions
+317 -259
View File
@@ -1,4 +1,8 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import { ChartConfigModal } from "../components/ChartConfigModal";
import { openChartWindow } from "../utils/openChartWindow";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import {
Cog,
Copy,
@@ -48,8 +52,15 @@ type ChartWorkspaceItem = PersistedChartWorkspaceItem & {
subtitle: string;
mode: WorkspaceChartMode;
selectedSensorKeys: string[];
hiddenSensorKeys?: string[];
timeRange: WorkspaceChartTimeRange;
interval: WorkspaceChartInterval;
detached?: boolean;
windowX?: number;
windowY?: number;
windowWidth?: number;
windowHeight?: number;
windowZIndex?: number;
};
const RADIUS = "rounded-[6px]";
@@ -111,7 +122,7 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
const visibleCharts = useMemo(() => {
const openCharts = charts.filter(
(chart) => !chart.hidden && !chart.collapsed,
(chart) => !chart.hidden && !chart.collapsed && !chart.detached,
);
if (layoutMode === "single") return openCharts.slice(0, 1);
@@ -165,26 +176,33 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
}) => {
if (!canAddMoreCharts) return;
const newChart: ChartWorkspaceItem = {
id: `chart-${Date.now()}`,
title,
subtitle: "Gráfico personalizado de telemetria.",
mode,
selectedSensorKeys,
timeRange: "24h",
interval: "5m",
};
const newChartId = `chart-${Date.now()}`;
setCharts((current) => {
const next = [...current, newChart];
const visibleSlotCount = getVisibleSlotCount(layoutMode);
const visibleOpenCount = current.filter(
(chart) => !chart.hidden && !chart.collapsed && !chart.detached
).length;
if (next.length >= 3) {
setLayoutMode("fourGrid");
} else if (next.length === 2) {
setLayoutMode("twoColumns");
const shouldAskPlacement = visibleOpenCount >= visibleSlotCount;
const newChart: ChartWorkspaceItem = {
id: newChartId,
title,
subtitle: "Gráfico personalizado de telemetria.",
mode,
selectedSensorKeys,
timeRange: "24h",
interval: "5m",
hidden: false,
collapsed: shouldAskPlacement,
};
if (shouldAskPlacement) {
setPlacingChartId(newChartId);
}
return next;
return [...current, newChart];
});
setNewChartOpen(false);
@@ -228,30 +246,19 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
current.map((chart) => {
if (chart.id !== chartId) return chart;
const alreadySelected = chart.selectedSensorKeys.includes(key);
if (!alreadySelected && chart.selectedSensorKeys.length >= MAX_VARIABLES_PER_CHART) {
return chart;
}
const hiddenSensorKeys = chart.hiddenSensorKeys ?? [];
const isHidden = hiddenSensorKeys.includes(key);
return {
...chart,
selectedSensorKeys: alreadySelected
? chart.selectedSensorKeys.filter((item) => item !== key)
: [...chart.selectedSensorKeys, key],
hiddenSensorKeys: isHidden
? hiddenSensorKeys.filter((item) => item !== key)
: [...hiddenSensorKeys, key],
};
}),
);
};
const updateChartTitle = (chartId: string, title: string) => {
setCharts((current) =>
current.map((chart) =>
chart.id === chartId ? { ...chart, title } : chart,
),
);
};
const loadSavedChart = (chartId: string) => {
setCharts((current) => {
const selected = current.find((chart) => chart.id === chartId);
@@ -269,11 +276,18 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
setPlacingChartId(null);
};
const closeChart = (chartId: string) => {
const closeChart = async (chartId: string) => {
await closeDetachedChartWindow(chartId);
setCharts((current) =>
current.map((chart) =>
chart.id === chartId
? { ...chart, hidden: true, collapsed: false }
? {
...chart,
detached: false,
hidden: true,
collapsed: false,
}
: chart,
),
);
@@ -283,7 +297,9 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
if (placingChartId === chartId) setPlacingChartId(null);
};
const deleteChart = (chartId: string) => {
const deleteChart = async (chartId: string) => {
await closeDetachedChartWindow(chartId);
setCharts((current) => current.filter((chart) => chart.id !== chartId));
if (configChartId === chartId) setConfigChartId(null);
@@ -295,19 +311,26 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
if (!placingChartId || placingChartId === targetChartId) return;
setCharts((current) => {
const source = current.find((chart) => chart.id === placingChartId);
const sourceIndex = current.findIndex((chart) => chart.id === placingChartId);
const targetIndex = current.findIndex((chart) => chart.id === targetChartId);
if (!source || targetIndex === -1 || !source.hidden) return current;
if (sourceIndex === -1 || targetIndex === -1) return current;
const withoutSource = current.filter((chart) => chart.id !== placingChartId);
const next = [...withoutSource];
const next = [...current];
const source = next[sourceIndex];
const target = next[targetIndex];
next.splice(targetIndex, 0, {
next[targetIndex] = {
...source,
hidden: false,
collapsed: false,
});
};
next[sourceIndex] = {
...target,
hidden: false,
collapsed: true,
};
return next;
});
@@ -317,23 +340,195 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
const openSavedChart = (chartId: string) => {
const chart = charts.find((item) => item.id === chartId);
if (!chart || !chart.hidden) return;
if (!chart) return;
const openCount = charts.filter((item) => !item.hidden).length;
const isActuallyOpen = !chart.hidden && !chart.collapsed && !chart.detached;
if (isActuallyOpen) return;
if (openCount === 0) {
const visibleSlotCount = getVisibleSlotCount(layoutMode);
const visibleOpenCount = charts.filter(
(item) => !item.hidden && !item.collapsed && !item.detached,
).length;
if (visibleOpenCount === 0) {
loadSavedChart(chartId);
return;
}
if (openCount >= 2) {
setLayoutMode("fourGrid");
if (visibleOpenCount < visibleSlotCount) {
loadSavedChart(chartId);
return;
}
setPlacingChartId(chartId);
setSavedOpen(false);
};
const closeDetachedChartWindow = async (chartId: string) => {
const label = `chart-${chartId}`;
const existing = await WebviewWindow.getByLabel(label);
if (!existing) return;
try {
await existing.destroy();
} catch (error) {
console.error(`Failed to destroy detached chart window: ${label}`, error);
}
};
const detachChart = (chartId: string) => {
setCharts((current) =>
current.map((chart) =>
chart.id === chartId
? {
...chart,
detached: true,
hidden: false,
collapsed: false,
}
: chart,
),
);
window.setTimeout(() => {
void openChartWindow(chartId);
}, 100);
};
const attachChart = async (chartId: string) => {
setCharts((current) =>
current.map((chart) =>
chart.id === chartId
? {
...chart,
detached: false,
hidden: false,
collapsed: false,
}
: chart,
),
);
await closeDetachedChartWindow(chartId);
};
const moveDetachedChart = (chartId: string, x: number, y: number) => {
setCharts((current) =>
current.map((chart) =>
chart.id === chartId
? { ...chart, windowX: x, windowY: y }
: chart,
),
);
};
useEffect(() => {
const unlistenAttachPromise = listen<{
chartId: string;
chart: ChartWorkspaceItem | null;
}>("maincharts://attach-chart", (event) => {
const chartId = event.payload.chartId;
const updatedChart = event.payload.chart;
setCharts((current) => {
const visibleSlotCount = getVisibleSlotCount(layoutMode);
const visibleOpenCount = current.filter(
(chart) =>
!chart.hidden &&
!chart.collapsed &&
!chart.detached &&
chart.id !== chartId,
).length;
const shouldAskPlacement = visibleOpenCount >= visibleSlotCount;
if (shouldAskPlacement) {
setPlacingChartId(chartId);
}
return current.map((chart) =>
chart.id === chartId
? {
...chart,
...(updatedChart ?? {}),
detached: false,
hidden: false,
collapsed: shouldAskPlacement,
}
: chart,
);
});
});
const unlistenHidePromise = listen<{
chartId?: string;
chart?: ChartWorkspaceItem | null;
}>("maincharts://hide-chart", (event) => {
const chartId = event.payload.chartId ?? event.payload.chart?.id;
if (!chartId) return;
setCharts((current) =>
current.map((chart) =>
chart.id === chartId
? {
...chart,
...(event.payload.chart ?? {}),
detached: false,
hidden: true,
collapsed: false,
}
: chart,
),
);
setPlacingChartId((current) => (current === chartId ? null : current));
});
const unlistenUpdatePromise = listen<{
chartId: string;
patch: Partial<ChartWorkspaceItem>;
}>(
"maincharts://update-chart",
(event) => {
const { chartId, patch } = event.payload;
setCharts((current) =>
current.map((chart) =>
chart.id === chartId
? {
...chart,
...patch,
}
: chart,
),
);
},
);
const unlistenReplacePromise = listen<{ chart: ChartWorkspaceItem }>(
"maincharts://replace-chart",
(event) => {
const updatedChart = event.payload.chart;
setCharts((current) =>
current.map((chart) =>
chart.id === updatedChart.id
? { ...chart, ...updatedChart }
: chart,
),
);
},
);
return () => {
void unlistenAttachPromise.then((unlisten) => unlisten());
void unlistenHidePromise.then((unlisten) => unlisten());
void unlistenUpdatePromise.then((unlisten) => unlisten());
void unlistenReplacePromise.then((unlisten) => unlisten());
};
}, [layoutMode]);
return (
<div className="space-y-4 pb-6">
<div
@@ -476,6 +671,9 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
setCharts={setCharts}
placingChartId={placingChartId}
placeChartHere={placeChartHere}
detachChart={detachChart}
attachChart={attachChart}
moveDetachedChart={moveDetachedChart}
/>
))}
@@ -487,7 +685,7 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
setCharts((current) => {
const source = current.find((chart) => chart.id === placingChartId);
if (!source || !source.hidden) return current;
if (!source || (!source.hidden && !source.collapsed)) return current;
const withoutSource = current.filter((chart) => chart.id !== placingChartId);
return [
@@ -536,9 +734,17 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
chart={configChart}
variables={chartableVariables}
onClose={() => setConfigChartId(null)}
onTitleChange={(title) => updateChartTitle(configChart.id, title)}
onModeChange={(mode) => setChartMode(configChart.id, mode)}
onVariableToggle={(key) => toggleVariable(configChart.id, key)}
onSave={(updatedChart) => {
setCharts((current) =>
current.map((chart) =>
chart.id === updatedChart.id
? { ...chart, ...updatedChart }
: chart,
),
);
setConfigChartId(null);
}}
/>
)}
</div>
@@ -561,6 +767,9 @@ function WorkspaceChartContainer({
setCharts,
placingChartId,
placeChartHere,
detachChart,
attachChart,
moveDetachedChart
}: {
theme: "dark" | "light";
chartItem: ChartWorkspaceItem;
@@ -570,13 +779,16 @@ function WorkspaceChartContainer({
setMovingChartId: React.Dispatch<React.SetStateAction<string | null>>;
swapCharts: (sourceId: string, targetId: string) => void;
duplicateChart: (chartId: string) => void;
closeChart: (chartId: string) => void;
closeChart: (chartId: string) => void | Promise<void>;
setConfigChartId: React.Dispatch<React.SetStateAction<string | null>>;
setChartMode: (chartId: string, mode: WorkspaceChartMode) => void;
toggleVariable: (chartId: string, key: string) => void;
setCharts: React.Dispatch<React.SetStateAction<ChartWorkspaceItem[]>>;
placingChartId: string | null;
placeChartHere: (targetChartId: string) => void;
detachChart: (chartId: string) => void;
attachChart: (chartId: string) => void | Promise<void>;
moveDetachedChart: (chartId: string, x: number, y: number) => void;
}) {
const isMoving = movingChartId === chartItem.id;
@@ -626,7 +838,7 @@ function WorkspaceChartContainer({
unit: variable.unit,
color: getVariableColor(index),
data: seriesByKey[variable.key] ?? [],
visible: true,
visible: !(chartItem.hiddenSensorKeys ?? []).includes(variable.key),
})),
};
@@ -654,11 +866,25 @@ function WorkspaceChartContainer({
<Copy className="h-4 w-4" />
</button>
<button type="button" title="Destacar janela" className={floatingIconClass(theme)}>
<button
type="button"
title={chartItem.detached ? "Repor janela" : "Destacar janela"}
className={floatingIconClass(theme)}
onClick={() =>
chartItem.detached
? attachChart(chartItem.id)
: detachChart(chartItem.id)
}
>
<Maximize2 className="h-4 w-4" />
</button>
<button type="button" title="Fechar" className={floatingIconClass(theme)} onClick={() => closeChart(chartItem.id)}>
<button
type="button"
title="Fechar"
className={floatingIconClass(theme)}
onClick={() => void closeChart(chartItem.id)}
>
<X className="h-4 w-4" />
</button>
</div>
@@ -691,6 +917,9 @@ function WorkspaceChartContainer({
chart={chartConfig}
configuredVariableCount={chartItem.selectedSensorKeys.length}
loading={loading || variablesStillResolving}
detached={chartItem.detached}
onDetach={() => detachChart(chartItem.id)}
onAttach={() => attachChart(chartItem.id)}
onTimeRangeChange={(range) =>
setCharts((current) =>
current.map((chart) =>
@@ -743,6 +972,32 @@ function WorkspaceChartContainer({
onVariableToggle={(variableKey) =>
toggleVariable(chartItem.id, variableKey)
}
onHeaderPointerDown={
chartItem.detached
? (event) => {
const startX = event.clientX;
const startY = event.clientY;
const initialX = chartItem.windowX ?? 120;
const initialY = chartItem.windowY ?? 120;
const handlePointerMove = (moveEvent: PointerEvent) => {
moveDetachedChart(
chartItem.id,
initialX + moveEvent.clientX - startX,
initialY + moveEvent.clientY - startY,
);
};
const handlePointerUp = () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
}
: undefined
}
/>
</div>
);
@@ -769,7 +1024,7 @@ function SavedChartsDropdown({
onSearchChange: (value: string) => void;
onClose: () => void;
onStartPlacement: (chartId: string) => void;
onDelete: (chartId: string) => void;
onDelete: (chartId: string) => void | Promise<void>;
}) {
const isDark = theme === "dark";
@@ -828,7 +1083,11 @@ function SavedChartsDropdown({
</div>
<div className="flex shrink-0 items-center gap-3">
{chart.hidden ? (
{chart.detached && !chart.hidden ? (
<span className="text-xs font-black text-[#FACC15]">
Destacado
</span>
) : chart.hidden || chart.collapsed ? (
<button
type="button"
onClick={() => onStartPlacement(chart.id)}
@@ -845,7 +1104,7 @@ function SavedChartsDropdown({
<button
type="button"
onClick={() => onDelete(chart.id)}
onClick={() => void onDelete(chart.id)}
title="Eliminar gráfico"
className="text-[#7F8CA3] transition hover:text-red-400"
>
@@ -1109,207 +1368,6 @@ function NewChartModal({
);
}
function ChartConfigModal({
theme,
chart,
variables,
onClose,
onTitleChange,
onModeChange,
onVariableToggle,
}: {
theme: "dark" | "light";
chart: ChartWorkspaceItem;
variables: ChartVariable[];
onClose: () => void;
onTitleChange: (title: string) => void;
onModeChange: (mode: WorkspaceChartMode) => void;
onVariableToggle: (key: string) => void;
}) {
const isDark = theme === "dark";
const [search, setSearch] = useState("");
const filteredVariables = variables.filter((variable) =>
`${variable.label} ${variable.category} ${variable.group} ${variable.unit}`
.toLowerCase()
.includes(search.toLowerCase()),
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-6">
<div
className={
isDark
? `${RADIUS} w-full max-w-[760px] border border-[#263247] bg-[#0E1726] shadow-2xl`
: `${RADIUS} w-full max-w-[760px] border border-[#D7DEE8] bg-white shadow-2xl`
}
>
<header
className={
isDark
? "flex items-center justify-between border-b border-[#263247] px-6 py-5"
: "flex items-center justify-between border-b border-[#D7DEE8] px-6 py-5"
}
>
<div>
<p
className={
isDark
? "text-xs font-black uppercase tracking-[0.2em] text-[#4FD1C5]"
: "text-xs font-black uppercase tracking-[0.2em] text-[#0F766E]"
}
>
Configuração do gráfico
</p>
<h2 className="mt-1 text-xl font-black">{chart.title}</h2>
</div>
<button
type="button"
onClick={onClose}
className={
isDark
? `${RADIUS} p-2 text-[#A8B3C7] transition hover:bg-[#111A2B] hover:text-white`
: `${RADIUS} p-2 text-slate-500 transition hover:bg-[#F8FAFC] hover:text-[#0F172A]`
}
>
<X className="h-5 w-5" />
</button>
</header>
<main className="grid gap-6 p-6 md:grid-cols-[260px_minmax(0,1fr)]">
<section className="space-y-4">
<div>
<label className="mb-2 block text-sm font-black">Nome</label>
<input
value={chart.title}
onChange={(event) => onTitleChange(event.target.value)}
className={
isDark
? `${RADIUS} h-11 w-full border border-[#263247] bg-[#07101B] px-3 text-sm font-bold text-white outline-none`
: `${RADIUS} h-11 w-full border border-[#D7DEE8] bg-[#F8FAFC] px-3 text-sm font-bold text-[#0F172A] outline-none`
}
/>
</div>
<div>
<label className="mb-2 block text-sm font-black">
Tipo de gráfico
</label>
<div className="grid grid-cols-3 gap-2">
<ModeButton
theme={theme}
active={chart.mode === "line"}
icon={<LineChart className="h-4 w-4" />}
label="Linha"
onClick={() => onModeChange("line")}
/>
<ModeButton
theme={theme}
active={chart.mode === "area"}
icon={<AreaChart className="h-4 w-4" />}
label="Área"
onClick={() => onModeChange("area")}
/>
<ModeButton
theme={theme}
active={chart.mode === "bar"}
icon={<BarChart3 className="h-4 w-4" />}
label="Barras"
onClick={() => onModeChange("bar")}
/>
</div>
</div>
<div
className={
isDark
? `${RADIUS} border border-[#263247] bg-[#111A2B] p-3`
: `${RADIUS} border border-[#D7DEE8] bg-[#F8FAFC] p-3`
}
>
<p className="text-xs font-black uppercase tracking-[0.18em] text-[#7F8CA3]">
Selecionadas
</p>
<p className="mt-2 text-sm font-black">
{chart.selectedSensorKeys.length}/{MAX_VARIABLES_PER_CHART}
</p>
</div>
</section>
<section>
<div
className={
isDark
? `${RADIUS} mb-4 flex h-10 items-center gap-2 border border-[#263247] bg-[#07101B] px-3 text-sm text-[#7F8CA3]`
: `${RADIUS} mb-4 flex h-10 items-center gap-2 border border-[#D7DEE8] bg-[#F8FAFC] px-3 text-sm text-slate-500`
}
>
<Search className="h-4 w-4" />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Pesquisar variável..."
className="w-full bg-transparent outline-none placeholder:text-inherit"
/>
</div>
<div className="max-h-[340px] space-y-2 overflow-y-auto pr-1">
{filteredVariables.map((variable, index) => {
const active = chart.selectedSensorKeys.includes(variable.key);
return (
<VariableRow
key={variable.key}
theme={theme}
variable={variable}
color={getVariableColor(index)}
active={active}
disabled={
!active &&
chart.selectedSensorKeys.length >=
MAX_VARIABLES_PER_CHART
}
onClick={() => onVariableToggle(variable.key)}
/>
);
})}
{filteredVariables.length === 0 && (
<EmptyVariableList theme={theme} />
)}
</div>
</section>
</main>
<footer
className={
isDark
? "flex justify-end gap-3 border-t border-[#263247] px-6 py-5"
: "flex justify-end gap-3 border-t border-[#D7DEE8] px-6 py-5"
}
>
<button
type="button"
onClick={onClose}
className={
isDark
? `${RADIUS} border border-[#263247] bg-[#111A2B] px-5 py-2.5 text-sm font-bold text-white`
: `${RADIUS} border border-[#D7DEE8] bg-[#F8FAFC] px-5 py-2.5 text-sm font-bold text-[#0F172A]`
}
>
Fechar
</button>
</footer>
</div>
</div>
);
}
function VariableRow({
theme,
variable,