дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
Maximize Tool Window sized a window to a percentage, which left a strip of the editor and could shrink a window already larger. A maximized tool window now fills the workbench and hides the editor and other anchors, keeping saved sizes and split ratios for the restore. Opening or focusing an editor restores the layout, and closing the window returns focus to the editor. Fixes #2162
1207 рядки
43 KiB
TypeScript
1207 рядки
43 KiB
TypeScript
import {
|
||
createContext,
|
||
useContext,
|
||
useEffect,
|
||
useLayoutEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type CSSProperties,
|
||
type KeyboardEvent,
|
||
type PointerEvent,
|
||
type ReactNode,
|
||
type RefObject,
|
||
} from "react";
|
||
import clsx from "clsx";
|
||
import { create } from "zustand";
|
||
import { call, commands } from "@/platform/ipc";
|
||
import { shortcutLabel } from "@/platform/keymap";
|
||
import { useProject } from "@/platform/project";
|
||
import { dragRequiresAlt, Icon, IconButton, Menu, showContextMenu, tooltipAttrs, type MenuItem } from "@/ui";
|
||
import { FOCUSABLE } from "@/ui/popupState";
|
||
import { TabStripRegistry } from "@/ui/Tabs";
|
||
import {
|
||
ToolWindowShownProvider,
|
||
useContributions,
|
||
type ToolWindowAnchor,
|
||
type ToolWindowContribution,
|
||
} from "../contributions";
|
||
import { useDistractionFree } from "../distractionFree";
|
||
import {
|
||
hideToolWindow,
|
||
MAX_SPLIT,
|
||
MIN_SPLIT,
|
||
moveToolWindow,
|
||
placed,
|
||
resetToolWindowPlacements,
|
||
hasStripeButton,
|
||
removeFromStripe,
|
||
setToolWindowSize,
|
||
setToolWindowSplit,
|
||
showToolWindow,
|
||
SLOTS,
|
||
slotOf,
|
||
toggleMaximizeToolWindow,
|
||
toggleToolWindow,
|
||
useToolWindowLayout,
|
||
type SlotRecord,
|
||
type ToolWindowSlot,
|
||
} from "./store";
|
||
import { contentTabRegistry } from "./content";
|
||
import styles from "./ToolWindows.module.css";
|
||
import { tx } from "@/platform/i18n";
|
||
|
||
/** The window's title in the UI language; a plugin's stays as written. */
|
||
function titleOf(tw: ToolWindowContribution): string {
|
||
return tw.verbatim ? tw.title : tx(tw.title);
|
||
}
|
||
|
||
function byOrder(a: ToolWindowContribution, b: ToolWindowContribution) {
|
||
return (a.order ?? 100) - (b.order ?? 100) || a.title.localeCompare(b.title);
|
||
}
|
||
|
||
/** Registered tool windows available in the current state (project open or not). */
|
||
export function useToolWindows(): ToolWindowContribution[] {
|
||
const map = useContributions((s) => s.toolWindows);
|
||
const placements = useToolWindowLayout((s) => s.placements);
|
||
const hasProject = useProject((s) => !!s.project);
|
||
return useMemo(
|
||
() =>
|
||
[...map.values()]
|
||
.filter((t) => hasProject || t.requiresProject === false)
|
||
.map((t) => placed(t, placements))
|
||
.sort(byOrder),
|
||
[map, hasProject, placements],
|
||
);
|
||
}
|
||
|
||
/** Visible window per area, ignoring ids that aren't registered (yet) or don't dock there. */
|
||
export function useEffectiveVisible(windows: ToolWindowContribution[]): SlotRecord {
|
||
const visible = useToolWindowLayout((s) => s.visible);
|
||
const maximized = useToolWindowLayout((s) => s.maximized);
|
||
return useMemo(() => {
|
||
const out = {} as SlotRecord;
|
||
for (const slot of SLOTS) {
|
||
const id = visible[slot];
|
||
out[slot] =
|
||
id && windows.some((w) => w.id === id && slotOf(w) === slot && (!maximized || w.anchor === maximized))
|
||
? id
|
||
: null;
|
||
}
|
||
return out;
|
||
}, [visible, windows, maximized]);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Stripes
|
||
|
||
/** IDEA's Move To anchors (New UI: no top). */
|
||
const MOVE_TARGETS: Array<[ToolWindowSlot, string]> = [
|
||
["left", "Left Top"],
|
||
["left-split", "Left Bottom"],
|
||
["bottom", "Bottom Left"],
|
||
["bottom-split", "Bottom Right"],
|
||
["right-split", "Right Bottom"],
|
||
["right", "Right Top"],
|
||
];
|
||
|
||
/** "Move to" submenu (IDEA: tool window options › Move To). */
|
||
export function moveToMenu(tw: ToolWindowContribution): MenuItem {
|
||
const current = slotOf(tw);
|
||
return {
|
||
label: "Move To",
|
||
submenu: [
|
||
...MOVE_TARGETS.map(([slot, label]): MenuItem => ({
|
||
id: slot,
|
||
label,
|
||
checked: slot === current ? true : undefined,
|
||
disabled: slot === current,
|
||
run: () => moveToolWindow(tw.id, slot),
|
||
})),
|
||
{ kind: "separator" },
|
||
{ label: "Restore Default Layout", run: resetToolWindowPlacements },
|
||
],
|
||
};
|
||
}
|
||
|
||
/** Stripe button context menu, as in IDEA: Hide (removes the button), Move to, Show Tool Window Names. */
|
||
function stripeButtonMenu(tw: ToolWindowContribution, showNames: boolean): MenuItem[] {
|
||
return [
|
||
{ label: "Hide", run: () => removeFromStripe(tw.id) },
|
||
{ kind: "separator" },
|
||
moveToMenu(tw),
|
||
{ kind: "separator" },
|
||
{
|
||
label: "Show Tool Window Names",
|
||
checked: showNames,
|
||
run: () => {
|
||
resetStripeNames();
|
||
void setShowToolWindowNames(!showNames);
|
||
},
|
||
},
|
||
];
|
||
}
|
||
|
||
async function setShowToolWindowNames(on: boolean) {
|
||
const settings = await call(commands.settingsSet("global", "appearance.showToolWindowNames", JSON.stringify(on)));
|
||
useProject.setState({ settings });
|
||
}
|
||
|
||
/** Stripe button widths with names; icons only follow `--stripe-icon-button` (smaller in compact mode). */
|
||
const STRIPE_MIN = 44;
|
||
const STRIPE_DEFAULT = 56;
|
||
const STRIPE_MAX = 96;
|
||
const STRIPE_KEY = "openidea.stripes.v1";
|
||
|
||
type StripeSide = "left" | "right";
|
||
/** Per stripe: button width with names, and names shown or hidden by a drag (unset: the setting decides). */
|
||
interface StripeSize {
|
||
width: number;
|
||
names?: boolean;
|
||
}
|
||
|
||
function readStripes(): Record<StripeSide, StripeSize> {
|
||
const out: Record<StripeSide, StripeSize> = { left: { width: STRIPE_DEFAULT }, right: { width: STRIPE_DEFAULT } };
|
||
try {
|
||
const raw = JSON.parse(localStorage.getItem(STRIPE_KEY) ?? "null");
|
||
for (const side of ["left", "right"] as const) {
|
||
const v = raw?.[side];
|
||
if (typeof v?.width === "number") out[side].width = Math.min(STRIPE_MAX, Math.max(STRIPE_MIN, v.width));
|
||
if (typeof v?.names === "boolean") out[side].names = v.names;
|
||
}
|
||
} catch {
|
||
/* storage unavailable */
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const useStripes = create<Record<StripeSide, StripeSize>>(readStripes);
|
||
|
||
function setStripe(side: StripeSide, size: StripeSize) {
|
||
useStripes.setState({ [side]: size });
|
||
try {
|
||
localStorage.setItem(STRIPE_KEY, JSON.stringify(useStripes.getState()));
|
||
} catch {
|
||
/* storage unavailable */
|
||
}
|
||
}
|
||
|
||
/** "Show Tool Window Names" (menu or settings) applies to both stripes again. */
|
||
function resetStripeNames() {
|
||
const s = useStripes.getState();
|
||
if (s.left.names === undefined && s.right.names === undefined) return;
|
||
setStripe("left", { width: s.left.width });
|
||
setStripe("right", { width: s.right.width });
|
||
}
|
||
|
||
/**
|
||
* Resize handle on a stripe's inner edge. Dragging sets the stripe's button width;
|
||
* narrower than halfway to the named minimum shows icons only.
|
||
*/
|
||
function StripeHandle({
|
||
side,
|
||
names,
|
||
width,
|
||
frameRef,
|
||
onPreview,
|
||
onCommit,
|
||
}: {
|
||
side: StripeSide;
|
||
names: boolean;
|
||
width: number;
|
||
frameRef: RefObject<HTMLDivElement | null>;
|
||
onPreview: (names: boolean) => void;
|
||
onCommit: (names: boolean, width: number) => void;
|
||
}) {
|
||
const sign = side === "left" ? 1 : -1;
|
||
// The stripe follows the pointer; names show from STRIPE_MIN (truncated), icons only below it.
|
||
const resolve = (raw: number, icons: number) => {
|
||
const px = Math.round(Math.min(STRIPE_MAX, Math.max(icons, raw)));
|
||
return px < STRIPE_MIN ? { names: false, width, px } : { names: true, width: px, px };
|
||
};
|
||
|
||
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
||
const stripe = frameRef.current?.querySelector<HTMLElement>(`[data-stripe-side="${side}"]`);
|
||
if (e.button !== 0 || !stripe) return;
|
||
e.preventDefault();
|
||
const el = e.currentTarget;
|
||
el.setPointerCapture(e.pointerId);
|
||
const origin = e.clientX;
|
||
const icons = parseFloat(getComputedStyle(stripe).getPropertyValue("--stripe-icon-button")) || 30;
|
||
const start = names ? width : icons;
|
||
const cursor = document.body.style.cursor;
|
||
document.body.style.cursor = "col-resize";
|
||
let next = { names, width, px: start };
|
||
const move = (ev: globalThis.PointerEvent) => {
|
||
if (!(ev.buttons & 1)) return end();
|
||
const r = resolve(start + sign * (ev.clientX - origin), icons);
|
||
if (r.names !== next.names) onPreview(r.names);
|
||
next = r;
|
||
stripe.style.setProperty("--stripe-drag", `${r.px}px`);
|
||
};
|
||
const end = () => {
|
||
el.removeEventListener("pointermove", move);
|
||
el.removeEventListener("pointerup", end);
|
||
el.removeEventListener("pointercancel", end);
|
||
document.body.style.cursor = cursor;
|
||
stripe.style.removeProperty("--stripe-drag");
|
||
onCommit(next.names, next.width);
|
||
};
|
||
el.addEventListener("pointermove", move);
|
||
el.addEventListener("pointerup", end);
|
||
el.addEventListener("pointercancel", end);
|
||
};
|
||
|
||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||
const dir = ["ArrowLeft", "ArrowRight"].indexOf(e.key);
|
||
if (dir < 0) return;
|
||
e.preventDefault();
|
||
const step = sign * (dir ? 1 : -1) * 8;
|
||
// One step crosses between icons only and the narrowest named width.
|
||
if (!names) {
|
||
if (step > 0) onCommit(true, STRIPE_MIN);
|
||
} else if (width + step < STRIPE_MIN) onCommit(false, width);
|
||
else onCommit(true, Math.min(STRIPE_MAX, width + step));
|
||
};
|
||
|
||
return (
|
||
<div
|
||
role="separator"
|
||
aria-orientation="vertical"
|
||
aria-label={side === "left" ? "Resize tool window bar" : "Resize right tool window bar"}
|
||
tabIndex={0}
|
||
className={clsx(styles.stripeHandle, side === "right" && styles.stripeHandleRight)}
|
||
onPointerDown={onPointerDown}
|
||
onKeyDown={onKeyDown}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// Stripe button drag. Pointer events rather than HTML5 drag and drop: with the window's native
|
||
// file drop enabled, the webview never sees dragover / drop for drags that start inside it.
|
||
|
||
type StripeDrop = { slot: ToolWindowSlot; before: string | null };
|
||
|
||
const useStripeDrag = create<{ id: string | null; drop: StripeDrop | null }>(() => ({ id: null, drop: null }));
|
||
|
||
/** Movement before a press on a stripe button becomes a drag, px. */
|
||
const DRAG_THRESHOLD = 4;
|
||
/** Set when a drag ends on its own button, so the click that follows doesn't toggle the window. */
|
||
let dragEnded = false;
|
||
|
||
/**
|
||
* Stripe groups (IDEA New UI): each stripe's upper group holds its side,
|
||
* primary windows then split ones (a separator marks the boundary while dragging); the lower group of the
|
||
* left stripe holds Bottom Left, that of the right stripe Bottom Right.
|
||
*/
|
||
function lowerSlot(side: StripeSide): ToolWindowSlot {
|
||
return side === "left" ? "bottom" : "bottom-split";
|
||
}
|
||
|
||
const middle = (r: DOMRect) => r.top + r.height / 2;
|
||
|
||
/** Drop position under the pointer: an area and the button to insert before (null: at the end). */
|
||
function stripeDropAt(x: number, y: number): StripeDrop | null {
|
||
const nav = document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-stripe-side]");
|
||
const side = nav?.dataset.stripeSide;
|
||
if (!nav || (side !== "left" && side !== "right")) return null;
|
||
const spacer = nav.querySelector<HTMLElement>("[data-stripe-spacer]")?.getBoundingClientRect();
|
||
const separator = nav.querySelector<HTMLElement>("[data-stripe-separator]")?.getBoundingClientRect();
|
||
const slot: ToolWindowSlot =
|
||
spacer && y > middle(spacer) ? lowerSlot(side) : separator && y > middle(separator) ? `${side}-split` : side;
|
||
let before: string | null = null;
|
||
for (const b of nav.querySelectorAll<HTMLElement>(`[data-stripe-slot="${slot}"] [data-stripe-button]`)) {
|
||
if (y < middle(b.getBoundingClientRect())) {
|
||
before = b.dataset.stripeButton!;
|
||
break;
|
||
}
|
||
}
|
||
return { slot, before };
|
||
}
|
||
|
||
function startStripeDrag(e: PointerEvent<HTMLButtonElement>, id: string) {
|
||
if (e.button !== 0 || (dragRequiresAlt() && !e.altKey)) return;
|
||
const button = e.currentTarget;
|
||
const start = { x: e.clientX, y: e.clientY };
|
||
let ghost: HTMLElement | null = null;
|
||
const place = (x: number, y: number) => {
|
||
if (ghost) ghost.style.transform = `translate(${x - start.x}px, ${y - start.y}px)`;
|
||
};
|
||
const onMove = (ev: globalThis.PointerEvent) => {
|
||
if (!ghost) {
|
||
// Reordered macOS tap: no button held, so not a drag (see pointerDrag.ts).
|
||
if (!(ev.buttons & 1)) return finish(false);
|
||
if (Math.hypot(ev.clientX - start.x, ev.clientY - start.y) < DRAG_THRESHOLD) return;
|
||
const r = button.getBoundingClientRect();
|
||
ghost = button.cloneNode(true) as HTMLElement;
|
||
ghost.removeAttribute("data-stripe-button");
|
||
ghost.className = clsx(button.className, styles.stripeGhost);
|
||
Object.assign(ghost.style, {
|
||
left: `${r.left}px`,
|
||
top: `${r.top}px`,
|
||
width: `${r.width}px`,
|
||
height: `${r.height}px`,
|
||
});
|
||
document.body.appendChild(ghost);
|
||
useStripeDrag.setState({ id, drop: null });
|
||
}
|
||
if (!(ev.buttons & 1)) return;
|
||
place(ev.clientX, ev.clientY);
|
||
const drop = stripeDropAt(ev.clientX, ev.clientY);
|
||
const cur = useStripeDrag.getState().drop;
|
||
if (drop?.slot !== cur?.slot || drop?.before !== cur?.before) useStripeDrag.setState({ drop });
|
||
};
|
||
const finish = (commit: boolean) => {
|
||
window.removeEventListener("pointermove", onMove);
|
||
window.removeEventListener("pointerup", onUp);
|
||
window.removeEventListener("pointercancel", onCancel);
|
||
window.removeEventListener("keydown", onKey, true);
|
||
if (!ghost) return;
|
||
ghost.remove();
|
||
const { drop } = useStripeDrag.getState();
|
||
useStripeDrag.setState({ id: null, drop: null });
|
||
dragEnded = true;
|
||
setTimeout(() => (dragEnded = false));
|
||
if (commit && drop && drop.before !== id) moveToolWindow(id, drop.slot, drop.before);
|
||
};
|
||
const onUp = () => finish(true);
|
||
const onCancel = () => finish(false);
|
||
const onKey = (ev: globalThis.KeyboardEvent) => {
|
||
if (ev.key !== "Escape" || !ghost) return;
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
finish(false);
|
||
};
|
||
window.addEventListener("pointermove", onMove);
|
||
window.addEventListener("pointerup", onUp);
|
||
window.addEventListener("pointercancel", onCancel);
|
||
window.addEventListener("keydown", onKey, true);
|
||
}
|
||
|
||
function StripeBadge({ useBadge }: { useBadge: () => number | string | null }) {
|
||
// Settings › Appearance › Tool Windows › "Show tool window badges".
|
||
const showBadges = useProject((s) => s.settings?.appearance?.showToolWindowBadges) !== false;
|
||
const value = useBadge();
|
||
if (!showBadges || value === null || value === 0 || value === "") return null;
|
||
return <span className={styles.badge}>{typeof value === "number" && value > 99 ? "99+" : value}</span>;
|
||
}
|
||
|
||
function StripeButton({
|
||
tw,
|
||
active,
|
||
focused,
|
||
side,
|
||
showName,
|
||
}: {
|
||
tw: ToolWindowContribution;
|
||
active: boolean;
|
||
focused: boolean;
|
||
side: "left" | "right";
|
||
showName: boolean;
|
||
}) {
|
||
const shortcut = shortcutLabel(`toolwindow.${tw.id}`);
|
||
const dragged = useStripeDrag((s) => s.id === tw.id);
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={clsx(
|
||
styles.stripeButton,
|
||
showName && styles.stripeNamed,
|
||
active && styles.stripeActive,
|
||
active && focused && styles.stripeFocused,
|
||
side === "right" && styles.stripeRight,
|
||
dragged && styles.stripeDragged,
|
||
)}
|
||
aria-label={titleOf(tw)}
|
||
aria-pressed={active}
|
||
aria-keyshortcuts={shortcut}
|
||
data-stripe-button={tw.id}
|
||
onPointerDown={(e) => startStripeDrag(e, tw.id)}
|
||
onClick={() => {
|
||
if (!dragEnded) toggleToolWindow(tw.id);
|
||
}}
|
||
onContextMenu={(e) => {
|
||
e.preventDefault();
|
||
showContextMenu(e, stripeButtonMenu(tw, showName));
|
||
}}
|
||
data-tooltip-stripe={side}
|
||
{...tooltipAttrs(titleOf(tw), shortcut, true)}
|
||
>
|
||
<span className={styles.stripeIcon}>
|
||
<Icon name={tw.icon} size={20} />
|
||
{tw.useBadge && <StripeBadge useBadge={tw.useBadge} />}
|
||
</span>
|
||
{showName && (
|
||
<span className={clsx(styles.stripeLabel, !/\s/.test(tw.title) && styles.stripeLabelWord)} aria-hidden="true">
|
||
{titleOf(tw)}
|
||
</span>
|
||
)}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/** Stripe "…": tool windows without a stripe button; opening one adds its button (IDEA). */
|
||
function StripeMore({ windows }: { windows: ToolWindowContribution[] }) {
|
||
const [open, setOpen] = useState(false);
|
||
const ref = useRef<HTMLButtonElement>(null);
|
||
const items = useMemo<MenuItem[]>(
|
||
() =>
|
||
[...windows]
|
||
.sort((a, b) => a.title.localeCompare(b.title))
|
||
.map((w) => ({
|
||
id: w.id,
|
||
label: w.title,
|
||
verbatim: w.verbatim,
|
||
icon: w.icon,
|
||
shortcut: shortcutLabel(`toolwindow.${w.id}`),
|
||
run: () => showToolWindow(w.id, true),
|
||
})),
|
||
[windows],
|
||
);
|
||
return (
|
||
<>
|
||
<button
|
||
ref={ref}
|
||
type="button"
|
||
className={clsx(styles.stripeButton, styles.stripeMore)}
|
||
aria-label={tx("More Tool Windows")}
|
||
aria-haspopup="menu"
|
||
aria-expanded={open}
|
||
onClick={() => setOpen((v) => !v)}
|
||
data-tooltip-stripe="left"
|
||
{...tooltipAttrs("More Tool Windows")}
|
||
>
|
||
<Icon name="more-horizontal" size={20} />
|
||
</button>
|
||
<Menu
|
||
open={open}
|
||
onClose={() => setOpen(false)}
|
||
anchor={ref}
|
||
items={items}
|
||
placement="right-start"
|
||
aria-label="Tool windows"
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function onStripeKeyDown(e: KeyboardEvent<HTMLElement>) {
|
||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") return;
|
||
const buttons = [...e.currentTarget.querySelectorAll<HTMLButtonElement>("button")];
|
||
const i = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
||
const next = e.key === "Home" ? 0 : e.key === "End" ? buttons.length - 1 : i + (e.key === "ArrowDown" ? 1 : -1);
|
||
buttons[Math.max(0, Math.min(buttons.length - 1, next))]?.focus();
|
||
e.preventDefault();
|
||
}
|
||
|
||
export function ToolWindowStripe({
|
||
side,
|
||
windows,
|
||
visible,
|
||
showNames = false,
|
||
buttonWidth,
|
||
}: {
|
||
side: StripeSide;
|
||
windows: ToolWindowContribution[];
|
||
visible: SlotRecord;
|
||
/** Tool window names under the icons (IDEA "Show Tool Window Names"). */
|
||
showNames?: boolean;
|
||
/** Button width with names, px. */
|
||
buttonWidth?: number;
|
||
}) {
|
||
const isActive = (w: ToolWindowContribution) => visible[slotOf(w)] === w.id;
|
||
const stripeButtons = useToolWindowLayout((s) => s.stripeButtons);
|
||
const onStripe = windows.filter((w) => hasStripeButton(w, stripeButtons) || isActive(w));
|
||
const inSlot = (slot: ToolWindowSlot) => onStripe.filter((w) => slotOf(w) === slot);
|
||
const primary = inSlot(side);
|
||
const split = inSlot(`${side}-split`);
|
||
const lower = inSlot(lowerSlot(side));
|
||
const moreWindows = windows.filter((w) => !onStripe.includes(w));
|
||
const focused = useToolWindowLayout((s) => s.focused);
|
||
const drop = useStripeDrag((s) => s.drop);
|
||
// The separator between primary and split windows only shows while a button is dragged, as a drop target.
|
||
const dragging = useStripeDrag((s) => !!s.id);
|
||
|
||
const indicator = (slot: ToolWindowSlot, before: string | null) =>
|
||
drop && drop.slot === slot && drop.before === before ? (
|
||
<div className={styles.stripeDrop} aria-hidden="true" />
|
||
) : null;
|
||
const renderGroup = (slot: ToolWindowSlot, list: ToolWindowContribution[]) => (
|
||
<div className={styles.stripeGroup} data-stripe-slot={slot}>
|
||
{list.map((w) => (
|
||
<div key={w.id} className={styles.stripeSlot}>
|
||
{indicator(slot, w.id)}
|
||
<StripeButton tw={w} active={isActive(w)} focused={focused === w.id} side={side} showName={showNames} />
|
||
</div>
|
||
))}
|
||
<div className={clsx(styles.stripeSlot, styles.stripeSlotEnd)}>{indicator(slot, null)}</div>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<nav
|
||
aria-label={side === "left" ? "Tool windows" : "Right tool windows"}
|
||
role="toolbar"
|
||
aria-orientation="vertical"
|
||
data-stripe-side={side}
|
||
style={buttonWidth ? ({ "--stripe-button-w": `${buttonWidth}px` } as CSSProperties) : undefined}
|
||
className={clsx(
|
||
styles.stripe,
|
||
showNames && styles.stripeWithNames,
|
||
side === "right" ? styles.stripeSideRight : styles.stripeSideLeft,
|
||
)}
|
||
onKeyDown={onStripeKeyDown}
|
||
>
|
||
{renderGroup(side, primary)}
|
||
{dragging && <div className={styles.stripeSeparator} data-stripe-separator="" role="separator" />}
|
||
{(split.length > 0 || dragging) && renderGroup(`${side}-split`, split)}
|
||
{side === "left" && <StripeMore windows={moreWindows} />}
|
||
<div className={styles.stripeSpacer} data-stripe-spacer="" />
|
||
{renderGroup(lowerSlot(side), lower)}
|
||
</nav>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Panels
|
||
|
||
const NO_ITEMS: MenuItem[] = [];
|
||
const staticMenus = new WeakMap<MenuItem[], (id: string) => MenuItem[]>();
|
||
function staticHeaderMenu(items: MenuItem[] | undefined): (id: string) => MenuItem[] {
|
||
if (!items) return () => NO_ITEMS;
|
||
let f = staticMenus.get(items);
|
||
if (!f) staticMenus.set(items, (f = () => items));
|
||
return f;
|
||
}
|
||
|
||
const noTitleMenu = (): { label: string; items: MenuItem[] } | null => null;
|
||
|
||
/** The tool window around its content, for `ToolWindowHeader` and `useOwnToolWindowHeader`. */
|
||
const ToolWindowFrame = createContext<{ tw: ToolWindowContribution; setOwnHeader: (own: boolean) => void } | null>(
|
||
null,
|
||
);
|
||
|
||
/**
|
||
* Takes the header over from the tool window while `own` is true: its content
|
||
* then places `ToolWindowHeader`s itself, as IDEA gives each cell of a split
|
||
* tool window (the Terminal's Split Right / Split Down) its own header.
|
||
*/
|
||
export function useOwnToolWindowHeader(own: boolean) {
|
||
const setOwnHeader = useContext(ToolWindowFrame)?.setOwnHeader;
|
||
useLayoutEffect(() => {
|
||
if (!setOwnHeader || !own) return;
|
||
setOwnHeader(true);
|
||
return () => setOwnHeader(false);
|
||
}, [setOwnHeader, own]);
|
||
}
|
||
|
||
export interface ToolWindowHeaderProps {
|
||
/** Shows the window's title (a split's first cell). */
|
||
title?: boolean;
|
||
/** Shows the window's own buttons: header actions, ⋮ options and hide (a split's last cell). */
|
||
actions?: boolean;
|
||
/** Its tab strip is the one Close Tab (⌘W) acts on. */
|
||
closeTarget?: boolean;
|
||
/** Content tabs, after the title. */
|
||
children?: ReactNode;
|
||
}
|
||
|
||
/** A header row of the tool window whose content renders it (see `useOwnToolWindowHeader`). */
|
||
export function ToolWindowHeader({
|
||
title = true,
|
||
actions = true,
|
||
closeTarget = false,
|
||
children,
|
||
}: ToolWindowHeaderProps) {
|
||
const tw = useContext(ToolWindowFrame)?.tw;
|
||
if (!tw) return null;
|
||
return (
|
||
<HeaderRow tw={tw} title={title} actions={actions}>
|
||
<TabStripRegistry.Provider value={closeTarget ? contentTabRegistry(tw.id) : null}>
|
||
{children}
|
||
</TabStripRegistry.Provider>
|
||
</HeaderRow>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The Options (`⋮` Move To / Maximize / Hide) and Hide (`—`) buttons of a tool
|
||
* window that uses `useOwnToolWindowHeader` without a full `ToolWindowHeader`.
|
||
*/
|
||
export function ToolWindowCornerActions({ className }: { className?: string }) {
|
||
const tw = useContext(ToolWindowFrame)?.tw;
|
||
if (!tw) return null;
|
||
return <ToolWindowActionsBar tw={tw} className={className} />;
|
||
}
|
||
|
||
function ToolWindowActionsBar({ tw, className }: { tw: ToolWindowContribution; className?: string }) {
|
||
const maximized = useToolWindowLayout((s) => s.maximized === tw.anchor);
|
||
const [moreOpen, setMoreOpen] = useState(false);
|
||
const moreRef = useRef<HTMLButtonElement>(null);
|
||
const Actions = tw.headerActions;
|
||
|
||
const useHeaderMenu = typeof tw.headerMenu === "function" ? tw.headerMenu : staticHeaderMenu(tw.headerMenu);
|
||
const own = useHeaderMenu(tw.id);
|
||
const more: MenuItem[] = [
|
||
...own,
|
||
...(own.length ? [{ kind: "separator" } as MenuItem] : []),
|
||
moveToMenu(tw),
|
||
{
|
||
label: maximized ? "Restore Size" : "Maximize",
|
||
icon: maximized ? "minimize" : "maximize",
|
||
run: () => toggleMaximizeToolWindow(tw.id),
|
||
},
|
||
{ kind: "separator" },
|
||
{ label: "Hide", icon: "hide", shortcut: shortcutLabel("toolwindow.hideActive"), run: () => hideToolWindow(tw.id) },
|
||
];
|
||
|
||
return (
|
||
<>
|
||
<div className={clsx(styles.headerActions, className)}>
|
||
{Actions && <Actions id={tw.id} />}
|
||
<IconButton
|
||
ref={moreRef}
|
||
icon="more"
|
||
title="Options"
|
||
size="small"
|
||
iconSize={16}
|
||
aria-haspopup="menu"
|
||
aria-expanded={moreOpen}
|
||
onClick={() => setMoreOpen((v) => !v)}
|
||
/>
|
||
<IconButton
|
||
icon="minus"
|
||
title="Hide"
|
||
shortcut={shortcutLabel("toolwindow.hideActive")}
|
||
size="small"
|
||
iconSize={16}
|
||
onClick={() => hideToolWindow(tw.id)}
|
||
/>
|
||
</div>
|
||
<Menu
|
||
open={moreOpen}
|
||
onClose={() => setMoreOpen(false)}
|
||
anchor={moreRef}
|
||
items={more}
|
||
placement="bottom-end"
|
||
aria-label={tx`${titleOf(tw)} options`}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function HeaderRow({
|
||
tw,
|
||
title,
|
||
actions,
|
||
children,
|
||
}: {
|
||
tw: ToolWindowContribution;
|
||
title: boolean;
|
||
actions: boolean;
|
||
children?: ReactNode;
|
||
}) {
|
||
const [titleOpen, setTitleOpen] = useState(false);
|
||
const titleRef = useRef<HTMLButtonElement>(null);
|
||
const showTitle = title && !(tw.headerContent && tw.tabsReplaceTitle);
|
||
|
||
const useHeaderMenu = typeof tw.headerMenu === "function" ? tw.headerMenu : staticHeaderMenu(tw.headerMenu);
|
||
const own = useHeaderMenu(tw.id);
|
||
const useTitleMenu = tw.titleMenu ?? noTitleMenu;
|
||
const titleMenu = useTitleMenu(tw.id);
|
||
const titleItems = titleMenu?.items ?? own;
|
||
|
||
return (
|
||
<header
|
||
className={clsx(styles.header, tw.headerContent && styles.headerTabbed)}
|
||
onDoubleClick={(e) => {
|
||
if (!(e.target as Element).closest('button, a, input, [role="tablist"], [role="separator"]'))
|
||
toggleMaximizeToolWindow(tw.id);
|
||
}}
|
||
onMouseDown={(e) => {
|
||
// A middle click on the header hides the window (like the Hide button).
|
||
// Not a press on the header's tabs or buttons, which close and act
|
||
// themselves: their event would otherwise hide the whole window too.
|
||
if (e.button !== 1 || (e.target as Element).closest('button, a, [role="tablist"], [role="separator"]')) return;
|
||
e.preventDefault();
|
||
hideToolWindow(tw.id);
|
||
}}
|
||
>
|
||
{!showTitle ? null : titleItems.length > 0 ? (
|
||
<>
|
||
<button
|
||
ref={titleRef}
|
||
type="button"
|
||
className={styles.titleButton}
|
||
aria-haspopup="menu"
|
||
aria-expanded={titleOpen}
|
||
onClick={() => setTitleOpen((v) => !v)}
|
||
>
|
||
<span className={styles.title}>{titleMenu ? tx(titleMenu.label) : titleOf(tw)}</span>
|
||
<Icon name="chevron-down" size={12} className={styles.titleChevron} />
|
||
</button>
|
||
<Menu
|
||
open={titleOpen}
|
||
onClose={() => setTitleOpen(false)}
|
||
anchor={titleRef}
|
||
items={titleItems}
|
||
aria-label={titleMenu ? tx`${titleOf(tw)} views` : tx`${titleOf(tw)} view options`}
|
||
/>
|
||
</>
|
||
) : (
|
||
<span className={clsx(styles.title, styles.titlePlain)}>{titleOf(tw)}</span>
|
||
)}
|
||
{children && <div className={clsx(styles.headerContent, !showTitle && styles.headerContentOnly)}>{children}</div>}
|
||
<div className={styles.headerSpacer} />
|
||
{actions && <ToolWindowActionsBar tw={tw} />}
|
||
</header>
|
||
);
|
||
}
|
||
|
||
/** How long an activated tool window waits for lazily loaded content to focus. */
|
||
const LATE_FOCUS_MS = 5000;
|
||
|
||
function ToolWindowView({ tw, hidden }: { tw: ToolWindowContribution; hidden: boolean }) {
|
||
const ref = useRef<HTMLElement>(null);
|
||
const focusRequest = useToolWindowLayout((s) => s.focusRequest);
|
||
const [ownHeader, setOwnHeader] = useState(false);
|
||
const frame = useMemo(() => ({ tw, setOwnHeader }), [tw]);
|
||
const Content = tw.component;
|
||
const Tabs = tw.headerContent;
|
||
|
||
useEffect(() => {
|
||
if (!focusRequest || focusRequest.id !== tw.id || hidden) return;
|
||
let observer: MutationObserver | null = null;
|
||
let timer = 0;
|
||
const stop = () => {
|
||
observer?.disconnect();
|
||
observer = null;
|
||
window.clearTimeout(timer);
|
||
};
|
||
const raf = requestAnimationFrame(() => {
|
||
const root = ref.current;
|
||
if (!root) return;
|
||
const body = root.querySelector<HTMLElement>("[data-toolwindow-body]");
|
||
// Like IDEA, activation focuses the content (a field, list or tree), never a button or
|
||
// link: a scripted focus on one draws its focus ring (an empty view's "New…" link).
|
||
const contentTarget = () =>
|
||
body?.querySelector<HTMLElement>("[data-autofocus]") ??
|
||
[...(body?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? [])].find(
|
||
(el) => !el.matches('button, a[href], [role="button"], [role="link"]'),
|
||
);
|
||
const target = contentTarget();
|
||
if (target || !body) {
|
||
(target ?? root).focus({ preventScroll: true });
|
||
return;
|
||
}
|
||
if (!body.hasAttribute("tabindex")) body.tabIndex = -1;
|
||
body.focus({ preventScroll: true });
|
||
// Lazily loaded views (the Terminal) render their field after this: focus it when it
|
||
// appears, unless focus has moved on meanwhile.
|
||
observer = new MutationObserver(() => {
|
||
if (document.activeElement !== body) return stop();
|
||
const late = contentTarget();
|
||
if (!late) return;
|
||
stop();
|
||
late.focus({ preventScroll: true });
|
||
});
|
||
observer.observe(body, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-autofocus"] });
|
||
timer = window.setTimeout(stop, LATE_FOCUS_MS);
|
||
});
|
||
return () => {
|
||
cancelAnimationFrame(raf);
|
||
stop();
|
||
};
|
||
}, [focusRequest, tw.id, hidden]);
|
||
|
||
return (
|
||
<section
|
||
ref={ref}
|
||
className={styles.window}
|
||
data-toolwindow={tw.id}
|
||
aria-label={titleOf(tw)}
|
||
hidden={hidden}
|
||
tabIndex={-1}
|
||
>
|
||
{!ownHeader && (
|
||
<HeaderRow tw={tw} title actions>
|
||
{Tabs && (
|
||
<TabStripRegistry.Provider value={contentTabRegistry(tw.id)}>
|
||
<Tabs id={tw.id} />
|
||
</TabStripRegistry.Provider>
|
||
)}
|
||
</HeaderRow>
|
||
)}
|
||
<div className={styles.body} data-toolwindow-body="">
|
||
<ToolWindowFrame.Provider value={frame}>
|
||
<ToolWindowShownProvider value={!hidden}>
|
||
<Content id={tw.id} />
|
||
</ToolWindowShownProvider>
|
||
</ToolWindowFrame.Provider>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const MIN: Record<ToolWindowAnchor, number> = { left: 140, right: 160, bottom: 90 };
|
||
/** The editor keeps at least this much room when a tool window is resized (see the grid template). */
|
||
const EDITOR_MIN = { width: 200, height: 60 };
|
||
/** Smallest area when a side is split: stacked (left / right) and side by side (bottom). */
|
||
const SPLIT_MIN = { stacked: 60, sideBySide: 120 };
|
||
const KEY_STEP = 10;
|
||
|
||
/**
|
||
* Splitter between the primary and split areas of a side: horizontal on the
|
||
* left and right (areas stacked), vertical at the bottom (side by side).
|
||
* Moves the areas' flex-grow while dragging; the ratio is stored on release.
|
||
*/
|
||
function SplitHandle({ anchor, hidden }: { anchor: ToolWindowAnchor; hidden: boolean }) {
|
||
const stacked = anchor !== "bottom";
|
||
const measure = (el: HTMLElement) => {
|
||
const first = el.previousElementSibling as HTMLElement | null;
|
||
const second = el.nextElementSibling as HTMLElement | null;
|
||
if (!first || !second) return null;
|
||
const size = (e: HTMLElement) => (stacked ? e.offsetHeight : e.offsetWidth);
|
||
const total = size(first) + size(second);
|
||
if (total <= 0) return null;
|
||
const min = Math.min(stacked ? SPLIT_MIN.stacked : SPLIT_MIN.sideBySide, total / 2);
|
||
return {
|
||
areas: [first, second] as const,
|
||
first: size(first),
|
||
total,
|
||
clamp: (px: number) => Math.min(total - min, Math.max(min, px)),
|
||
};
|
||
};
|
||
const apply = ([first, second]: readonly [HTMLElement, HTMLElement], ratio: number) => {
|
||
first.style.flexGrow = String(ratio);
|
||
second.style.flexGrow = String(1 - ratio);
|
||
};
|
||
const ratioOf = (px: number, total: number) => Math.min(MAX_SPLIT, Math.max(MIN_SPLIT, px / total));
|
||
|
||
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
||
const el = e.currentTarget;
|
||
const m = measure(el);
|
||
if (e.button !== 0 || !m) return;
|
||
e.preventDefault();
|
||
el.setPointerCapture(e.pointerId);
|
||
const origin = stacked ? e.clientY : e.clientX;
|
||
const cursor = document.body.style.cursor;
|
||
document.body.style.cursor = stacked ? "row-resize" : "col-resize";
|
||
let ratio: number | null = null;
|
||
const move = (ev: globalThis.PointerEvent) => {
|
||
if (!(ev.buttons & 1)) return end();
|
||
ratio = ratioOf(m.clamp(m.first + (stacked ? ev.clientY : ev.clientX) - origin), m.total);
|
||
apply(m.areas, ratio);
|
||
};
|
||
const end = () => {
|
||
el.removeEventListener("pointermove", move);
|
||
el.removeEventListener("pointerup", end);
|
||
el.removeEventListener("pointercancel", end);
|
||
document.body.style.cursor = cursor;
|
||
if (ratio !== null) setToolWindowSplit(anchor, ratio);
|
||
// Back to the store's value (unchanged when the ratio was clamped to it).
|
||
apply(m.areas, useToolWindowLayout.getState().splits[anchor]);
|
||
};
|
||
el.addEventListener("pointermove", move);
|
||
el.addEventListener("pointerup", end);
|
||
el.addEventListener("pointercancel", end);
|
||
};
|
||
|
||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||
const dir = (stacked ? ["ArrowUp", "ArrowDown"] : ["ArrowLeft", "ArrowRight"]).indexOf(e.key);
|
||
const m = measure(e.currentTarget);
|
||
if (dir < 0 || !m) return;
|
||
e.preventDefault();
|
||
setToolWindowSplit(anchor, ratioOf(m.clamp(m.first + (dir ? 1 : -1) * KEY_STEP), m.total));
|
||
};
|
||
|
||
return (
|
||
<div
|
||
role="separator"
|
||
aria-orientation={stacked ? "horizontal" : "vertical"}
|
||
aria-label={`Resize ${anchor} tool window split`}
|
||
tabIndex={0}
|
||
hidden={hidden}
|
||
className={clsx(styles.gap, styles.splitGap)}
|
||
data-split-handle={anchor}
|
||
onPointerDown={onPointerDown}
|
||
onKeyDown={onKeyDown}
|
||
/>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* One side's tool windows: the primary area and the split area, each an
|
||
* island; with both showing they share the side at the stored ratio.
|
||
*/
|
||
function SidePanel({
|
||
anchor,
|
||
windows,
|
||
visible,
|
||
}: {
|
||
anchor: ToolWindowAnchor;
|
||
windows: ToolWindowContribution[];
|
||
visible: SlotRecord;
|
||
}) {
|
||
const mounted = useToolWindowLayout((s) => s.mounted);
|
||
const split = !!visible[anchor] && !!visible[`${anchor}-split`];
|
||
// A lone area takes the whole side: flex-grow below 1 would leave the rest empty.
|
||
const ratio = useToolWindowLayout((s) => s.splits[anchor]);
|
||
const area = (slot: ToolWindowSlot, grow: number) => {
|
||
const visibleId = visible[slot];
|
||
const list = windows.filter((w) => slotOf(w) === slot && (mounted.has(w.id) || w.id === visibleId));
|
||
if (!list.length) return null;
|
||
return (
|
||
<div
|
||
className={clsx(styles.island, styles.anchor)}
|
||
// The stored ratio; SplitHandle moves it directly while dragging.
|
||
style={{ flexGrow: grow }}
|
||
data-slot={slot}
|
||
hidden={!visibleId}
|
||
>
|
||
{list.map((w) => (
|
||
<ToolWindowView key={w.id} tw={w} hidden={w.id !== visibleId} />
|
||
))}
|
||
</div>
|
||
);
|
||
};
|
||
return (
|
||
<div
|
||
className={clsx(styles.side, anchor === "bottom" && styles.sideRow)}
|
||
data-anchor={anchor}
|
||
hidden={!sideVisible(visible, anchor)}
|
||
>
|
||
{area(anchor, split ? ratio : 1)}
|
||
<SplitHandle anchor={anchor} hidden={!split} />
|
||
{area(`${anchor}-split`, split ? 1 - ratio : 1)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Windows shown on side `a`, primary area first. */
|
||
function visibleOn(visible: SlotRecord, a: ToolWindowAnchor): string[] {
|
||
return [visible[a], visible[`${a}-split`]].filter((id): id is string => !!id);
|
||
}
|
||
|
||
const sideVisible = (visible: SlotRecord, a: ToolWindowAnchor) => !!(visible[a] || visible[`${a}-split`]);
|
||
|
||
/** Writes the grid's track variables from the store; the grid itself squeezes tracks when the window is small. */
|
||
function syncTracks(grid: HTMLElement, visible: SlotRecord) {
|
||
const s = useToolWindowLayout.getState();
|
||
for (const a of ["left", "right", "bottom"] as const) {
|
||
const shown = sideVisible(visible, a);
|
||
const size = !shown ? "0px" : `${s.sizes[a]}px`;
|
||
grid.style.setProperty(`--tw-${a}`, size);
|
||
grid.style.setProperty(`--gap-${a}`, shown ? "var(--island-gap)" : "0px");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Resize handle in the gap next to a side's islands. Moves only a CSS variable
|
||
* while dragging; the remembered size is stored on release.
|
||
*/
|
||
function GapHandle({
|
||
anchor,
|
||
visibleIds,
|
||
gridRef,
|
||
editorRef,
|
||
sync,
|
||
}: {
|
||
anchor: ToolWindowAnchor;
|
||
/** Windows shown on this side (primary and split area). */
|
||
visibleIds: string[];
|
||
gridRef: RefObject<HTMLDivElement | null>;
|
||
editorRef: RefObject<HTMLDivElement | null>;
|
||
sync: () => void;
|
||
}) {
|
||
const vertical = anchor !== "bottom";
|
||
const maximized = useToolWindowLayout((s) => s.maximized);
|
||
const sign = anchor === "left" ? 1 : -1;
|
||
const measure = () => {
|
||
const island = gridRef.current?.querySelector<HTMLElement>(`[data-anchor="${anchor}"]`);
|
||
const editor = editorRef.current;
|
||
if (!island || !editor) return null;
|
||
const size = vertical ? island.offsetWidth : island.offsetHeight;
|
||
const spare = vertical ? editor.offsetWidth - EDITOR_MIN.width : editor.offsetHeight - EDITOR_MIN.height;
|
||
return { size, max: size + Math.max(0, spare) };
|
||
};
|
||
const clamp = (raw: number, max: number) => Math.min(max, Math.max(MIN[anchor], raw));
|
||
const commit = (px: number) => {
|
||
const s = useToolWindowLayout.getState();
|
||
if (visibleIds[0] && s.maximized === anchor) toggleMaximizeToolWindow(visibleIds[0]);
|
||
setToolWindowSize(anchor, px);
|
||
sync();
|
||
};
|
||
|
||
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
||
const grid = gridRef.current;
|
||
const m = measure();
|
||
if (e.button !== 0 || !grid || !m || !visibleIds.length) return;
|
||
// No focus (and so no focus ring) or text selection from a mouse drag.
|
||
e.preventDefault();
|
||
const el = e.currentTarget;
|
||
el.setPointerCapture(e.pointerId);
|
||
const origin = vertical ? e.clientX : e.clientY;
|
||
const cursor = document.body.style.cursor;
|
||
document.body.style.cursor = vertical ? "col-resize" : "row-resize";
|
||
let next = m.size;
|
||
const move = (ev: globalThis.PointerEvent) => {
|
||
if (!(ev.buttons & 1)) return end();
|
||
const raw = m.size + sign * ((vertical ? ev.clientX : ev.clientY) - origin);
|
||
// Dragged past half the minimum: collapse, as IDEA does.
|
||
next = raw < MIN[anchor] / 2 ? 0 : clamp(raw, m.max);
|
||
grid.style.setProperty(`--tw-${anchor}`, `${next}px`);
|
||
};
|
||
const end = () => {
|
||
el.removeEventListener("pointermove", move);
|
||
el.removeEventListener("pointerup", end);
|
||
el.removeEventListener("pointercancel", end);
|
||
document.body.style.cursor = cursor;
|
||
if (next === 0) visibleIds.forEach(hideToolWindow);
|
||
else if (next !== m.size) commit(next);
|
||
else sync();
|
||
};
|
||
el.addEventListener("pointermove", move);
|
||
el.addEventListener("pointerup", end);
|
||
el.addEventListener("pointercancel", end);
|
||
};
|
||
|
||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||
const keys = vertical ? ["ArrowLeft", "ArrowRight"] : ["ArrowUp", "ArrowDown"];
|
||
const dir = keys.indexOf(e.key);
|
||
const m = measure();
|
||
if (dir < 0 || !m) return;
|
||
e.preventDefault();
|
||
commit(clamp(m.size + sign * (dir ? 1 : -1) * KEY_STEP, m.max));
|
||
};
|
||
|
||
return (
|
||
<div
|
||
role="separator"
|
||
aria-orientation={vertical ? "vertical" : "horizontal"}
|
||
aria-label={`Resize ${anchor} tool window`}
|
||
tabIndex={0}
|
||
hidden={!visibleIds.length || !!maximized}
|
||
className={styles.gap}
|
||
data-gap={anchor}
|
||
onPointerDown={onPointerDown}
|
||
onKeyDown={onKeyDown}
|
||
/>
|
||
);
|
||
}
|
||
|
||
/** Settings › Appearance › Tool Windows (raw settings: the workbench does not depend on the settings feature). */
|
||
function useToolWindowAppearance() {
|
||
const a = useProject((s) => s.settings?.appearance);
|
||
const distractionFree = useDistractionFree((s) => s.active);
|
||
return {
|
||
bars: a?.showToolWindowBars !== false && !distractionFree,
|
||
names: a?.showToolWindowNames === true,
|
||
widescreen: a?.widescreenToolWindows === true,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The Islands frame below the title bar: stripes on the window background,
|
||
* side tool windows and the editor as rounded islands in one row, bottom
|
||
* tool windows as a full-width island underneath (or, with "Widescreen tool
|
||
* window layout", between the side windows). Resize handles sit in the gaps
|
||
* between islands. A CSS grid does the layout, so a window resize costs no script.
|
||
*/
|
||
export function ToolWindowLayout({ editor }: { editor: ReactNode }) {
|
||
const windows = useToolWindows();
|
||
const visible = useEffectiveVisible(windows);
|
||
const appearance = useToolWindowAppearance();
|
||
const { bars, widescreen } = appearance;
|
||
const stripes = useStripes();
|
||
const names = (side: StripeSide) => stripes[side].names ?? appearance.names;
|
||
// A change of the setting (here or in Settings) overrides what stripe drags chose.
|
||
const namesSetting = useProject((s) => s.settings?.appearance?.showToolWindowNames);
|
||
const seenNamesSetting = useRef(namesSetting);
|
||
useEffect(() => {
|
||
const seen = seenNamesSetting.current;
|
||
seenNamesSetting.current = namesSetting;
|
||
if (seen !== undefined && seen !== namesSetting) resetStripeNames();
|
||
}, [namesSetting]);
|
||
const frameRef = useRef<HTMLDivElement>(null);
|
||
const stripeHandle = (side: StripeSide) => (
|
||
<StripeHandle
|
||
side={side}
|
||
names={names(side)}
|
||
width={stripes[side].width}
|
||
frameRef={frameRef}
|
||
onPreview={(on) => useStripes.setState({ [side]: { ...stripes[side], names: on } })}
|
||
onCommit={(on, width) => setStripe(side, { width, names: on === appearance.names ? undefined : on })}
|
||
/>
|
||
);
|
||
const stripe = (side: StripeSide) => (
|
||
<ToolWindowStripe
|
||
side={side}
|
||
windows={windows}
|
||
visible={visible}
|
||
showNames={names(side)}
|
||
buttonWidth={stripes[side].width}
|
||
/>
|
||
);
|
||
const sizes = useToolWindowLayout((s) => s.sizes);
|
||
const maximized = useToolWindowLayout((s) => s.maximized);
|
||
const stripeButtons = useToolWindowLayout((s) => s.stripeButtons);
|
||
// The right stripe holds the right side's windows and Bottom Right.
|
||
const hasRight = windows.some((w) => {
|
||
const slot = slotOf(w);
|
||
return (
|
||
(w.anchor === "right" || slot === "bottom-split") && (hasStripeButton(w, stripeButtons) || visible[slot] === w.id)
|
||
);
|
||
});
|
||
// While a stripe button is dragged, an empty right stripe shows as a drop target.
|
||
const dragging = useStripeDrag((s) => !!s.id);
|
||
const rightStripe = bars && (hasRight || dragging);
|
||
const gridRef = useRef<HTMLDivElement>(null);
|
||
const editorRef = useRef<HTMLDivElement>(null);
|
||
const sync = () => {
|
||
if (gridRef.current) syncTracks(gridRef.current, visible);
|
||
};
|
||
useLayoutEffect(sync, [visible, sizes, maximized]);
|
||
useLayoutEffect(() => {
|
||
if (!maximized || !editorRef.current?.contains(document.activeElement)) return;
|
||
const id = visibleOn(visible, maximized)[0];
|
||
if (id) gridRef.current?.querySelector<HTMLElement>(`[data-toolwindow="${id}"]`)?.focus({ preventScroll: true });
|
||
}, [maximized, visible]);
|
||
|
||
const gap = (anchor: ToolWindowAnchor) => (
|
||
<GapHandle
|
||
anchor={anchor}
|
||
visibleIds={visibleOn(visible, anchor)}
|
||
gridRef={gridRef}
|
||
editorRef={editorRef}
|
||
sync={sync}
|
||
/>
|
||
);
|
||
return (
|
||
<div
|
||
ref={frameRef}
|
||
className={clsx(styles.frame, !rightStripe && styles.frameNoRight, !bars && styles.frameNoLeft)}
|
||
>
|
||
{bars && stripe("left")}
|
||
{bars && stripeHandle("left")}
|
||
<div
|
||
ref={gridRef}
|
||
className={clsx(styles.grid, widescreen && styles.widescreen)}
|
||
data-maximized={maximized ?? undefined}
|
||
>
|
||
<SidePanel anchor="left" windows={windows} visible={visible} />
|
||
{gap("left")}
|
||
<div ref={editorRef} className={clsx(styles.island, styles.editorIsland)} hidden={!!maximized}>
|
||
{editor}
|
||
</div>
|
||
{gap("right")}
|
||
<SidePanel anchor="right" windows={windows} visible={visible} />
|
||
{gap("bottom")}
|
||
<SidePanel anchor="bottom" windows={windows} visible={visible} />
|
||
</div>
|
||
{rightStripe && stripeHandle("right")}
|
||
{rightStripe && stripe("right")}
|
||
</div>
|
||
);
|
||
}
|