feat(map): add reference layers and stabilize workspace controls

This commit is contained in:
Codex
2026-07-25 13:12:24 +03:00
parent c859ae4db0
commit eaecce9f56
29 changed files with 1593 additions and 247 deletions
+14 -7
View File
@@ -16,21 +16,29 @@ export interface InspectorSectionSpec {
export interface InspectorProps {
sections: InspectorSectionSpec[];
defaultOpen?: string[];
openSections?: string[];
activeId?: string;
singleOpen?: boolean;
className?: string;
onOpenSectionsChange?: (ids: string[]) => void;
onActiveChange?: (id: string) => void;
}
export function Inspector({
sections,
defaultOpen = [],
openSections,
activeId,
singleOpen = false,
className,
onOpenSectionsChange,
onActiveChange,
}: InspectorProps) {
const [openIds, setOpenIds] = useState(() => new Set(defaultOpen));
const [internalOpenIds, setInternalOpenIds] = useState(() => new Set(defaultOpen));
const openIds = useMemo(
() => openSections === undefined ? internalOpenIds : new Set(openSections),
[internalOpenIds, openSections],
);
const groups = useMemo(() => {
const result: Array<{ label?: string; sections: InspectorSectionSpec[] }> = [];
sections.forEach((section) => {
@@ -45,12 +53,11 @@ export function Inspector({
}, [sections]);
const toggle = (id: string) => {
setOpenIds((current) => {
const next = singleOpen ? new Set<string>() : new Set(current);
if (current.has(id)) next.delete(id);
else next.add(id);
return next;
});
const next = singleOpen ? new Set<string>() : new Set(openIds);
if (openIds.has(id)) next.delete(id);
else next.add(id);
if (openSections === undefined) setInternalOpenIds(next);
onOpenSectionsChange?.([...next]);
onActiveChange?.(id);
};
+120 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, type CSSProperties, type InputHTMLAttributes, type PointerEvent } from "react";
import { useEffect, useRef, useState, type CSSProperties, type InputHTMLAttributes, type KeyboardEvent, type PointerEvent } from "react";
import { cn } from "./cn.js";
import { Dropdown } from "./Dropdown.js";
@@ -11,6 +11,38 @@ export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputEle
onChange: (value: number) => void;
}
const clampRangeValue = (value: number, min: number, max: number) => (
Math.max(min, Math.min(max, value))
);
const decimalPlaces = (value: number) => {
const [coefficient, exponentText] = String(value).toLowerCase().split("e");
const coefficientDecimals = coefficient?.split(".")[1]?.length ?? 0;
if (exponentText === undefined) return coefficientDecimals;
const exponent = Number.parseInt(exponentText, 10);
if (!Number.isFinite(exponent)) return coefficientDecimals;
return Math.max(0, coefficientDecimals - exponent);
};
const normalizeEditedRangeValue = (
value: number,
min: number,
max: number,
step: RangeControlProps["step"],
) => {
const clamped = clampRangeValue(value, min, max);
if (step === "any") return clamped;
const numericStep = step === undefined ? 1 : Number(step);
if (!Number.isFinite(numericStep) || numericStep <= 0) return clamped;
const precision = Math.min(12, Math.max(decimalPlaces(min), decimalPlaces(numericStep)));
const aligned = min + Math.round((clamped - min) / numericStep) * numericStep;
return clampRangeValue(Number(aligned.toFixed(precision)), min, max);
};
const editableNumber = (value: number) => (
Number.isInteger(value) ? String(value) : String(Number(value.toFixed(12)))
);
export function RangeControl({
label,
value,
@@ -20,31 +52,111 @@ export function RangeControl({
formatValue = String,
onChange,
className,
disabled = false,
tabIndex,
...props
}: RangeControlProps) {
const rangeRef = useRef<HTMLInputElement>(null);
const editorRef = useRef<HTMLInputElement>(null);
const cancelNextBlurRef = useRef(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(() => editableNumber(value));
const safeMax = max === min ? min + 1 : max;
const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100));
const style = { "--nodedc-range-progress": `${progress}%` } as CSSProperties;
const displayValue = formatValue(value);
const editorCharacters = Math.max(
1,
Math.min(14, editing ? draft.length || editableNumber(value).length : displayValue.length),
);
const style = {
"--nodedc-range-progress": `${progress}%`,
"--nodedc-range-editor-characters": editorCharacters,
} as CSSProperties;
useEffect(() => {
if (!editing) setDraft(editableNumber(value));
}, [editing, value]);
const finishEditing = (commit: boolean, restoreRangeFocus: boolean) => {
if (commit) {
const parsed = Number(draft.trim().replace(",", "."));
if (Number.isFinite(parsed)) onChange(normalizeEditedRangeValue(parsed, min, max, step));
}
setEditing(false);
if (restoreRangeFocus) requestAnimationFrame(() => rangeRef.current?.focus());
};
const handleEditorKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
event.stopPropagation();
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
if (event.key === "Escape") {
event.preventDefault();
cancelNextBlurRef.current = true;
setDraft(editableNumber(value));
setEditing(false);
event.currentTarget.blur();
requestAnimationFrame(() => rangeRef.current?.focus());
}
};
return (
<label className={cn("nodedc-range", className)} style={style}>
<div className={cn("nodedc-range", className)} style={style} data-editing={editing || undefined}>
<input
ref={rangeRef}
{...props}
type="range"
value={value}
min={min}
max={safeMax}
step={step}
disabled={disabled}
tabIndex={editing ? -1 : tabIndex}
aria-label={label}
aria-valuetext={formatValue(value)}
aria-valuetext={displayValue}
onChange={(event) => onChange(Number(event.target.value))}
{...props}
/>
<span className="nodedc-range__label">{label}</span>
<span className="nodedc-range__value">{formatValue(value)}</span>
<span className="nodedc-range__value">{displayValue}</span>
<span className="nodedc-range__fill-text" aria-hidden="true">
<span className="nodedc-range__label">{label}</span>
<span className="nodedc-range__value">{formatValue(value)}</span>
<span className="nodedc-range__value">{displayValue}</span>
</span>
</label>
<input
ref={editorRef}
className="nodedc-range__editor"
type="text"
inputMode="decimal"
value={editing ? draft : editableNumber(value)}
aria-label={`${label}: точное значение`}
disabled={disabled}
spellCheck={false}
data-active={editing || undefined}
onPointerDown={() => {
if (!editing) {
setDraft(editableNumber(value));
setEditing(true);
}
}}
onFocus={() => {
if (!editing) {
setDraft(editableNumber(value));
setEditing(true);
}
}}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleEditorKeyDown}
onBlur={() => {
if (cancelNextBlurRef.current) {
cancelNextBlurRef.current = false;
return;
}
finishEditing(true, false);
}}
/>
</div>
);
}
+4 -2
View File
@@ -63,10 +63,12 @@ export function Window({
const titleId = useId();
const descriptionId = useId();
const dialogRef = useRef<HTMLDivElement>(null);
const onCloseRef = useRef(onClose);
const dragRef = useRef<{ pointerId: number; offsetX: number; offsetY: number } | null>(null);
const [dragPosition, setDragPosition] = useState<{ left: number; top: number } | null>(null);
const shouldLockBodyScroll = lockBodyScroll ?? placement === "center";
const shouldTrapFocus = trapFocus ?? placement === "center";
onCloseRef.current = onClose;
useEffect(() => {
if (!open || typeof document === "undefined") return;
@@ -82,7 +84,7 @@ export function Window({
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && closeOnEscape) {
event.preventDefault();
onClose();
onCloseRef.current();
return;
}
if (event.key !== "Tab" || !shouldTrapFocus || !dialogRef.current) return;
@@ -110,7 +112,7 @@ export function Window({
if (shouldLockBodyScroll) document.body.style.overflow = previousOverflow;
previousActiveElement?.focus();
};
}, [closeOnEscape, onClose, open, shouldLockBodyScroll, shouldTrapFocus]);
}, [closeOnEscape, open, shouldLockBodyScroll, shouldTrapFocus]);
useEffect(() => {
if (!open) setDragPosition(null);
+58 -2
View File
@@ -35,6 +35,7 @@ export interface WorkspaceWindowProps extends Omit<HTMLAttributes<HTMLDivElement
minWidth?: number;
minHeight?: number;
resizable?: boolean;
autoHeight?: boolean;
active?: boolean;
zIndex?: number;
closeLabel?: string;
@@ -121,6 +122,7 @@ export function WorkspaceWindow({
minWidth = 260,
minHeight = 180,
resizable = true,
autoHeight = false,
active = false,
zIndex,
closeLabel = "Закрыть окно",
@@ -139,6 +141,10 @@ export function WorkspaceWindow({
const interactionRef = useRef<WorkspaceInteraction | null>(null);
const rectRef = useRef(rect);
const onRectChangeRef = useRef(onRectChange);
const headRef = useRef<HTMLElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const bodyContentRef = useRef<HTMLDivElement>(null);
const footerRef = useRef<HTMLElement>(null);
const [interactionKind, setInteractionKind] = useState<WorkspaceInteraction["kind"] | null>(null);
rectRef.current = rect;
@@ -175,6 +181,52 @@ export function WorkspaceWindow({
};
}, [boundsRef, maximized, minHeight, minWidth, rect.height, rect.width, rect.x, rect.y]);
useEffect(() => {
if (!autoHeight || maximized) return;
const bounds = boundsRef.current;
const head = headRef.current;
const body = bodyRef.current;
const content = bodyContentRef.current;
if (!bounds || !head || !body || !content) return;
let frame = 0;
const fitContent = () => {
frame = 0;
const nextBounds = readBounds();
if (!nextBounds) return;
const normalized = normalizeRect(rectRef.current, nextBounds, minWidth, minHeight);
const bodyStyle = window.getComputedStyle(body);
const bodyPadding = Number.parseFloat(bodyStyle.paddingTop || "0")
+ Number.parseFloat(bodyStyle.paddingBottom || "0");
const desiredHeight = Math.ceil(
head.offsetHeight
+ content.scrollHeight
+ bodyPadding
+ (footerRef.current?.offsetHeight ?? 0),
);
const availableHeight = Math.max(0, nextBounds.height - normalized.y);
emitRect({
...normalized,
height: clampDimension(desiredHeight, minHeight, availableHeight),
});
};
const scheduleFit = () => {
if (frame) cancelAnimationFrame(frame);
frame = requestAnimationFrame(fitContent);
};
scheduleFit();
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleFit);
observer?.observe(content);
observer?.observe(head);
if (footerRef.current) observer?.observe(footerRef.current);
return () => {
if (frame) cancelAnimationFrame(frame);
observer?.disconnect();
};
}, [autoHeight, boundsRef, maximized, minHeight, minWidth, rect.width]);
useEffect(() => {
const handlePointerMove = (event: globalThis.PointerEvent) => {
const interaction = interactionRef.current;
@@ -273,6 +325,7 @@ export function WorkspaceWindow({
data-active={active ? "true" : undefined}
data-maximized={maximized ? "true" : undefined}
data-resizable={resizable ? "true" : undefined}
data-auto-height={autoHeight ? "true" : undefined}
data-interaction={interactionKind ?? undefined}
role="dialog"
aria-modal="false"
@@ -289,6 +342,7 @@ export function WorkspaceWindow({
}}
>
<header
ref={headRef}
className="nodedc-workspace-window__head"
tabIndex={maximized ? -1 : 0}
role="group"
@@ -326,8 +380,10 @@ export function WorkspaceWindow({
</button>
</div>
</header>
<div className="nodedc-workspace-window__body">{children}</div>
{footer ? <footer className="nodedc-workspace-window__footer">{footer}</footer> : null}
<div ref={bodyRef} className="nodedc-workspace-window__body">
{autoHeight ? <div ref={bodyContentRef} className="nodedc-workspace-window__body-content">{children}</div> : children}
</div>
{footer ? <footer ref={footerRef} className="nodedc-workspace-window__footer">{footer}</footer> : null}
{resizable && !maximized ? (
<button
type="button"