mirror of
https://github.com/k1tbyte/Wand-Enhancer.git
synced 2026-09-02 07:00:34 +00:00
13759b1db6
- reduce ASAR IO overhead with streamed archive reads, buffered copies, faster relative-path handling and placeholder integrity records - fix in-place app.asar.unpacked packing/extraction self-copy cases that caused locked-file failures - tighten JS patch discovery with candidate bundle filters and search hints - require prebuilt remote-panel dist artifacts and clean up embedded bridge/script packaging - add unified build entrypoints for PowerShell, cmd and bash and move native CMake output under .tmp - add release metadata validation, changelog section extraction, pre-commit hook and GitHub Actions validation/release pipelines - make CHANGELOG the source of truth for release notes and document the tag-driven release flow - add updater release notes UI with latest/full changelog loading and localize the new update strings - modularize bridge renderer scripts, add installed apps and game status sync, and support remote launch/stop commands - centralize bridge protocol, IPC and WebSocket constants and improve LAN IP selection for QR pairing - refactor remote panel controls/state enums, persist accent color, polish library/session UI and refresh assets
167 lines
6.0 KiB
TypeScript
167 lines
6.0 KiB
TypeScript
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
|
|
|
import { Icon } from '@/components/ui/icon';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
import type { CheatSchema } from '../protocol';
|
|
import { ECheatType } from '../protocol';
|
|
import { CheatControl } from '../controls/CheatControl';
|
|
|
|
type CheatTileProps = {
|
|
cheat: CheatSchema;
|
|
value: unknown;
|
|
pending: boolean;
|
|
disabled: boolean;
|
|
pinned: boolean;
|
|
first: boolean;
|
|
onChange: (nextValue: unknown) => void;
|
|
onTogglePin: () => void;
|
|
};
|
|
|
|
const SWIPE_REVEAL = 80;
|
|
const SWIPE_TRIGGER = 56;
|
|
const SWIPE_DEAD_ZONE = 8;
|
|
const SWIPE_ANIMATION_MS = 220;
|
|
|
|
export const CheatTile = ({ cheat, value, pending, disabled, pinned, first, onChange, onTogglePin }: CheatTileProps) => {
|
|
const [offset, setOffset] = useState(0);
|
|
const [animating, setAnimating] = useState(false);
|
|
const [armed, setArmed] = useState(false);
|
|
const dragRef = useRef<{ id: number; startX: number; startY: number; locked: boolean | null } | null>(null);
|
|
|
|
useEffect(() => {
|
|
setOffset(0);
|
|
setArmed(false);
|
|
}, [pinned]);
|
|
|
|
const settle = (target: number) => {
|
|
setAnimating(true);
|
|
setOffset(target);
|
|
window.setTimeout(() => setAnimating(false), SWIPE_ANIMATION_MS);
|
|
};
|
|
|
|
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
|
if (isInteractiveTarget(event.target)) return;
|
|
dragRef.current = { id: event.pointerId, startX: event.clientX, startY: event.clientY, locked: null };
|
|
setAnimating(false);
|
|
};
|
|
|
|
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
const drag = dragRef.current;
|
|
if (!drag || drag.id !== event.pointerId) return;
|
|
|
|
const dx = event.clientX - drag.startX;
|
|
const dy = event.clientY - drag.startY;
|
|
|
|
if (drag.locked === null) {
|
|
if (Math.abs(dx) < SWIPE_DEAD_ZONE && Math.abs(dy) < SWIPE_DEAD_ZONE) return;
|
|
drag.locked = Math.abs(dx) > Math.abs(dy) && dx < 0;
|
|
if (!drag.locked) {
|
|
dragRef.current = null;
|
|
return;
|
|
}
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
}
|
|
|
|
const next = clamp(dx, -SWIPE_REVEAL * 1.2, 0);
|
|
setOffset(next);
|
|
setArmed(-next >= SWIPE_TRIGGER);
|
|
};
|
|
|
|
const handlePointerEnd = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
const drag = dragRef.current;
|
|
if (!drag || drag.id !== event.pointerId) return;
|
|
dragRef.current = null;
|
|
|
|
if (drag.locked !== true) {
|
|
return;
|
|
}
|
|
|
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
}
|
|
|
|
const triggered = -offset >= SWIPE_TRIGGER;
|
|
settle(0);
|
|
setArmed(false);
|
|
if (triggered) {
|
|
onTogglePin();
|
|
}
|
|
};
|
|
|
|
const stacked = isStackedControl(cheat);
|
|
const showReveal = offset < 0;
|
|
|
|
return (
|
|
<div className={cn('relative overflow-hidden', first ? '' : 'border-t border-white/[0.06]')}>
|
|
{showReveal ? <PinReveal pinned={pinned} armed={armed} /> : null}
|
|
<div
|
|
className={cn('relative', animating ? 'transition-transform duration-200 ease-out' : '')}
|
|
style={{ transform: `translate3d(${offset}px, 0, 0)`, touchAction: 'pan-y' }}
|
|
onPointerCancel={handlePointerEnd}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerEnd}
|
|
>
|
|
<div className="flex flex-col gap-2 px-3.5 py-3">
|
|
<div className={cn('flex gap-3', stacked ? 'flex-col items-stretch' : 'items-start justify-between')}>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<h4 className="truncate text-[13.5px] font-medium leading-tight text-(--deck-fg)">{cheat.name}</h4>
|
|
{pending ? <Icon className="size-3.5 shrink-0 animate-spin text-(--deck-accent)" name="loader" /> : null}
|
|
</div>
|
|
{cheat.description ? <p className="mt-1 line-clamp-2 text-[11.5px] leading-snug text-(--deck-fg-3)">{cheat.description}</p> : null}
|
|
</div>
|
|
<CheatControl cheat={cheat} disabled={disabled} pending={pending} value={value} onChange={onChange} />
|
|
</div>
|
|
{cheat.instructions ? (
|
|
<div className="flex gap-2 rounded-[8px] border border-amber-300/25 bg-amber-400/10 px-2.5 py-2 text-[11.5px] leading-5 text-amber-200">
|
|
<Icon className="mt-0.5 size-3.5 shrink-0" name="alert" />
|
|
<span>{cheat.instructions}</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const PinReveal = ({ pinned, armed }: { pinned: boolean; armed: boolean }) => {
|
|
return (
|
|
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center justify-center px-4">
|
|
<span className={cn('flex size-9 items-center justify-center rounded-full border transition-all duration-150', armed ? 'scale-110 border-(--deck-accent) bg-[color-mix(in_oklab,var(--deck-accent)_28%,transparent)] text-(--deck-accent) shadow-[0_0_0_4px_color-mix(in_oklab,var(--deck-accent)_18%,transparent)]' : 'border-white/10 bg-white/[0.06] text-(--deck-fg-3)')}>
|
|
<Icon className="size-4" name={pinned ? 'pin-off' : 'pin'} />
|
|
</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
function isStackedControl(cheat: CheatSchema): boolean {
|
|
if (
|
|
cheat.type === ECheatType.Slider ||
|
|
cheat.type === ECheatType.Scalar ||
|
|
cheat.type === ECheatType.Number ||
|
|
cheat.type === ECheatType.Incremental ||
|
|
cheat.type === ECheatType.Button
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
if (cheat.type === ECheatType.Selection) {
|
|
const optionCount = cheat.args.options?.length ?? 0;
|
|
return optionCount > 0;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|
|
|
|
function isInteractiveTarget(target: EventTarget | null): boolean {
|
|
if (!(target instanceof Element)) return false;
|
|
return Boolean(target.closest('input, button, select, textarea, a, [role="slider"], [role="button"]'));
|
|
}
|