Files
Netcatty/application/state/useToolbarItemLayout.ts
T
陈大猫 fa4d0d5348 feat(ui): customizable dense toolbars (show / collapse / hide) (#2183)
* feat(ui): customizable dense toolbars with show/collapse/hide

Add a shared toolbar layout model so terminal, SFTP, and side-panel
action strips can right-click customize placement, overflow collapsed
items into the more menu, and persist order plus placement.

* fix(ui): harden toolbar layout placement after review

Address review findings: availability-aware reachability, skip
unavailable reorder neighbors, Popover overflow for nested SFTP menus,
path chrome outside customize context menu, and side-panel hide close.

* fix(ui): only close side panel when hide placement sticks

Return the next layout from setPlacement so hide-of-last-reachable
does not tear down the panel, keep an active hidden tab chip visible,
and rebuild terminal shown order with highlight collapse coercion.

* fix(ui): narrow SFTP overflow spill and close ⋮ after leaf actions

Respect user show/collapse/hide, then temporarily move non-pinned shown
actions into ⋮ when the SFTP toolbar is narrow. Close overflow after
leaf clicks while keeping nested bookmark/encoding panels usable.
2026-07-14 17:11:03 +08:00

159 lines
4.7 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import {
moveToolbarItem,
normalizeToolbarItemLayout,
partitionToolbarItems,
reorderToolbarItems,
resetToolbarItemLayout,
setToolbarItemPlacement,
type ToolbarItemLayout,
type ToolbarItemLayoutDefaults,
type ToolbarItemPartition,
type ToolbarItemPlacement,
} from '../../domain/toolbarItemLayout';
import {
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
localStorageAdapter,
} from '../../infrastructure/persistence/localStorageAdapter';
export type UseToolbarItemLayoutResult = {
layout: ToolbarItemLayout;
partition: (availableIds?: readonly string[] | ReadonlySet<string>) => ToolbarItemPartition;
setPlacement: (
id: string,
placement: ToolbarItemPlacement,
availableIds?: readonly string[] | ReadonlySet<string> | null,
) => ToolbarItemLayout;
/** Replace full order while preserving placements (normalized against defaults). */
setOrder: (order: string[]) => void;
reorder: (draggedId: string, targetId: string, placement?: 'before' | 'after') => void;
move: (
id: string,
direction: 'earlier' | 'later',
availableIds?: readonly string[] | ReadonlySet<string> | null,
) => void;
reset: () => void;
};
function readLayout(storageKey: string, defaults: ToolbarItemLayoutDefaults): ToolbarItemLayout {
try {
return normalizeToolbarItemLayout(localStorageAdapter.read(storageKey), defaults);
} catch {
return resetToolbarItemLayout(defaults);
}
}
function writeLayout(storageKey: string, layout: ToolbarItemLayout): void {
try {
localStorageAdapter.write(storageKey, layout);
} catch {
// Best effort; in-memory state still applies for the session.
}
}
/**
* Persist show / collapse / hide + order for a dense toolbar region.
* `defaults` should be a stable reference (module-level const).
*/
export function useToolbarItemLayout(
storageKey: string,
defaults: ToolbarItemLayoutDefaults,
): UseToolbarItemLayoutResult {
const [layout, setLayout] = useState<ToolbarItemLayout>(() => readLayout(storageKey, defaults));
useEffect(() => {
const sync = (event: Event) => {
if (event instanceof StorageEvent && event.key !== storageKey) return;
if (event instanceof CustomEvent && event.detail?.key !== storageKey) return;
setLayout(readLayout(storageKey, defaults));
};
window.addEventListener('storage', sync);
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, sync);
return () => {
window.removeEventListener('storage', sync);
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, sync);
};
}, [defaults, storageKey]);
const commit = useCallback(
(next: ToolbarItemLayout) => {
setLayout(next);
writeLayout(storageKey, next);
},
[storageKey],
);
const setPlacement = useCallback(
(
id: string,
placement: ToolbarItemPlacement,
availableIds?: readonly string[] | ReadonlySet<string> | null,
): ToolbarItemLayout => {
let nextLayout!: ToolbarItemLayout;
setLayout((current) => {
nextLayout = setToolbarItemPlacement(current, id, placement, defaults, availableIds);
writeLayout(storageKey, nextLayout);
return nextLayout;
});
return nextLayout;
},
[defaults, storageKey],
);
const setOrder = useCallback(
(order: string[]) => {
setLayout((current) => {
const next = normalizeToolbarItemLayout(
{ order, placement: current.placement },
defaults,
);
writeLayout(storageKey, next);
return next;
});
},
[defaults, storageKey],
);
const reorder = useCallback(
(draggedId: string, targetId: string, placement: 'before' | 'after' = 'before') => {
setLayout((current) => {
const next = reorderToolbarItems(current, draggedId, targetId, placement);
writeLayout(storageKey, next);
return next;
});
},
[storageKey],
);
const move = useCallback(
(
id: string,
direction: 'earlier' | 'later',
availableIds?: readonly string[] | ReadonlySet<string> | null,
) => {
setLayout((current) => {
const next = moveToolbarItem(current, id, direction, availableIds);
writeLayout(storageKey, next);
return next;
});
},
[storageKey],
);
const reset = useCallback(() => {
commit(resetToolbarItemLayout(defaults));
}, [commit, defaults]);
const partition = useCallback(
(availableIds?: readonly string[] | ReadonlySet<string>) =>
partitionToolbarItems(layout, availableIds),
[layout],
);
return useMemo(
() => ({ layout, partition, setPlacement, setOrder, reorder, move, reset }),
[layout, partition, setPlacement, setOrder, reorder, move, reset],
);
}