1040 lines
38 KiB
TypeScript
1040 lines
38 KiB
TypeScript
import {
|
||
useEffect,
|
||
useRef,
|
||
useState,
|
||
type FocusEvent as ReactFocusEvent,
|
||
type FormEvent,
|
||
type KeyboardEvent as ReactKeyboardEvent,
|
||
type MouseEvent as ReactMouseEvent,
|
||
type PointerEvent as ReactPointerEvent,
|
||
type ReactNode,
|
||
} from 'react';
|
||
import { flushSync } from 'react-dom';
|
||
import {
|
||
canAppendRouteRule,
|
||
ROUTE_RULES_CONTRACT_VERSION,
|
||
} from '../../../shared/routingRules.js';
|
||
import type { RouteRule } from '../../../shared/contracts/state.js';
|
||
import { operationBlocked } from '../../state/operations.js';
|
||
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
|
||
import { Drawer } from '../../ui/Drawer.js';
|
||
import { RailAction } from '../../ui/RailAction.js';
|
||
import {
|
||
beginRuleReorder,
|
||
crossedRuleIndex,
|
||
edgeScrollDelta,
|
||
endRuleReorder,
|
||
keyboardRuleIndex,
|
||
moveRule,
|
||
RULE_DROP_DURATION_MS,
|
||
RULE_REORDER_DURATION_MS,
|
||
restoreRuleOrder,
|
||
shouldLiftRule,
|
||
type RuleReorderEndReason,
|
||
type RuleReorderLifecycle,
|
||
} from './ruleReorderModel.js';
|
||
|
||
const ROUTE_RULE_OPTIONS: Array<[RouteRule['type'], string]> = [
|
||
['domain', 'Точный домен'],
|
||
['domain_suffix', 'Суффикс'],
|
||
['domain_keyword', 'Содержит'],
|
||
];
|
||
|
||
const ROUTE_RULE_PLACEHOLDERS: Record<RouteRule['type'], string> = {
|
||
domain: 'example.com или полный URL',
|
||
domain_suffix: 'example.org',
|
||
domain_keyword: 'cdn',
|
||
};
|
||
|
||
interface DraftRule extends RouteRule {
|
||
_key: string;
|
||
removing?: boolean;
|
||
}
|
||
|
||
interface RoutingState {
|
||
rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION;
|
||
mode?: string;
|
||
localRules?: RouteRule[];
|
||
activeLocalRules?: RouteRule[];
|
||
localRulesRevision?: number;
|
||
localRulesPendingRestart?: boolean;
|
||
}
|
||
|
||
interface RoutingFeatureOptions {
|
||
route?: RoutingState | null;
|
||
connected: boolean;
|
||
operations: Record<string, { status?: string } | undefined>;
|
||
onSave: (rules: RouteRule[], expectedRevision: number) => Promise<unknown>;
|
||
onDismissError: () => void;
|
||
}
|
||
|
||
interface RoutingSaveState {
|
||
localRulesRevision: number;
|
||
localRulesPendingRestart: boolean;
|
||
}
|
||
|
||
interface RuleDragSession extends RuleReorderLifecycle {
|
||
originRules: DraftRule[];
|
||
pointerId: number | null;
|
||
startY: number;
|
||
latestY: number;
|
||
pointerOffsetY: number;
|
||
translateY: number;
|
||
raf: number | null;
|
||
handle: HTMLButtonElement;
|
||
}
|
||
|
||
let localRuleDraftId = 0;
|
||
|
||
const createLocalRuleDraft = (rule: RouteRule): DraftRule => ({
|
||
...rule,
|
||
enabled: rule?.enabled !== false,
|
||
outbound: rule?.outbound === 'vpn' ? 'vpn' : 'direct',
|
||
_key: `route-rule-${localRuleDraftId += 1}`,
|
||
});
|
||
|
||
const localRuleValues = (rules: DraftRule[]): RouteRule[] => rules
|
||
.filter((rule) => !rule.removing)
|
||
.map(({ type, value, enabled, outbound }) => ({ type, value, enabled, outbound }));
|
||
|
||
const localRulesSignature = (rules: Array<RouteRule & { removing?: boolean }>) => JSON.stringify(
|
||
rules
|
||
.filter((rule) => !rule.removing)
|
||
.map(({ type, value, enabled, outbound }) => ({ type, value, enabled, outbound })),
|
||
);
|
||
|
||
const localRuleKey = ({ type, value, enabled, outbound }: RouteRule) => (
|
||
`${type}:${String(value || '').trim().toLowerCase()}:${enabled}:${outbound}`
|
||
);
|
||
|
||
const sameRule = (left?: RouteRule, right?: RouteRule) => (
|
||
Boolean(left && right && localRuleKey(left) === localRuleKey(right))
|
||
);
|
||
|
||
function localRuleStatus(
|
||
rule: DraftRule,
|
||
index: number,
|
||
savedRules: RouteRule[],
|
||
activeRules: RouteRule[],
|
||
runtimeActive: boolean,
|
||
bypassed: boolean,
|
||
) {
|
||
if (!sameRule(rule, savedRules[index])) return ['unsaved', 'Не сохранено'];
|
||
if (!rule.enabled) return ['disabled', 'Выключено'];
|
||
if (bypassed) return ['bypassed', 'Обходится'];
|
||
if (!runtimeActive) return ['saved', 'Сохранено'];
|
||
if (sameRule(rule, activeRules[index])) return ['active', 'Активно'];
|
||
return ['pending', 'Ждёт перезапуска'];
|
||
}
|
||
|
||
function routingSaveState(result: unknown): RoutingSaveState | null {
|
||
if (!result) return null;
|
||
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
||
throw new TypeError('Harbor route mutation returned invalid state');
|
||
}
|
||
const state = (result as Record<string, unknown>).state;
|
||
if (!state || typeof state !== 'object' || Array.isArray(state)) {
|
||
throw new TypeError('Harbor route mutation returned invalid state');
|
||
}
|
||
const route = (state as Record<string, unknown>).route;
|
||
if (!route || typeof route !== 'object' || Array.isArray(route)) {
|
||
throw new TypeError('Harbor route mutation returned invalid state');
|
||
}
|
||
const { localRulesRevision, localRulesPendingRestart } = route as Record<string, unknown>;
|
||
if (
|
||
!Number.isSafeInteger(localRulesRevision)
|
||
|| (localRulesRevision as number) < 0
|
||
|| typeof localRulesPendingRestart !== 'boolean'
|
||
) {
|
||
throw new TypeError('Harbor route mutation returned invalid state');
|
||
}
|
||
return { localRulesRevision: localRulesRevision as number, localRulesPendingRestart };
|
||
}
|
||
|
||
export function useRoutingFeature({
|
||
route,
|
||
connected,
|
||
operations,
|
||
onSave,
|
||
onDismissError,
|
||
}: RoutingFeatureOptions) {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [rules, setRules] = useState<DraftRule[]>([]);
|
||
const [revision, setRevision] = useState(route?.localRulesRevision || 0);
|
||
const [confirmingClose, setConfirmingClose] = useState(false);
|
||
const [liftedKey, setLiftedKey] = useState<string | null>(null);
|
||
const [reorderAnnouncement, setReorderAnnouncement] = useState('');
|
||
const panelRef = useRef<HTMLElement>(null);
|
||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||
const closeRef = useRef<HTMLButtonElement>(null);
|
||
const baselineRef = useRef('[]');
|
||
const rulesRef = useRef<DraftRule[]>(rules);
|
||
const dragRef = useRef<RuleDragSession | null>(null);
|
||
const suppressHandleClickRef = useRef(false);
|
||
rulesRef.current = rules;
|
||
const savedRules = route?.localRules || [];
|
||
const activeRules = route?.activeLocalRules || [];
|
||
const editable = route?.rulesContractVersion === ROUTE_RULES_CONTRACT_VERSION;
|
||
const bypassed = route?.mode === 'gateway-direct';
|
||
const dirty = localRulesSignature(rules) !== baselineRef.current;
|
||
const pendingRestart = connected && !bypassed && route?.localRulesPendingRestart === true;
|
||
const savedUntilStart = !connected && !bypassed && route?.localRulesPendingRestart === true;
|
||
const pendingCount = pendingRestart
|
||
? savedRules.filter((rule, index) => rule.enabled && !sameRule(rule, activeRules[index])).length
|
||
: 0;
|
||
const reorderedOnly = pendingRestart
|
||
&& savedRules.length === activeRules.length
|
||
&& JSON.stringify(savedRules.map(localRuleKey).sort()) === JSON.stringify(activeRules.map(localRuleKey).sort());
|
||
const pendingMessage = !pendingRestart
|
||
? ''
|
||
: reorderedOnly || pendingCount === 0
|
||
? 'Изменения правил не применены'
|
||
: `${pendingCount} ${pendingCount === 1 ? 'правило не применено' : 'правила не применены'}`;
|
||
const blocked = operationBlocked(operations, 'routeRules') || rules.some((rule) => rule.removing);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) return undefined;
|
||
const frame = requestAnimationFrame(() => closeRef.current?.focus());
|
||
return () => {
|
||
cancelAnimationFrame(frame);
|
||
requestAnimationFrame(() => {
|
||
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
|
||
});
|
||
};
|
||
}, [isOpen]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) return undefined;
|
||
const closeRouting = (event: PointerEvent | KeyboardEvent) => {
|
||
if (event.type === 'keydown') {
|
||
const keyboardEvent = event as KeyboardEvent;
|
||
if (keyboardEvent.key !== 'Escape' || keyboardEvent.defaultPrevented) return;
|
||
if (dragRef.current?.lifted) {
|
||
keyboardEvent.preventDefault();
|
||
cancelReorder();
|
||
return;
|
||
}
|
||
} else {
|
||
if (panelRef.current?.contains(event.target as Node)) return;
|
||
if (toggleRef.current?.contains(event.target as Node)) return;
|
||
}
|
||
requestClose();
|
||
};
|
||
document.addEventListener('pointerdown', closeRouting);
|
||
document.addEventListener('keydown', closeRouting);
|
||
return () => {
|
||
document.removeEventListener('pointerdown', closeRouting);
|
||
document.removeEventListener('keydown', closeRouting);
|
||
};
|
||
}, [isOpen, dirty]);
|
||
|
||
useEffect(() => () => {
|
||
const session = dragRef.current;
|
||
if (session && endRuleReorder(session, 'unmount').stopAutoScroll && session.raf !== null) {
|
||
cancelAnimationFrame(session.raf);
|
||
}
|
||
dragRef.current = null;
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen || !dirty) return undefined;
|
||
const warnBeforeUnload = (event: BeforeUnloadEvent) => {
|
||
event.preventDefault();
|
||
event.returnValue = '';
|
||
};
|
||
window.addEventListener('beforeunload', warnBeforeUnload);
|
||
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
|
||
}, [isOpen, dirty]);
|
||
|
||
function open() {
|
||
const nextRules = savedRules.map(createLocalRuleDraft);
|
||
baselineRef.current = localRulesSignature(savedRules);
|
||
rulesRef.current = nextRules;
|
||
setRules(nextRules);
|
||
setRevision(route?.localRulesRevision || 0);
|
||
setConfirmingClose(false);
|
||
onDismissError();
|
||
setIsOpen(true);
|
||
}
|
||
|
||
function forceClose() {
|
||
cancelReorder(false);
|
||
setIsOpen(false);
|
||
}
|
||
|
||
function requestClose() {
|
||
const currentRules = cancelReorder(false);
|
||
if (localRulesSignature(currentRules) !== baselineRef.current) {
|
||
setConfirmingClose(true);
|
||
return false;
|
||
}
|
||
setIsOpen(false);
|
||
return true;
|
||
}
|
||
|
||
function discard() {
|
||
cancelReorder(false);
|
||
setConfirmingClose(false);
|
||
setIsOpen(false);
|
||
}
|
||
|
||
function updateRules(updater: (current: DraftRule[]) => DraftRule[]) {
|
||
setRules((current) => {
|
||
const next = updater(current);
|
||
rulesRef.current = next;
|
||
return next;
|
||
});
|
||
}
|
||
|
||
function change(
|
||
index: number,
|
||
field: keyof Pick<RouteRule, 'type' | 'value' | 'enabled' | 'outbound'>,
|
||
value: unknown,
|
||
) {
|
||
if (!editable || blocked) return;
|
||
updateRules((current) => current.map((rule, ruleIndex) => (
|
||
ruleIndex === index ? { ...rule, [field]: value } as DraftRule : rule
|
||
)));
|
||
}
|
||
|
||
function add() {
|
||
if (!editable || blocked) return;
|
||
updateRules((current) => [
|
||
...current,
|
||
createLocalRuleDraft({ type: 'domain', value: '', enabled: true, outbound: 'direct' }),
|
||
]);
|
||
}
|
||
|
||
function remove(ruleKey: string) {
|
||
if (!editable || blocked) return;
|
||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||
updateRules((current) => current.filter((rule) => rule._key !== ruleKey));
|
||
return;
|
||
}
|
||
updateRules((current) => current.map((rule) => (
|
||
rule._key === ruleKey ? { ...rule, removing: true } : rule
|
||
)));
|
||
}
|
||
|
||
function finishRemove(ruleKey: string) {
|
||
const update = () => flushSync(() => {
|
||
updateRules((current) => current.filter((rule) => rule._key !== ruleKey));
|
||
});
|
||
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||
update();
|
||
return;
|
||
}
|
||
document.startViewTransition(update);
|
||
}
|
||
|
||
async function save(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (!editable || blocked) return;
|
||
if (dragRef.current) dropReorder(false);
|
||
const values = localRuleValues(rulesRef.current);
|
||
const result = routingSaveState(await onSave(values, revision));
|
||
if (!result) return;
|
||
baselineRef.current = localRulesSignature(values);
|
||
setRevision(result.localRulesRevision);
|
||
setConfirmingClose(false);
|
||
if (!connected || !result.localRulesPendingRestart) setIsOpen(false);
|
||
}
|
||
|
||
function reducedMotion() {
|
||
return matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
}
|
||
|
||
function ruleRows() {
|
||
return new Map(Array.from(
|
||
panelRef.current?.querySelectorAll<HTMLElement>('[data-rule-key]') || [],
|
||
(row) => [row.dataset.ruleKey || '', row],
|
||
));
|
||
}
|
||
|
||
function replaceRuleOrder(next: DraftRule[], draggedRuleKey: string) {
|
||
const rows = ruleRows();
|
||
const previousTops = new Map(Array.from(rows, ([key, row]) => [key, row.getBoundingClientRect().top]));
|
||
rulesRef.current = next;
|
||
flushSync(() => setRules(next));
|
||
if (reducedMotion()) return;
|
||
for (const [key, row] of ruleRows()) {
|
||
if (key === draggedRuleKey) continue;
|
||
const previousTop = previousTops.get(key);
|
||
if (previousTop === undefined) continue;
|
||
const delta = previousTop - row.getBoundingClientRect().top;
|
||
if (!delta) continue;
|
||
row.animate(
|
||
[{ transform: `translateY(${delta}px)` }, { transform: 'translateY(0)' }],
|
||
{ duration: RULE_REORDER_DURATION_MS, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' },
|
||
);
|
||
}
|
||
}
|
||
|
||
function moveRuleTo(ruleKey: string, target: number) {
|
||
const current = rulesRef.current;
|
||
const from = current.findIndex((rule) => rule._key === ruleKey);
|
||
if (from < 0 || target === from) return from;
|
||
replaceRuleOrder(moveRule(current, from, target), ruleKey);
|
||
return target;
|
||
}
|
||
|
||
function positionPointerRule(pointerY: number) {
|
||
const session = dragRef.current;
|
||
if (!session?.lifted) return;
|
||
const rows = ruleRows();
|
||
let row = rows.get(session.key);
|
||
if (!row) return;
|
||
let rect = row.getBoundingClientRect();
|
||
const desiredCenter = pointerY - session.pointerOffsetY;
|
||
const baseCenter = rect.top + rect.height / 2 - session.translateY;
|
||
session.translateY = desiredCenter - baseCenter;
|
||
row.style.setProperty('--client-rule-drag-y', `${session.translateY}px`);
|
||
|
||
const currentIndex = rulesRef.current.findIndex((rule) => rule._key === session.key);
|
||
const centers = rulesRef.current.map((rule) => {
|
||
const ruleRect = rows.get(rule._key)?.getBoundingClientRect();
|
||
return ruleRect ? ruleRect.top + ruleRect.height / 2 : desiredCenter;
|
||
});
|
||
const target = crossedRuleIndex(currentIndex, desiredCenter, centers);
|
||
if (target === currentIndex) return;
|
||
moveRuleTo(session.key, target);
|
||
row = ruleRows().get(session.key);
|
||
if (!row) return;
|
||
rect = row.getBoundingClientRect();
|
||
session.translateY = desiredCenter - (rect.top + rect.height / 2 - session.translateY);
|
||
row.style.setProperty('--client-rule-drag-y', `${session.translateY}px`);
|
||
setReorderAnnouncement(`Правило перемещено на позицию ${target + 1}`);
|
||
}
|
||
|
||
function stopAutoScroll(session: RuleDragSession) {
|
||
if (session.raf !== null) cancelAnimationFrame(session.raf);
|
||
session.raf = null;
|
||
}
|
||
|
||
function continueAutoScroll(session: RuleDragSession) {
|
||
if (session.raf !== null) return;
|
||
const tick = () => {
|
||
session.raf = null;
|
||
if (dragRef.current !== session || !session.lifted) return;
|
||
const container = panelRef.current;
|
||
if (!container) return;
|
||
const rect = container.getBoundingClientRect();
|
||
const delta = edgeScrollDelta(session.latestY, rect.top, rect.bottom);
|
||
if (!delta) return;
|
||
const previousTop = container.scrollTop;
|
||
container.scrollTop += delta;
|
||
if (container.scrollTop === previousTop) return;
|
||
positionPointerRule(session.latestY);
|
||
session.raf = requestAnimationFrame(tick);
|
||
};
|
||
session.raf = requestAnimationFrame(tick);
|
||
}
|
||
|
||
function startPointerReorder(event: ReactPointerEvent<HTMLButtonElement>, ruleKey: string) {
|
||
if (event.button !== 0 || !event.isPrimary || !editable || blocked || rulesRef.current.length < 2) return;
|
||
if (dragRef.current?.input === 'keyboard') finishReorder('focus-leave');
|
||
const lifecycle = beginRuleReorder(dragRef.current, ruleKey, 'pointer');
|
||
if (!lifecycle) return;
|
||
const row = event.currentTarget.closest<HTMLElement>('[data-rule-key]');
|
||
if (!row) return;
|
||
const rect = row.getBoundingClientRect();
|
||
event.currentTarget.setPointerCapture(event.pointerId);
|
||
dragRef.current = {
|
||
...lifecycle,
|
||
originRules: [...rulesRef.current],
|
||
pointerId: event.pointerId,
|
||
startY: event.clientY,
|
||
latestY: event.clientY,
|
||
pointerOffsetY: event.clientY - (rect.top + rect.height / 2),
|
||
translateY: 0,
|
||
raf: null,
|
||
handle: event.currentTarget,
|
||
};
|
||
}
|
||
|
||
function movePointerReorder(event: ReactPointerEvent<HTMLButtonElement>) {
|
||
const session = dragRef.current;
|
||
if (!session || session.pointerId !== event.pointerId) return;
|
||
session.latestY = event.clientY;
|
||
if (!session.lifted) {
|
||
if (!shouldLiftRule(session.startY, event.clientY)) return;
|
||
event.preventDefault();
|
||
session.lifted = true;
|
||
flushSync(() => setLiftedKey(session.key));
|
||
setReorderAnnouncement('Правило поднято');
|
||
}
|
||
event.preventDefault();
|
||
positionPointerRule(event.clientY);
|
||
const container = panelRef.current;
|
||
const bounds = container?.getBoundingClientRect();
|
||
if (!bounds || !edgeScrollDelta(session.latestY, bounds.top, bounds.bottom)) {
|
||
stopAutoScroll(session);
|
||
} else {
|
||
continueAutoScroll(session);
|
||
}
|
||
}
|
||
|
||
function clearDraggedStyle(session: RuleDragSession, settle: boolean) {
|
||
const row = ruleRows().get(session.key);
|
||
if (!row) return;
|
||
const translateY = session.translateY;
|
||
row.style.removeProperty('--client-rule-drag-y');
|
||
if (settle && translateY && !reducedMotion()) {
|
||
row.animate(
|
||
[{ transform: `translateY(${translateY}px)` }, { transform: 'translateY(0)' }],
|
||
{ duration: RULE_DROP_DURATION_MS, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' },
|
||
);
|
||
}
|
||
}
|
||
|
||
function finishReorder(reason: RuleReorderEndReason, announce = true) {
|
||
const session = dragRef.current;
|
||
if (!session) return rulesRef.current;
|
||
const outcome = endRuleReorder(session, reason, reducedMotion());
|
||
if (outcome.stopAutoScroll) stopAutoScroll(session);
|
||
dragRef.current = null;
|
||
let nextRules = rulesRef.current;
|
||
if (outcome.restoreOrder) {
|
||
nextRules = restoreRuleOrder(session.originRules, rulesRef.current, (rule) => rule._key);
|
||
replaceRuleOrder(nextRules, session.key);
|
||
}
|
||
clearDraggedStyle(session, outcome.animateDrop);
|
||
setLiftedKey(null);
|
||
if (
|
||
outcome.releasePointerCapture
|
||
&& session.pointerId !== null
|
||
&& session.handle.hasPointerCapture(session.pointerId)
|
||
) {
|
||
session.handle.releasePointerCapture(session.pointerId);
|
||
}
|
||
if (session.lifted) {
|
||
if (reason === 'drop' && session.input === 'pointer') suppressHandleClickRef.current = true;
|
||
if (announce && reason !== 'unmount') {
|
||
setReorderAnnouncement(reason === 'drop' ? 'Правило размещено' : 'Перемещение отменено');
|
||
}
|
||
}
|
||
if (outcome.restoreFocus) session.handle.focus();
|
||
return nextRules;
|
||
}
|
||
|
||
function dropReorder(announce = true) {
|
||
return finishReorder('drop', announce);
|
||
}
|
||
|
||
function cancelReorder(announce = true) {
|
||
return finishReorder('cancel', announce);
|
||
}
|
||
|
||
function finishPointerReorder(event: ReactPointerEvent<HTMLButtonElement>) {
|
||
if (dragRef.current?.pointerId !== event.pointerId) return;
|
||
dropReorder();
|
||
}
|
||
|
||
function cancelPointerReorder(event: ReactPointerEvent<HTMLButtonElement>) {
|
||
if (dragRef.current?.pointerId !== event.pointerId) return;
|
||
cancelReorder();
|
||
}
|
||
|
||
function losePointerReorder(event: ReactPointerEvent<HTMLButtonElement>) {
|
||
if (dragRef.current?.pointerId !== event.pointerId) return;
|
||
finishReorder('lost-capture');
|
||
}
|
||
|
||
function toggleKeyboardReorder(handle: HTMLButtonElement, ruleKey: string) {
|
||
const active = dragRef.current;
|
||
if (active?.key === ruleKey && active.input === 'keyboard') {
|
||
dropReorder();
|
||
return;
|
||
}
|
||
if (active?.input === 'keyboard') finishReorder('focus-leave');
|
||
const lifecycle = beginRuleReorder(dragRef.current, ruleKey, 'keyboard');
|
||
if (!lifecycle || !editable || blocked || rulesRef.current.length < 2) return;
|
||
dragRef.current = {
|
||
...lifecycle,
|
||
originRules: [...rulesRef.current],
|
||
pointerId: null,
|
||
startY: 0,
|
||
latestY: 0,
|
||
pointerOffsetY: 0,
|
||
translateY: 0,
|
||
raf: null,
|
||
handle,
|
||
};
|
||
setLiftedKey(ruleKey);
|
||
setReorderAnnouncement('Правило поднято');
|
||
}
|
||
|
||
function handleReorderKey(event: ReactKeyboardEvent<HTMLButtonElement>, ruleKey: string) {
|
||
const session = dragRef.current;
|
||
const ownsSession = session?.key === ruleKey && session.input === 'keyboard';
|
||
if (!ownsSession) return;
|
||
if (event.key === 'Tab') {
|
||
finishReorder('focus-leave');
|
||
return;
|
||
}
|
||
if (event.key === 'Escape') {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
cancelReorder();
|
||
return;
|
||
}
|
||
if (!['ArrowUp', 'ArrowDown'].includes(event.key)) return;
|
||
event.preventDefault();
|
||
const current = rulesRef.current.findIndex((rule) => rule._key === ruleKey);
|
||
const target = keyboardRuleIndex(current, event.key === 'ArrowUp' ? -1 : 1, rulesRef.current.length);
|
||
if (target === current) return;
|
||
moveRuleTo(ruleKey, target);
|
||
setReorderAnnouncement(`Правило перемещено на позицию ${target + 1}`);
|
||
event.currentTarget.focus();
|
||
}
|
||
|
||
function handleReorderClick(event: ReactMouseEvent<HTMLButtonElement>, ruleKey: string) {
|
||
if (suppressHandleClickRef.current) {
|
||
suppressHandleClickRef.current = false;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
return;
|
||
}
|
||
if (event.detail === 0) toggleKeyboardReorder(event.currentTarget, ruleKey);
|
||
}
|
||
|
||
function handleReorderBlur(event: ReactFocusEvent<HTMLButtonElement>, ruleKey: string) {
|
||
const session = dragRef.current;
|
||
if (session?.key === ruleKey && session.input === 'keyboard' && event.relatedTarget !== event.currentTarget) {
|
||
finishReorder('focus-leave');
|
||
}
|
||
}
|
||
|
||
return {
|
||
isOpen,
|
||
rules,
|
||
savedRules,
|
||
activeRules,
|
||
connected,
|
||
editable,
|
||
bypassed,
|
||
dirty,
|
||
pendingRestart,
|
||
savedUntilStart,
|
||
pendingCount,
|
||
pendingMessage,
|
||
blocked,
|
||
liftedKey,
|
||
reorderAnnouncement,
|
||
confirmingClose,
|
||
panelRef,
|
||
toggleRef,
|
||
closeRef,
|
||
open,
|
||
forceClose,
|
||
requestClose,
|
||
setConfirmingClose,
|
||
discard,
|
||
change,
|
||
add,
|
||
remove,
|
||
finishRemove,
|
||
startPointerReorder,
|
||
movePointerReorder,
|
||
finishPointerReorder,
|
||
cancelPointerReorder,
|
||
losePointerReorder,
|
||
handleReorderKey,
|
||
handleReorderClick,
|
||
handleReorderBlur,
|
||
save,
|
||
};
|
||
}
|
||
|
||
type RoutingFeature = ReturnType<typeof useRoutingFeature>;
|
||
|
||
interface RuleTypePickerProps {
|
||
value: RouteRule['type'];
|
||
ruleKey: string;
|
||
index: number;
|
||
disabled?: boolean;
|
||
onChange: (value: RouteRule['type']) => void;
|
||
}
|
||
|
||
function RuleTypePicker({ value, ruleKey, index, disabled, onChange }: RuleTypePickerProps) {
|
||
const [open, setOpen] = useState(false);
|
||
const rootRef = useRef<HTMLDivElement>(null);
|
||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||
const listId = `${ruleKey}-types`;
|
||
const selectedIndex = Math.max(0, ROUTE_RULE_OPTIONS.findIndex(([type]) => type === value));
|
||
|
||
useEffect(() => {
|
||
if (!open) return undefined;
|
||
optionRefs.current[selectedIndex]?.focus();
|
||
const close = (event: PointerEvent | KeyboardEvent) => {
|
||
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
|
||
if (rootRef.current?.contains(event.target as Node)) return;
|
||
setOpen(false);
|
||
};
|
||
document.addEventListener('pointerdown', close);
|
||
document.addEventListener('keydown', close);
|
||
return () => {
|
||
document.removeEventListener('pointerdown', close);
|
||
document.removeEventListener('keydown', close);
|
||
};
|
||
}, [open, selectedIndex]);
|
||
|
||
function choose(type: RouteRule['type']) {
|
||
onChange(type);
|
||
setOpen(false);
|
||
triggerRef.current?.focus();
|
||
}
|
||
|
||
function moveOption(event: React.KeyboardEvent<HTMLButtonElement>, offset: number) {
|
||
if (!['ArrowDown', 'ArrowUp', 'Home', 'End', 'Escape'].includes(event.key)) return;
|
||
event.preventDefault();
|
||
if (event.key === 'Escape') {
|
||
setOpen(false);
|
||
triggerRef.current?.focus();
|
||
return;
|
||
}
|
||
const current = optionRefs.current.indexOf(document.activeElement as HTMLButtonElement);
|
||
const next = event.key === 'Home'
|
||
? 0
|
||
: event.key === 'End'
|
||
? ROUTE_RULE_OPTIONS.length - 1
|
||
: (current + offset + ROUTE_RULE_OPTIONS.length) % ROUTE_RULE_OPTIONS.length;
|
||
optionRefs.current[next]?.focus();
|
||
}
|
||
|
||
return (
|
||
<div className={`client-rule-type${open ? ' is-open' : ''}`} ref={rootRef}>
|
||
<button
|
||
ref={triggerRef}
|
||
className="client-rule-type-trigger"
|
||
type="button"
|
||
aria-label={`Тип правила ${index + 1}`}
|
||
aria-haspopup="listbox"
|
||
aria-expanded={open}
|
||
aria-controls={listId}
|
||
disabled={disabled}
|
||
onClick={() => setOpen((current) => !current)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Escape' && open) {
|
||
event.preventDefault();
|
||
setOpen(false);
|
||
return;
|
||
}
|
||
if (!['ArrowDown', 'ArrowUp'].includes(event.key)) return;
|
||
event.preventDefault();
|
||
setOpen(true);
|
||
}}
|
||
>
|
||
<span>{ROUTE_RULE_OPTIONS[selectedIndex][1]}</span>
|
||
<svg viewBox="0 0 12 8" aria-hidden="true"><path d="m1 1 5 5 5-5" /></svg>
|
||
</button>
|
||
<div className="client-rule-type-list" id={listId} role="listbox" aria-hidden={!open}>
|
||
{ROUTE_RULE_OPTIONS.map(([type, label], optionIndex) => (
|
||
<button
|
||
ref={(node) => { optionRefs.current[optionIndex] = node; }}
|
||
type="button"
|
||
role="option"
|
||
aria-selected={type === value}
|
||
tabIndex={open ? 0 : -1}
|
||
key={type}
|
||
onClick={() => choose(type)}
|
||
onKeyDown={(event) => moveOption(event, event.key === 'ArrowUp' ? -1 : 1)}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RuleOutboundPicker({
|
||
value,
|
||
index,
|
||
disabled,
|
||
onChange,
|
||
}: {
|
||
value: RouteRule['outbound'];
|
||
index: number;
|
||
disabled?: boolean;
|
||
onChange: (value: RouteRule['outbound']) => void;
|
||
}) {
|
||
return <div className="client-rule-outbound" role="group" aria-label={`Маршрут правила ${index + 1}`}>
|
||
{([['vpn', 'VPN'], ['direct', 'Напрямую']] as const).map(([outbound, label]) => (
|
||
<button
|
||
type="button"
|
||
key={outbound}
|
||
aria-pressed={value === outbound}
|
||
disabled={disabled}
|
||
onClick={() => onChange(outbound)}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>;
|
||
}
|
||
|
||
export function RoutingToggle({
|
||
feature,
|
||
open,
|
||
gatewayDirect,
|
||
isGateway,
|
||
hasSubscription,
|
||
onOpen,
|
||
}: {
|
||
feature: RoutingFeature;
|
||
open: boolean;
|
||
gatewayDirect: boolean;
|
||
isGateway: boolean;
|
||
hasSubscription: boolean;
|
||
onOpen: () => void;
|
||
}) {
|
||
const disabled = isGateway && !hasSubscription;
|
||
return (
|
||
<RailAction
|
||
buttonRef={feature.toggleRef}
|
||
className={`client-local-rules-toggle${feature.pendingRestart ? ' has-pending' : ''}`}
|
||
open={open}
|
||
controls="client-local-rules"
|
||
disabled={disabled}
|
||
ariaLabel={disabled
|
||
? 'Правила маршрутизации недоступны: сначала добавьте подписку'
|
||
: gatewayDirect
|
||
? open ? 'Закрыть правила маршрутизации' : 'Настроить правила, обходящиеся Harbor Gateway'
|
||
: open ? 'Закрыть правила маршрутизации' : 'Настроить правила маршрутизации'}
|
||
label={disabled
|
||
? 'Сначала добавьте подписку'
|
||
: feature.pendingRestart ? 'Правила ждут перезапуска' : 'Правила маршрутизации'}
|
||
onClick={() => open ? feature.requestClose() : onOpen()}
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path className="client-rail-rule-track" d="M4 7h16M4 17h16" />
|
||
<path className="client-rail-rule-scan is-top" pathLength="1" d="M4 7h16" />
|
||
<path className="client-rail-rule-scan is-bottom" pathLength="1" d="M4 17h16" />
|
||
<circle className="client-rail-rule-knob is-top" cx="15" cy="7" r="2" />
|
||
<circle className="client-rail-rule-knob is-bottom" cx="9" cy="17" r="2" />
|
||
</svg>
|
||
</RailAction>
|
||
);
|
||
}
|
||
|
||
export function RoutingPendingStatus({
|
||
feature,
|
||
blocked,
|
||
onRestart,
|
||
}: {
|
||
feature: RoutingFeature;
|
||
blocked: boolean;
|
||
onRestart: () => unknown;
|
||
}) {
|
||
return <div className={`client-route-rules-pending${feature.pendingMessage ? ' is-visible' : ''}`} role="status" aria-live="polite">
|
||
{feature.pendingMessage && (
|
||
<>
|
||
<span>{feature.pendingMessage}</span>
|
||
<button type="button" disabled={blocked} onClick={onRestart}>Перезапустить VPN</button>
|
||
</>
|
||
)}
|
||
</div>;
|
||
}
|
||
|
||
export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature; statusSlot?: ReactNode }) {
|
||
const draftRules = feature.rules.filter((rule) => !rule.removing);
|
||
const editorDisabled = feature.blocked || !feature.editable;
|
||
const canAdd = canAppendRouteRule(draftRules) && !editorDisabled;
|
||
const incomplete = draftRules.some((rule) => !String(rule.value || '').trim());
|
||
|
||
return (
|
||
<Drawer
|
||
panelRef={feature.panelRef}
|
||
closeRef={feature.closeRef}
|
||
id="client-local-rules"
|
||
className="client-local-rules"
|
||
sheetClassName="client-local-rules-sheet"
|
||
open={feature.isOpen}
|
||
labelledBy="local-rules-title"
|
||
closeLabel="Закрыть правила маршрутизации"
|
||
onClose={feature.requestClose}
|
||
>
|
||
<header className="client-local-rules-header">
|
||
<span>Маршрутизация</span>
|
||
<button
|
||
className="client-local-rules-save"
|
||
type="submit"
|
||
form="client-local-rules-form"
|
||
disabled={editorDisabled || !feature.dirty}
|
||
>
|
||
Сохранить
|
||
</button>
|
||
<h2 id="local-rules-title">Правила маршрутизации</h2>
|
||
<p>Проверяются сверху вниз. Первое совпадение выбирает маршрут.</p>
|
||
{!feature.editable && (
|
||
<p className="client-local-rules-runtime" role="status">
|
||
Обновите Harbor, чтобы редактировать правила.
|
||
</p>
|
||
)}
|
||
{feature.bypassed && (
|
||
<p className="client-local-rules-runtime" role="status">
|
||
Локальный список сейчас обходится Harbor Gateway.
|
||
</p>
|
||
)}
|
||
{!feature.bypassed && feature.pendingRestart && (
|
||
<p className="client-local-rules-runtime" role="status">
|
||
Правила сохранены, но начнут работать после запуска или перезапуска sing-box.
|
||
</p>
|
||
)}
|
||
{feature.savedUntilStart && (
|
||
<p className="client-local-rules-runtime" role="status">
|
||
Правила сохранены и начнут работать после запуска Harbor.
|
||
</p>
|
||
)}
|
||
</header>
|
||
|
||
<form id="client-local-rules-form" className="client-local-rules-form" onSubmit={feature.save}>
|
||
<span id="client-rule-reorder-instructions" className="client-rule-reorder-instructions">
|
||
Нажмите пробел или Enter, затем используйте стрелки вверх и вниз. Повторное нажатие размещает правило, Escape отменяет.
|
||
</span>
|
||
<span className="client-rule-reorder-live" role="status" aria-live="polite" aria-atomic="true">
|
||
{feature.reorderAnnouncement}
|
||
</span>
|
||
<section className="client-local-rules-group" aria-labelledby="local-rules-list-title">
|
||
<span id="local-rules-list-title">Правила</span>
|
||
<div className="client-local-rules-list">
|
||
{feature.rules.map((rule, index) => {
|
||
const [status, statusLabel] = localRuleStatus(
|
||
rule,
|
||
index,
|
||
feature.savedRules,
|
||
feature.activeRules,
|
||
feature.connected,
|
||
feature.bypassed,
|
||
);
|
||
const lifted = feature.liftedKey === rule._key;
|
||
const handleDisabled = editorDisabled || rule.removing || draftRules.length < 2;
|
||
return (
|
||
<div
|
||
className={`client-local-rule client-deletable-row is-${status}${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}${lifted ? ' is-dragging' : ''}`}
|
||
key={rule._key}
|
||
data-rule-key={rule._key}
|
||
style={{ viewTransitionName: rule.removing ? 'none' : rule._key }}
|
||
inert={rule.removing ? true : undefined}
|
||
>
|
||
<button
|
||
className="client-rule-handle"
|
||
type="button"
|
||
aria-label={`Переместить правило, позиция ${index + 1} из ${draftRules.length}`}
|
||
aria-describedby="client-rule-reorder-instructions"
|
||
aria-pressed={lifted}
|
||
disabled={handleDisabled}
|
||
onPointerDown={(event) => feature.startPointerReorder(event, rule._key)}
|
||
onPointerMove={feature.movePointerReorder}
|
||
onPointerUp={feature.finishPointerReorder}
|
||
onPointerCancel={feature.cancelPointerReorder}
|
||
onLostPointerCapture={feature.losePointerReorder}
|
||
onKeyDown={(event) => feature.handleReorderKey(event, rule._key)}
|
||
onClick={(event) => feature.handleReorderClick(event, rule._key)}
|
||
onBlur={(event) => feature.handleReorderBlur(event, rule._key)}
|
||
>
|
||
<svg viewBox="0 0 12 28" aria-hidden="true">
|
||
<circle cx="6" cy="6" r="1.6" />
|
||
<circle cx="6" cy="14" r="1.6" />
|
||
<circle cx="6" cy="22" r="1.6" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
className="client-local-rule-enabled"
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={rule.enabled}
|
||
aria-label={`${rule.enabled ? 'Выключить' : 'Включить'} правило ${index + 1}`}
|
||
disabled={editorDisabled}
|
||
onClick={() => feature.change(index, 'enabled', !rule.enabled)}
|
||
>
|
||
<svg viewBox="0 0 20 20" aria-hidden="true">
|
||
<circle cx="10" cy="10" r="6" />
|
||
<path className="client-rule-check" d="m6.8 10.1 2.1 2.2 4.5-5" />
|
||
</svg>
|
||
</button>
|
||
<RuleTypePicker
|
||
value={rule.type}
|
||
ruleKey={rule._key}
|
||
index={index}
|
||
disabled={editorDisabled || rule.removing}
|
||
onChange={(type) => feature.change(index, 'type', type)}
|
||
/>
|
||
<input
|
||
type="text"
|
||
inputMode="url"
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
required
|
||
disabled={editorDisabled}
|
||
aria-label={`Значение правила ${index + 1}`}
|
||
placeholder={ROUTE_RULE_PLACEHOLDERS[rule.type]}
|
||
value={rule.value}
|
||
onChange={(event) => feature.change(index, 'value', event.target.value)}
|
||
/>
|
||
<button
|
||
className="client-row-delete"
|
||
type="button"
|
||
aria-label={`Удалить правило ${index + 1}`}
|
||
disabled={editorDisabled}
|
||
onClick={() => feature.remove(rule._key)}
|
||
>
|
||
×
|
||
</button>
|
||
<span
|
||
className="client-delete-strike"
|
||
aria-hidden="true"
|
||
onAnimationEnd={() => feature.finishRemove(rule._key)}
|
||
/>
|
||
<div className="client-local-rule-meta">
|
||
<RuleOutboundPicker
|
||
value={rule.outbound}
|
||
index={index}
|
||
disabled={editorDisabled}
|
||
onChange={(outbound) => feature.change(index, 'outbound', outbound)}
|
||
/>
|
||
<span className="client-local-rule-status" role="status">{statusLabel}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
{!feature.rules.length && <p className="client-local-rules-empty">Правил пока нет. Harbor использует выбранный режим соединения.</p>}
|
||
</div>
|
||
<div className="client-row-add-slot">
|
||
<button className="client-row-add" type="button" disabled={!canAdd} onClick={feature.add}>
|
||
+ Добавить правило
|
||
</button>
|
||
<span className={incomplete ? 'is-visible' : ''}>Сначала заполните текущее правило</span>
|
||
</div>
|
||
</section>
|
||
|
||
<p className="client-local-rules-note">
|
||
Правила применяются только к трафику, который вошёл в VPN-маршрутизацию Harbor. Устройство Gateway в режиме «Напрямую» и Connect при активном Harbor Gateway обходят локальный список; «Напрямую» внутри правила — результат уже найденного совпадения.
|
||
</p>
|
||
<p className="client-local-rules-note">
|
||
Можно вставить полный URL: Harbor сохранит только домен. Путь и параметры HTTPS недоступны для маршрутизации.
|
||
</p>
|
||
{statusSlot}
|
||
<div className="client-local-rules-actions">
|
||
<button type="button" onClick={feature.requestClose}>Отмена</button>
|
||
</div>
|
||
</form>
|
||
</Drawer>
|
||
);
|
||
}
|
||
|
||
export function RoutingDiscardDialog({ feature }: { feature: RoutingFeature }) {
|
||
return <ConfirmationDialog
|
||
open={feature.confirmingClose}
|
||
id="discard-local-rules"
|
||
title="Есть несохранённые настройки"
|
||
description="Закрыть редактор и потерять изменения?"
|
||
cancelLabel="Остаться"
|
||
confirmLabel="Закрыть без сохранения"
|
||
onCancel={() => feature.setConfirmingClose(false)}
|
||
onConfirm={feature.discard}
|
||
/>;
|
||
}
|