mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-18 00:47:19 +02:00
refactor: cleanup
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -11,9 +11,11 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||
import type {
|
||||
CookieBotRun,
|
||||
CookieBotSchedule,
|
||||
CookieBotSlot,
|
||||
RemoteHoursQuota,
|
||||
} from "@/lib/cookie-bot";
|
||||
import { MOTION_EASE_OUT } from "@/lib/motion";
|
||||
@@ -66,6 +68,84 @@ export function nightsPerWeek(mask: number): number {
|
||||
return count;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Slots */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Every time-of-day an enrolment fires, from whichever shape the server sent.
|
||||
*
|
||||
* ALWAYS at least one slot. A server that predates multi-slot scheduling sends
|
||||
* only the mirrored `run_at_minute` / `days_mask` pair, and a renderer that read
|
||||
* `slots` directly would show an enrolment as firing at no time at all. Reading
|
||||
* the wire through here is what keeps that fallback in one place.
|
||||
*/
|
||||
export function scheduleSlots(schedule: {
|
||||
slots?: CookieBotSlot[];
|
||||
run_at_minute: number;
|
||||
days_mask: number;
|
||||
}): CookieBotSlot[] {
|
||||
const slots = schedule.slots ?? [];
|
||||
if (slots.length > 0) return slots;
|
||||
return [
|
||||
{ days_mask: schedule.days_mask, run_at_minute: schedule.run_at_minute },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* How many times a week a whole calendar fires.
|
||||
*
|
||||
* Counted across slots, not read off the first one: an enrolment with three
|
||||
* slots costs three times the hours, and the budget estimate beside it is the
|
||||
* only place a user sees that before committing.
|
||||
*
|
||||
* DISTINCT (weekday, minute) pairs rather than a sum of night counts, because
|
||||
* two slots landing on the same weekday at the same minute are the same
|
||||
* instant and the server dispatches them as ONE run — `upcomingSlotsMulti`
|
||||
* collapses coincident fires. Summing quoted a Mon+Tue and a Tue+Wed slot at
|
||||
* 02:00 as four nights when it is three, and that inflated figure is what the
|
||||
* over-budget warning is compared against.
|
||||
*/
|
||||
export function weeklyRuns(
|
||||
slots: { days_mask: number; run_at_minute: number }[],
|
||||
): number {
|
||||
const fires = new Set<number>();
|
||||
for (const slot of slots) {
|
||||
for (let bit = 0; bit < 7; bit += 1) {
|
||||
if ((slot.days_mask & (1 << bit)) !== 0) {
|
||||
fires.add(bit * 1440 + slot.run_at_minute);
|
||||
}
|
||||
}
|
||||
}
|
||||
return fires.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monday-first weekday names, from the viewer's own locale.
|
||||
*
|
||||
* Derived rather than translated into ten locale files: `narrow` already gives
|
||||
* each language its own single-letter convention, and a hand-written table
|
||||
* would be ten more places for Monday-first to be got wrong. The reference week
|
||||
* is formatted in UTC so a user east of Greenwich does not see it shift by a
|
||||
* day.
|
||||
*/
|
||||
export function weekdayNames(): { narrow: string; long: string }[] {
|
||||
// 2024-01-01 was a Monday, which is bit 0 of the server's mask.
|
||||
const monday = Date.UTC(2024, 0, 1);
|
||||
const narrow = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: "narrow",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
const long = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: "long",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
return Array.from({ length: 7 }, (_, index) => {
|
||||
const day = new Date(monday + index * 24 * 60 * 60 * 1000);
|
||||
return { narrow: narrow.format(day), long: long.format(day) };
|
||||
});
|
||||
}
|
||||
|
||||
/** A human cadence label. Unknown masks fall back to the night count. */
|
||||
export function describeCadence(t: TFunction, mask: number): string {
|
||||
const id = cadenceForMask(mask);
|
||||
@@ -409,6 +489,36 @@ export function outcomeLabel(
|
||||
return key ? t(key) : t("cookieBot.outcome.unknown", { code });
|
||||
}
|
||||
|
||||
/**
|
||||
* The three refusals only the saved-list routes can raise.
|
||||
*
|
||||
* They are absent from the shared `backendErrors` table, so
|
||||
* `translateBackendError` renders them through its unknown-code fallback: a
|
||||
* user who reuses a name would be told "Something went wrong:
|
||||
* COOKIE_BOT_TEMPLATE_NAME_TAKEN" instead of that the name is taken, in the one
|
||||
* dialog where the fix is a single keystroke. Everything else — a signed-out
|
||||
* desktop, an unreachable cloud — still goes through the shared translator.
|
||||
*/
|
||||
const TEMPLATE_ERROR_KEYS: Record<string, string> = {
|
||||
COOKIE_BOT_TEMPLATE_NAME_TAKEN: "cookieBot.enrol.templateNameTaken",
|
||||
COOKIE_BOT_INVALID_TEMPLATE_NAME: "cookieBot.enrol.templateNameInvalid",
|
||||
COOKIE_BOT_TEMPLATE_NOT_FOUND: "cookieBot.enrol.templateMissing",
|
||||
};
|
||||
|
||||
export function templateErrorMessage(t: TFunction, error: unknown): string {
|
||||
const parsed = parseBackendError(error);
|
||||
const key = parsed ? TEMPLATE_ERROR_KEYS[parsed.code] : undefined;
|
||||
if (!key) return translateBackendError(t, error);
|
||||
const max = parsed?.params?.max;
|
||||
// The server does not always send a limit. Interpolating an empty string
|
||||
// rendered "a name of characters or fewer", so fall back to wording that
|
||||
// does not need the number.
|
||||
if (key === "cookieBot.enrol.templateNameInvalid" && !max) {
|
||||
return t("cookieBot.enrol.templateNameInvalidNoMax");
|
||||
}
|
||||
return t(key, { max });
|
||||
}
|
||||
|
||||
/**
|
||||
* The session state machine, named honestly. `provisioning -> ready -> live ->
|
||||
* closed`, with `error` reachable from any of the first three, is what the
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LuChevronLeft, LuChevronRight, LuSearch, LuX } from "react-icons/lu";
|
||||
import { useWindowDecorations } from "@/hooks/use-window-decorations";
|
||||
import { getCurrentOS } from "@/lib/browser-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { GroupWithCount } from "@/types";
|
||||
@@ -59,8 +60,14 @@ const HomeHeader = ({
|
||||
}, []);
|
||||
|
||||
const isMacOS = platform === "macos";
|
||||
const isLinux = platform === "linux";
|
||||
const showProfileToolbar = !pageTitle;
|
||||
|
||||
// Same hook the controls use, so the reserved space can never disagree with
|
||||
// what is actually drawn.
|
||||
const decorations = useWindowDecorations();
|
||||
const linuxLayout = decorations.clientSide ? decorations.layout : null;
|
||||
|
||||
// Press-and-hold drag: any pixel of the sys-bar becomes a drag handle after
|
||||
// HOLD_MS, but quick clicks still reach buttons/inputs underneath.
|
||||
const holdTimeoutRef = useRef<number | null>(null);
|
||||
@@ -179,14 +186,29 @@ const HomeHeader = ({
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
className={cn(
|
||||
"flex h-11 items-center gap-2 border-b border-border bg-card pl-3 select-none",
|
||||
"flex h-11 items-center gap-2 border-b border-border bg-card select-none",
|
||||
// Windows: WindowDragArea renders three 44px native-style controls
|
||||
// (minimize + maximize/restore + close) fixed at top-right with
|
||||
// z-50, total 132px wide. Reserve 144px on the right edge so the
|
||||
// "+ New" button and search input clear them with a few pixels of
|
||||
// breathing room and never sit underneath the controls.
|
||||
isWindows ? "pr-[144px]" : "pr-3",
|
||||
isWindows ? "pl-3 pr-[144px]" : null,
|
||||
// Linux reserves its space through the inline style below, because the
|
||||
// desktop chooses which side the controls sit on and how many there
|
||||
// are. Everything else keeps the plain symmetric padding.
|
||||
!isWindows && !isLinux ? "pl-3 pr-3" : null,
|
||||
)}
|
||||
style={
|
||||
isLinux
|
||||
? {
|
||||
// Each control is 44px wide; add the usual 12px gutter. Before
|
||||
// the layout resolves, fall back to the gutter alone rather than
|
||||
// to no padding, which would visibly shift the content.
|
||||
paddingLeft: (linuxLayout?.left.length ?? 0) * 44 + 12,
|
||||
paddingRight: (linuxLayout?.right.length ?? 0) * 44 + 12,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isMacOS && (
|
||||
<div
|
||||
|
||||
@@ -89,6 +89,12 @@ export function ProxyFormDialog({
|
||||
const { t } = useTranslation();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [form, setForm] = useState<ProxyFormData>(DEFAULT_FORM);
|
||||
// The local parse only covers scheme/host/port. Whether Donut can actually
|
||||
// use the server — REALITY, XTLS Vision, plain TCP — is decided by the Rust
|
||||
// parser, so ask it (below) and show the specific reason while the user is
|
||||
// still editing rather than after they save. Declared here because
|
||||
// `handleSubmit` guards on it.
|
||||
const [vlessUnsupported, setVlessUnsupported] = useState<string | null>(null);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setForm(DEFAULT_FORM);
|
||||
@@ -134,6 +140,11 @@ export function ProxyFormDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVless && vlessUnsupported) {
|
||||
toast.error(vlessUnsupported);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isVless && (!form.host.trim() || !form.port)) {
|
||||
toast.error(t("proxies.form.hostPortRequired"));
|
||||
return;
|
||||
@@ -183,7 +194,7 @@ export function ProxyFormDialog({
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [editingProxy, form, onClose, t]);
|
||||
}, [editingProxy, form, onClose, t, vlessUnsupported]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (!isSubmitting) {
|
||||
@@ -193,12 +204,37 @@ export function ProxyFormDialog({
|
||||
|
||||
const isVless = form.proxy_type === "vless";
|
||||
const vlessEndpoint = isVless ? parseVlessEndpoint(form.vless_uri) : null;
|
||||
|
||||
const trimmedVlessUri = form.vless_uri.trim();
|
||||
useEffect(() => {
|
||||
if (!isVless || trimmedVlessUri.length === 0) {
|
||||
setVlessUnsupported(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
void invoke("validate_vless_uri", { uri: trimmedVlessUri })
|
||||
.then(() => {
|
||||
if (!cancelled) setVlessUnsupported(null);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!cancelled) setVlessUnsupported(translateBackendError(t, error));
|
||||
});
|
||||
}, 300);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [isVless, trimmedVlessUri, t]);
|
||||
|
||||
const hasInvalidVlessUri =
|
||||
isVless && form.vless_uri.trim().length > 0 && !vlessEndpoint;
|
||||
isVless &&
|
||||
trimmedVlessUri.length > 0 &&
|
||||
(!vlessEndpoint || vlessUnsupported !== null);
|
||||
const isFormValid =
|
||||
form.name.trim() &&
|
||||
(isVless
|
||||
? vlessEndpoint !== null
|
||||
? vlessEndpoint !== null && vlessUnsupported === null
|
||||
: form.host.trim() &&
|
||||
form.port > 0 &&
|
||||
form.port <= 65535 &&
|
||||
@@ -286,7 +322,7 @@ export function ProxyFormDialog({
|
||||
role={hasInvalidVlessUri ? "alert" : undefined}
|
||||
>
|
||||
{hasInvalidVlessUri
|
||||
? t("proxies.form.vlessUriInvalid")
|
||||
? (vlessUnsupported ?? t("proxies.form.vlessUriInvalid"))
|
||||
: t("proxies.form.vlessUriHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+165
-118
@@ -3,19 +3,23 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWindowDecorations } from "@/hooks/use-window-decorations";
|
||||
import { getCurrentOS, type OperatingSystem } from "@/lib/platform";
|
||||
import type { WindowControl } from "@/lib/window-decorations";
|
||||
import { WindowResizeHandles } from "./window-resize-handles";
|
||||
|
||||
export function WindowDragArea() {
|
||||
const { t } = useTranslation();
|
||||
const [platform, setPlatform] = useState<OperatingSystem | null>(null);
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
const decorations = useWindowDecorations();
|
||||
|
||||
useEffect(() => {
|
||||
setPlatform(getCurrentOS());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (platform !== "windows") return;
|
||||
if (platform !== "windows" && platform !== "linux") return;
|
||||
const win = getCurrentWindow();
|
||||
let cancelled = false;
|
||||
const sync = async () => {
|
||||
@@ -38,42 +42,6 @@ export function WindowDragArea() {
|
||||
};
|
||||
}, [platform]);
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const startDrag = async () => {
|
||||
try {
|
||||
const window = getCurrentWindow();
|
||||
await window.startDragging();
|
||||
} catch (error) {
|
||||
console.error("Failed to start window dragging:", error);
|
||||
}
|
||||
};
|
||||
|
||||
void startDrag();
|
||||
};
|
||||
|
||||
// Linux: system decorations handle everything
|
||||
if (!platform || platform === "linux" || platform === "unknown") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// macOS: nothing to render here. The transparent native titlebar (set via
|
||||
// `set_transparent_titlebar(true)` in src-tauri/src/lib.rs) lets the OS
|
||||
// handle dragging directly, and the sys-bar inside `home-header.tsx`
|
||||
// declares its own `data-tauri-drag-region` overlay for the WebView area.
|
||||
// The previous full-width fixed z-[999999] button was stealing every
|
||||
// click in the top 40px of the window.
|
||||
if (platform === "macos") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Windows: minimize/maximize/close controls anchored at the top-right
|
||||
// corner of the sys-bar. The HomeHeader's own drag-region overlay handles window
|
||||
// dragging via Tauri 2, so we don't need a separate draggable spacer
|
||||
// covering the whole width.
|
||||
const handleMinimize = async () => {
|
||||
try {
|
||||
await getCurrentWindow().minimize();
|
||||
@@ -97,93 +65,172 @@ export function WindowDragArea() {
|
||||
console.error("Failed to close window:", error);
|
||||
}
|
||||
};
|
||||
void handlePointerDown; // kept for backwards-compat; not used on Windows now
|
||||
|
||||
const renderControl = (control: WindowControl) => {
|
||||
switch (control) {
|
||||
case "minimize":
|
||||
return (
|
||||
<button
|
||||
key="minimize"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleMinimize();
|
||||
}}
|
||||
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
aria-label={t("common.window.minimize")}
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="1"
|
||||
viewBox="0 0 10 1"
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
aria-label={t("common.window.minimize")}
|
||||
>
|
||||
<rect width="10" height="1" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
case "maximize":
|
||||
return (
|
||||
<button
|
||||
key="maximize"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleToggleMaximize();
|
||||
}}
|
||||
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
aria-label={
|
||||
isMaximized
|
||||
? t("common.window.restore")
|
||||
: t("common.window.maximize")
|
||||
}
|
||||
>
|
||||
{isMaximized ? (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
role="img"
|
||||
aria-label={t("common.window.restore")}
|
||||
>
|
||||
<rect x="1" y="3" width="6" height="6" />
|
||||
<path d="M3 3 V1 H9 V7 H7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
role="img"
|
||||
aria-label={t("common.window.maximize")}
|
||||
>
|
||||
<rect x="1" y="1" width="8" height="8" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
case "close":
|
||||
return (
|
||||
<button
|
||||
key="close"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleClose();
|
||||
}}
|
||||
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-destructive hover:text-destructive-foreground"
|
||||
aria-label={t("common.window.close")}
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
role="img"
|
||||
aria-label={t("common.window.close")}
|
||||
>
|
||||
<line x1="1" y1="1" x2="9" y2="9" />
|
||||
<line x1="9" y1="1" x2="1" y2="9" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!platform || platform === "unknown") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// macOS: nothing to render here. The transparent native titlebar (set via
|
||||
// `set_transparent_titlebar(true)` in src-tauri/src/lib.rs) lets the OS
|
||||
// handle dragging directly, and the sys-bar inside `home-header.tsx`
|
||||
// declares its own `data-tauri-drag-region` overlay for the WebView area.
|
||||
// The previous full-width fixed z-[999999] button was stealing every
|
||||
// click in the top 40px of the window.
|
||||
if (platform === "macos") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Linux: the window has no server-side decorations, so the app owns both the
|
||||
// controls and the resize edges. Which buttons appear and on which side is a
|
||||
// desktop-wide preference (GNOME's `button-layout`, KWin's decoration
|
||||
// settings), read through GTK so both environments are honored.
|
||||
if (platform === "linux") {
|
||||
// Not resolved yet, or the session keeps server-side decorations (KDE on
|
||||
// Wayland — see `use_client_side_decorations` in the backend). Either way
|
||||
// there is a real titlebar and nothing for the app to draw.
|
||||
if (!decorations.resolved || !decorations.clientSide) {
|
||||
return null;
|
||||
}
|
||||
const { layout } = decorations;
|
||||
return (
|
||||
<>
|
||||
{/* Dropping decorations also drops the compositor's drop shadow, and
|
||||
neither Tauri nor tao exposes a Linux shadow API. Without some edge
|
||||
the window is invisible against a similarly coloured desktop, so
|
||||
draw a hairline. Not rounded: that needs a transparent window, which
|
||||
would conflict with the WebView and the resize strips. */}
|
||||
{!isMaximized && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none fixed inset-0 z-[99999] border border-border"
|
||||
/>
|
||||
)}
|
||||
<WindowResizeHandles isMaximized={isMaximized} />
|
||||
{layout.left.length > 0 && (
|
||||
<div className="fixed top-0 left-0 z-[100000] flex h-11 items-center select-none pointer-events-auto">
|
||||
{layout.left.map(renderControl)}
|
||||
</div>
|
||||
)}
|
||||
{layout.right.length > 0 && (
|
||||
<div className="fixed top-0 right-0 z-[100000] flex h-11 items-center select-none pointer-events-auto">
|
||||
{layout.right.map(renderControl)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Windows: minimize/maximize/close controls anchored at the top-right
|
||||
// corner of the sys-bar. The HomeHeader's own drag-region overlay handles
|
||||
// window dragging via Tauri 2, so we don't need a separate draggable spacer
|
||||
// covering the whole width.
|
||||
return (
|
||||
<div
|
||||
className="fixed top-0 right-0 z-50 flex h-11 items-center select-none"
|
||||
aria-hidden="false"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleMinimize();
|
||||
}}
|
||||
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
aria-label={t("common.window.minimize")}
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="1"
|
||||
viewBox="0 0 10 1"
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
aria-label={t("common.window.minimize")}
|
||||
>
|
||||
<rect width="10" height="1" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleToggleMaximize();
|
||||
}}
|
||||
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
aria-label={
|
||||
isMaximized ? t("common.window.restore") : t("common.window.maximize")
|
||||
}
|
||||
>
|
||||
{isMaximized ? (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
role="img"
|
||||
aria-label={t("common.window.restore")}
|
||||
>
|
||||
<rect x="1" y="3" width="6" height="6" />
|
||||
<path d="M3 3 V1 H9 V7 H7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
role="img"
|
||||
aria-label={t("common.window.maximize")}
|
||||
>
|
||||
<rect x="1" y="1" width="8" height="8" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleClose();
|
||||
}}
|
||||
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-destructive hover:text-destructive-foreground"
|
||||
aria-label={t("common.buttons.close")}
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
role="img"
|
||||
aria-label={t("common.buttons.close")}
|
||||
>
|
||||
<line x1="1" y1="1" x2="9" y2="9" />
|
||||
<line x1="9" y1="1" x2="1" y2="9" />
|
||||
</svg>
|
||||
</button>
|
||||
{(["minimize", "maximize", "close"] as WindowControl[]).map(
|
||||
renderControl,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
/**
|
||||
* Mirrors the API's own `ResizeDirection`, which it declares but does not
|
||||
* export. Structurally identical, so a drift would fail the call below.
|
||||
*/
|
||||
type ResizeDirection =
|
||||
| "East"
|
||||
| "North"
|
||||
| "NorthEast"
|
||||
| "NorthWest"
|
||||
| "South"
|
||||
| "SouthEast"
|
||||
| "SouthWest"
|
||||
| "West";
|
||||
|
||||
/**
|
||||
* Mouse resize areas for a window with no server-side decorations.
|
||||
*
|
||||
* `gtk_window_set_decorated(false)` removes GTK's own invisible resize border
|
||||
* along with the frame, so without these the window can only be resized through
|
||||
* window-manager shortcuts (Super+right-drag and friends). Each handle hands the
|
||||
* pointer to the compositor via `begin_resize_drag`, which is the same call
|
||||
* GTK's client-side decorations make, so edge snapping and the resize cursor
|
||||
* come from the WM exactly as they do for a native window.
|
||||
*
|
||||
* Rendered only where the app owns the frame; on macOS the native titlebar is
|
||||
* still in place and the system draws its own resize edges.
|
||||
*/
|
||||
|
||||
/**
|
||||
* GTK's own grab area is far wider, but all of it sits *outside* the window in
|
||||
* the shadow margin. Ours is inside, so every pixel is taken from real content.
|
||||
*/
|
||||
/** Top edge only — it overlaps the 44px-tall window controls. */
|
||||
const TOP_EDGE = "6px";
|
||||
/** Sides and bottom overlap nothing, so they can be comfortably grabbable. */
|
||||
const EDGE = "8px";
|
||||
/** Corners need to win over the edges that overlap them. */
|
||||
const CORNER = "16px";
|
||||
|
||||
interface Handle {
|
||||
direction: ResizeDirection;
|
||||
style: React.CSSProperties;
|
||||
cursor: string;
|
||||
}
|
||||
|
||||
const HANDLES: Handle[] = [
|
||||
// Edges.
|
||||
{
|
||||
direction: "North",
|
||||
cursor: "ns-resize",
|
||||
style: { top: 0, left: CORNER, right: CORNER, height: TOP_EDGE },
|
||||
},
|
||||
{
|
||||
direction: "South",
|
||||
cursor: "ns-resize",
|
||||
style: { bottom: 0, left: CORNER, right: CORNER, height: EDGE },
|
||||
},
|
||||
{
|
||||
direction: "West",
|
||||
cursor: "ew-resize",
|
||||
style: { left: 0, top: CORNER, bottom: CORNER, width: EDGE },
|
||||
},
|
||||
{
|
||||
direction: "East",
|
||||
cursor: "ew-resize",
|
||||
style: { right: 0, top: CORNER, bottom: CORNER, width: EDGE },
|
||||
},
|
||||
// Corners, drawn after the edges so they sit on top of the overlap.
|
||||
{
|
||||
direction: "NorthWest",
|
||||
cursor: "nwse-resize",
|
||||
style: { top: 0, left: 0, width: CORNER, height: TOP_EDGE },
|
||||
},
|
||||
{
|
||||
direction: "NorthEast",
|
||||
cursor: "nesw-resize",
|
||||
style: { top: 0, right: 0, width: CORNER, height: TOP_EDGE },
|
||||
},
|
||||
{
|
||||
direction: "SouthWest",
|
||||
cursor: "nesw-resize",
|
||||
style: { bottom: 0, left: 0, width: CORNER, height: CORNER },
|
||||
},
|
||||
{
|
||||
direction: "SouthEast",
|
||||
cursor: "nwse-resize",
|
||||
style: { bottom: 0, right: 0, width: CORNER, height: CORNER },
|
||||
},
|
||||
];
|
||||
|
||||
export function WindowResizeHandles({ isMaximized }: { isMaximized: boolean }) {
|
||||
// A maximized window has no resizable edge, and leaving the strips live would
|
||||
// put invisible hit areas over real content. Tauri's own built-in undecorated
|
||||
// resizing disables itself while maximized for the same reason.
|
||||
if (isMaximized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startResize =
|
||||
(direction: ResizeDirection) => (e: React.PointerEvent) => {
|
||||
// Left button only: right-click belongs to the WM/window menu, and a
|
||||
// middle-click drag should not resize.
|
||||
if (e.button !== 0) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void getCurrentWindow()
|
||||
.startResizeDragging(direction)
|
||||
.catch((error: unknown) => {
|
||||
console.error("Failed to start window resize:", error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{HANDLES.map((handle) => (
|
||||
<div
|
||||
key={handle.direction}
|
||||
// Deliberately BELOW the window controls (z-100000) rather than
|
||||
// above: a real CSD window keeps its resize border outside the
|
||||
// buttons, but ours is inside the window, so layering it on top
|
||||
// would steal the corner of whichever control sits in that corner.
|
||||
// Edges still win over ordinary content, which is all they need.
|
||||
className="fixed z-[99998] pointer-events-auto"
|
||||
style={{ ...handle.style, cursor: handle.cursor }}
|
||||
onPointerDown={startResize(handle.direction)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user