feat(tokens): app-local non-player tokens with session moves and UI polish
Add token library/placements, keep play-time moves for the session, lock presentation interactions, and fix export/import modal layout plus freeform trap label. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,6 +17,9 @@ import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
|
||||
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
|
||||
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
|
||||
import { useSceneViewState } from './sceneView/useSceneViewState';
|
||||
import { SceneTokensOverlay } from './tokens/SceneTokensOverlay';
|
||||
import { useAppTokens } from './tokens/useAppTokens';
|
||||
import { useSceneTokensSession } from './tokens/useSceneTokensSession';
|
||||
import { SceneTrapsOverlay } from './traps/SceneTrapsOverlay';
|
||||
import { useSceneTrapsState } from './traps/useSceneTrapsState';
|
||||
import styles from './PresentationView.module.css';
|
||||
@@ -44,6 +47,8 @@ export function PresentationView({
|
||||
const [sceneView] = useSceneViewState();
|
||||
const [materialsOverlay] = useMaterialsOverlayState();
|
||||
const [npcsOverlay] = useNpcsOverlayState();
|
||||
const appTokens = useAppTokens();
|
||||
const [sceneTokensSession] = useSceneTokensSession();
|
||||
const [vp] = useVideoPlaybackState();
|
||||
const videoElRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(
|
||||
@@ -164,6 +169,14 @@ export function PresentationView({
|
||||
<SceneGridOverlay grid={scene.grid} viewport={contentRect} />
|
||||
) : null}
|
||||
<div className={styles.vignette} />
|
||||
{scene?.previewAssetType === 'image' && contentRect ? (
|
||||
<SceneTokensOverlay
|
||||
placements={scene.tokens ?? []}
|
||||
library={appTokens}
|
||||
session={sceneTokensSession}
|
||||
viewport={contentRect}
|
||||
/>
|
||||
) : null}
|
||||
{scene?.previewAssetType === 'image' && contentRect ? (
|
||||
<SceneTrapsOverlay
|
||||
traps={scene.traps ?? []}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
.layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
/* Выше brushLayer (3) и traps (5), ниже explosion (7) — drag должен стабильно ловиться. */
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
.token {
|
||||
position: absolute;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
touch-action: none;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.tokenEditable {
|
||||
pointer-events: auto;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.tokenEditable:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.tokenImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { AppToken, SceneToken, SceneTokensSessionState } from '../../../shared/types';
|
||||
|
||||
import { useTokenImageUrl } from './useTokenImageUrl';
|
||||
import styles from './SceneTokensOverlay.module.css';
|
||||
|
||||
type Viewport = { x: number; y: number; w: number; h: number };
|
||||
|
||||
type Props = {
|
||||
placements: readonly SceneToken[];
|
||||
library: readonly AppToken[];
|
||||
session: SceneTokensSessionState | null;
|
||||
viewport: Viewport | null;
|
||||
editable?: boolean;
|
||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||
};
|
||||
|
||||
function TokenSprite({
|
||||
placement,
|
||||
nx,
|
||||
ny,
|
||||
viewport,
|
||||
editable,
|
||||
onMove,
|
||||
}: {
|
||||
placement: SceneToken;
|
||||
nx: number;
|
||||
ny: number;
|
||||
viewport: Viewport;
|
||||
editable: boolean;
|
||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||
}) {
|
||||
const url = useTokenImageUrl(placement.tokenId);
|
||||
const dragRef = useRef<{
|
||||
startNx: number;
|
||||
startNy: number;
|
||||
pointerNx: number;
|
||||
pointerNy: number;
|
||||
pointerId: number;
|
||||
lastNx: number;
|
||||
lastNy: number;
|
||||
} | null>(null);
|
||||
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
|
||||
const frameRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (dragRef.current) return;
|
||||
setLocalPos(null);
|
||||
}, [nx, ny]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const posNx = localPos?.nx ?? nx;
|
||||
const posNy = localPos?.ny ?? ny;
|
||||
|
||||
const minDim = Math.min(viewport.w, viewport.h);
|
||||
const sizePx = Math.max(16, placement.sizeN * minDim);
|
||||
const left = viewport.x + posNx * viewport.w;
|
||||
const top = viewport.y + posNy * viewport.h;
|
||||
|
||||
const hostToNorm = (clientX: number, clientY: number, host: HTMLElement) => {
|
||||
const r = host.getBoundingClientRect();
|
||||
const w = Math.max(1e-6, viewport.w);
|
||||
const h = Math.max(1e-6, viewport.h);
|
||||
return {
|
||||
x: Math.max(0, Math.min(1, (clientX - (r.left + viewport.x)) / w)),
|
||||
y: Math.max(0, Math.min(1, (clientY - (r.top + viewport.y)) / h)),
|
||||
};
|
||||
};
|
||||
|
||||
const endDrag = (el: HTMLDivElement, pointerId: number) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || d.pointerId !== pointerId) return;
|
||||
dragRef.current = null;
|
||||
if (frameRef.current) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = 0;
|
||||
}
|
||||
// Финальный commit в session store — один раз на отпускание.
|
||||
onMove?.(String(placement.id), d.lastNx, d.lastNy);
|
||||
setLocalPos({ nx: d.lastNx, ny: d.lastNy });
|
||||
try {
|
||||
if (el.hasPointerCapture(pointerId)) el.releasePointerCapture(pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.token, editable ? styles.tokenEditable : ''].filter(Boolean).join(' ')}
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
transform: `translate(-50%, -50%) rotate(${String(placement.rotationDeg)}deg)`,
|
||||
}}
|
||||
onPointerDown={
|
||||
editable && onMove
|
||||
? (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const host = (e.currentTarget.parentElement as HTMLElement | null) ?? e.currentTarget;
|
||||
const p = hostToNorm(e.clientX, e.clientY, host);
|
||||
dragRef.current = {
|
||||
startNx: posNx,
|
||||
startNy: posNy,
|
||||
pointerNx: p.x,
|
||||
pointerNy: p.y,
|
||||
pointerId: e.pointerId,
|
||||
lastNx: posNx,
|
||||
lastNy: posNy,
|
||||
};
|
||||
setLocalPos({ nx: posNx, ny: posNy });
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerMove={
|
||||
editable && onMove
|
||||
? (e) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || d.pointerId !== e.pointerId) return;
|
||||
const host = (e.currentTarget.parentElement as HTMLElement | null) ?? e.currentTarget;
|
||||
const p = hostToNorm(e.clientX, e.clientY, host);
|
||||
const nextNx = Math.max(0, Math.min(1, d.startNx + (p.x - d.pointerNx)));
|
||||
const nextNy = Math.max(0, Math.min(1, d.startNy + (p.y - d.pointerNy)));
|
||||
d.lastNx = nextNx;
|
||||
d.lastNy = nextNy;
|
||||
if (frameRef.current) return;
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = 0;
|
||||
const cur = dragRef.current;
|
||||
if (!cur) return;
|
||||
setLocalPos({ nx: cur.lastNx, ny: cur.lastNy });
|
||||
});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerUp={(e) => {
|
||||
endDrag(e.currentTarget as HTMLDivElement, e.pointerId);
|
||||
}}
|
||||
onPointerCancel={(e) => {
|
||||
endDrag(e.currentTarget as HTMLDivElement, e.pointerId);
|
||||
}}
|
||||
>
|
||||
{url ? <img className={styles.tokenImg} src={url} alt="" draggable={false} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SceneTokensOverlay({
|
||||
placements,
|
||||
library,
|
||||
session,
|
||||
viewport,
|
||||
editable = false,
|
||||
onMove,
|
||||
}: Props) {
|
||||
if (!viewport || placements.length === 0) return null;
|
||||
const known = new Set(library.map((t) => t.id));
|
||||
|
||||
return (
|
||||
<div className={styles.layer}>
|
||||
{placements
|
||||
.filter((p) => known.has(p.tokenId))
|
||||
.map((placement) => {
|
||||
const key = String(placement.id);
|
||||
const override = session?.byPlacementId[key] ?? session?.byPlacementId[placement.id];
|
||||
const nx = override?.nx ?? placement.nx;
|
||||
const ny = override?.ny ?? placement.ny;
|
||||
return (
|
||||
<TokenSprite
|
||||
key={key}
|
||||
placement={placement}
|
||||
nx={nx}
|
||||
ny={ny}
|
||||
viewport={viewport}
|
||||
editable={editable}
|
||||
onMove={onMove}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type { AppToken } from '../../../shared/types';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
export function useAppTokens(): AppToken[] {
|
||||
const api = getDndApi();
|
||||
const [tokens, setTokens] = useState<AppToken[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.tokens.list, {}).then(({ tokens: list }) => setTokens(list));
|
||||
return api.on(ipcChannels.tokens.stateChanged, ({ tokens: list }) => setTokens(list));
|
||||
}, [api]);
|
||||
|
||||
return tokens;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type { SceneTokensSessionEvent, SceneTokensSessionState } from '../../../shared/types';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
function applyEvent(
|
||||
prev: SceneTokensSessionState | null,
|
||||
event: SceneTokensSessionEvent,
|
||||
): SceneTokensSessionState {
|
||||
const base = prev ?? { revision: 0, byPlacementId: {} };
|
||||
if (event.kind === 'clear') {
|
||||
return { revision: base.revision + 1, byPlacementId: {} };
|
||||
}
|
||||
const placementId = String(event.placementId ?? '');
|
||||
return {
|
||||
revision: base.revision + 1,
|
||||
byPlacementId: {
|
||||
...base.byPlacementId,
|
||||
[placementId]: { nx: event.nx, ny: event.ny },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useSceneTokensSession(): [
|
||||
SceneTokensSessionState | null,
|
||||
{ dispatch: (event: SceneTokensSessionEvent) => Promise<void> },
|
||||
] {
|
||||
const api = getDndApi();
|
||||
const [state, setState] = useState<SceneTokensSessionState | null>(null);
|
||||
const localRevisionRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.sceneTokensSession.getState, {}).then(({ state: s }) => {
|
||||
localRevisionRef.current = Math.max(localRevisionRef.current, s.revision);
|
||||
setState(s);
|
||||
});
|
||||
return api.on(ipcChannels.sceneTokensSession.stateChanged, ({ state: s }) => {
|
||||
// Не затираем более свежий optimistic-стейт устаревшим broadcast
|
||||
// (например, emit при смене сцены, пока последний move ещё в полёте).
|
||||
if (localRevisionRef.current > s.revision) return;
|
||||
localRevisionRef.current = s.revision;
|
||||
setState(s);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const apiWrap = useMemo(
|
||||
() => ({
|
||||
dispatch: async (event: SceneTokensSessionEvent) => {
|
||||
setState((prev) => {
|
||||
const next = applyEvent(prev, event);
|
||||
localRevisionRef.current = Math.max(localRevisionRef.current, next.revision);
|
||||
return next;
|
||||
});
|
||||
const res = await api.invoke(ipcChannels.sceneTokensSession.dispatch, { event });
|
||||
void res;
|
||||
},
|
||||
}),
|
||||
[api],
|
||||
);
|
||||
|
||||
return [state, apiWrap];
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type { TokenId } from '../../../shared/types';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
const cache = new Map<string, string | null>();
|
||||
|
||||
export function useTokenImageUrl(tokenId: TokenId | null | undefined): string | null {
|
||||
const api = getDndApi();
|
||||
const [url, setUrl] = useState<string | null>(() =>
|
||||
tokenId ? (cache.get(tokenId) ?? null) : null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tokenId) {
|
||||
setUrl(null);
|
||||
return;
|
||||
}
|
||||
const cached = cache.get(tokenId);
|
||||
if (cached !== undefined) {
|
||||
setUrl(cached);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void api.invoke(ipcChannels.tokens.imageUrl, { id: tokenId }).then(({ url: next }) => {
|
||||
cache.set(tokenId, next);
|
||||
if (!cancelled) setUrl(next);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [api, tokenId]);
|
||||
|
||||
useEffect(() => {
|
||||
return api.on(ipcChannels.tokens.stateChanged, ({ tokens }) => {
|
||||
for (const t of tokens) {
|
||||
cache.delete(t.id);
|
||||
}
|
||||
if (tokenId) {
|
||||
void api.invoke(ipcChannels.tokens.imageUrl, { id: tokenId }).then(({ url: next }) => {
|
||||
cache.set(tokenId, next);
|
||||
setUrl(next);
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [api, tokenId]);
|
||||
|
||||
return url;
|
||||
}
|
||||
@@ -132,7 +132,9 @@ export function SceneTrapsOverlay({
|
||||
}
|
||||
>
|
||||
<TrapGlyph type={trap.type} status={rt.status} size={Math.max(14, sizePx * 0.55)} />
|
||||
{trap.label ? <div className={styles.label}>{trap.label}</div> : null}
|
||||
{trap.label && trap.label.trim().toLowerCase() !== 'свободная' ? (
|
||||
<div className={styles.label}>{trap.label}</div>
|
||||
) : null}
|
||||
{activationFx?.trapId === trap.id && activationFx.kind === 'flash' ? (
|
||||
<div className={styles.flash} />
|
||||
) : null}
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
.buttonHost {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
flex: 0 0 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.button {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
@@ -89,11 +96,3 @@
|
||||
outline: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
select.input {
|
||||
padding-right: 28px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
background-size: 12px 12px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 10px 0 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-3);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.trigger:hover:not(:disabled) {
|
||||
border-color: var(--stroke-2);
|
||||
background: var(--color-panel-2);
|
||||
}
|
||||
|
||||
.trigger:focus-visible {
|
||||
border-color: var(--accent-border);
|
||||
box-shadow: 0 0 0 2px var(--accent-fill-soft);
|
||||
}
|
||||
|
||||
.trigger:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.triggerOpen {
|
||||
border-color: var(--accent-border);
|
||||
background: var(--color-panel-2);
|
||||
}
|
||||
|
||||
.value {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: 0 0 auto;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
opacity: 0.72;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.chevronOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: calc(var(--z-modal) + 10);
|
||||
max-height: min(280px, 50vh);
|
||||
overflow: auto;
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke-2);
|
||||
background: var(--color-surface-menu);
|
||||
box-shadow: var(--shadow-menu);
|
||||
backdrop-filter: var(--backdrop-blur-surface);
|
||||
-webkit-backdrop-filter: var(--backdrop-blur-surface);
|
||||
}
|
||||
|
||||
.option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-xs);
|
||||
background: transparent;
|
||||
color: var(--text0);
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.option:hover:not(:disabled),
|
||||
.optionActive:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.optionSelected {
|
||||
background: var(--accent-fill-soft-2);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.optionSelected:hover:not(:disabled),
|
||||
.optionSelected.optionActive:not(:disabled) {
|
||||
background: var(--accent-fill-soft);
|
||||
}
|
||||
|
||||
.option:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import styles from './Select.module.css';
|
||||
|
||||
export type SelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type SelectProps = {
|
||||
value: string;
|
||||
options: readonly SelectOption[];
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
/** Доп. класс на кнопку-триггер (ширина и т.п.). */
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
type MenuPos = { left: number; top: number; width: number; maxHeight: number };
|
||||
|
||||
function Chevron({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
className={[styles.chevron, open ? styles.chevronOpen : ''].filter(Boolean).join(' ')}
|
||||
viewBox="0 0 12 12"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M2.5 4.25L6 7.75L9.5 4.25"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Select({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
className,
|
||||
placeholder = '—',
|
||||
}: SelectProps) {
|
||||
const listId = useId();
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [menuPos, setMenuPos] = useState<MenuPos | null>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
const selected = useMemo(() => options.find((o) => o.value === value) ?? null, [options, value]);
|
||||
const enabledIndexes = useMemo(
|
||||
() => options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0),
|
||||
[options],
|
||||
);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpen(false);
|
||||
setMenuPos(null);
|
||||
}, []);
|
||||
|
||||
const layoutMenu = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) return;
|
||||
const r = trigger.getBoundingClientRect();
|
||||
const pad = 8;
|
||||
const gap = 4;
|
||||
const preferredMax = Math.min(280, window.innerHeight * 0.5);
|
||||
const spaceBelow = window.innerHeight - r.bottom - pad;
|
||||
const spaceAbove = r.top - pad;
|
||||
const placeBelow = spaceBelow >= 120 || spaceBelow >= spaceAbove;
|
||||
const maxHeight = Math.max(96, Math.min(preferredMax, placeBelow ? spaceBelow - gap : spaceAbove - gap));
|
||||
const top = placeBelow ? r.bottom + gap : Math.max(pad, r.top - gap - maxHeight);
|
||||
setMenuPos({
|
||||
left: Math.max(pad, Math.min(r.left, window.innerWidth - r.width - pad)),
|
||||
top,
|
||||
width: r.width,
|
||||
maxHeight,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openMenu = useCallback(() => {
|
||||
if (disabled) return;
|
||||
const selectedIdx = options.findIndex((o) => o.value === value && !o.disabled);
|
||||
const fallback = enabledIndexes[0] ?? 0;
|
||||
setActiveIndex(selectedIdx >= 0 ? selectedIdx : fallback);
|
||||
setOpen(true);
|
||||
}, [disabled, enabledIndexes, options, value]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
layoutMenu();
|
||||
}, [layoutMenu, open, options.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onWin = () => layoutMenu();
|
||||
window.addEventListener('resize', onWin);
|
||||
window.addEventListener('scroll', onWin, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onWin);
|
||||
window.removeEventListener('scroll', onWin, true);
|
||||
};
|
||||
}, [layoutMenu, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
const t = e.target;
|
||||
if (!(t instanceof Node)) return;
|
||||
if (triggerRef.current?.contains(t)) return;
|
||||
if (menuRef.current?.contains(t)) return;
|
||||
close();
|
||||
};
|
||||
window.addEventListener('pointerdown', onPointerDown, true);
|
||||
return () => window.removeEventListener('pointerdown', onPointerDown, true);
|
||||
}, [close, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const el = menuRef.current?.querySelector<HTMLElement>(`[data-select-index="${String(activeIndex)}"]`);
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeIndex, open]);
|
||||
|
||||
const moveActive = (dir: 1 | -1) => {
|
||||
if (enabledIndexes.length === 0) return;
|
||||
const pos = enabledIndexes.indexOf(activeIndex);
|
||||
const nextPos =
|
||||
pos < 0
|
||||
? dir === 1
|
||||
? 0
|
||||
: enabledIndexes.length - 1
|
||||
: (pos + dir + enabledIndexes.length) % enabledIndexes.length;
|
||||
setActiveIndex(enabledIndexes[nextPos]!);
|
||||
};
|
||||
|
||||
const commitIndex = (index: number) => {
|
||||
const opt = options[index];
|
||||
if (!opt || opt.disabled) return;
|
||||
onChange(opt.value);
|
||||
close();
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
const onTriggerKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (disabled) return;
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (!open) {
|
||||
openMenu();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') moveActive(1);
|
||||
else if (e.key === 'ArrowUp') moveActive(-1);
|
||||
else if (e.key === 'Enter' || e.key === ' ') commitIndex(activeIndex);
|
||||
} else if (e.key === 'Escape' && open) {
|
||||
e.preventDefault();
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const onMenuKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
moveActive(1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
moveActive(-1);
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
commitIndex(activeIndex);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
close();
|
||||
triggerRef.current?.focus();
|
||||
} else if (e.key === 'Tab') {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const triggerClass = [
|
||||
styles.trigger,
|
||||
open ? styles.triggerOpen : '',
|
||||
className ?? '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const menu =
|
||||
open && menuPos && typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
id={listId}
|
||||
role="listbox"
|
||||
className={styles.menu}
|
||||
style={{
|
||||
left: menuPos.left,
|
||||
top: menuPos.top,
|
||||
width: menuPos.width,
|
||||
maxHeight: menuPos.maxHeight,
|
||||
}}
|
||||
onKeyDown={onMenuKeyDown}
|
||||
>
|
||||
{options.map((opt, index) => {
|
||||
const selectedOpt = opt.value === value;
|
||||
const active = index === activeIndex;
|
||||
return (
|
||||
<button
|
||||
key={`${opt.value}::${String(index)}`}
|
||||
type="button"
|
||||
role="option"
|
||||
data-select-index={index}
|
||||
aria-selected={selectedOpt}
|
||||
disabled={opt.disabled}
|
||||
className={[
|
||||
styles.option,
|
||||
selectedOpt ? styles.optionSelected : '',
|
||||
active ? styles.optionActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onMouseEnter={() => {
|
||||
if (!opt.disabled) setActiveIndex(index);
|
||||
}}
|
||||
onClick={() => commitIndex(index)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={triggerClass}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
onClick={() => {
|
||||
if (open) close();
|
||||
else openMenu();
|
||||
}}
|
||||
onKeyDown={onTriggerKeyDown}
|
||||
>
|
||||
<span className={[styles.value, selected ? '' : styles.placeholder].filter(Boolean).join(' ')}>
|
||||
{selected?.label ?? placeholder}
|
||||
</span>
|
||||
<Chevron open={open} />
|
||||
</button>
|
||||
{menu}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { createPortal } from 'react-dom';
|
||||
|
||||
import styles from './Controls.module.css';
|
||||
|
||||
export { Select, type SelectOption, type SelectProps } from './Select';
|
||||
|
||||
type ButtonProps = {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
@@ -94,22 +96,23 @@ export function Button({
|
||||
);
|
||||
|
||||
// Disabled buttons don't receive mouse events — host span keeps tooltip usable.
|
||||
// Single DOM root (not Fragment) so flex/grid parents don't mis-place the button.
|
||||
if (disabled && title) {
|
||||
return (
|
||||
<>
|
||||
<span className={styles.buttonHost}>
|
||||
<span ref={hostRef} className={styles.disabledTipHost} onMouseEnter={showTip} onMouseLeave={hideTip}>
|
||||
{button}
|
||||
</span>
|
||||
{tip}
|
||||
</>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={styles.buttonHost}>
|
||||
{button}
|
||||
{tip}
|
||||
</>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,15 +120,19 @@ type InputProps = {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onChange: (v: string) => void;
|
||||
autoFocus?: boolean;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
export function Input({ value, placeholder, onChange }: InputProps) {
|
||||
export function Input({ value, placeholder, onChange, autoFocus, onKeyDown }: InputProps) {
|
||||
return (
|
||||
<input
|
||||
className={styles.input}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user