import React from 'react';
/**
* GuiOverlay — рендерит GUI-элементы (Frame/Text/Button/Image) поверх viewport.
*
* Этап 2.8.1: drag-and-drop, выделение.
* Этап 2.8.2: resize-ручки (8 точек: углы и середины сторон), snap-линии
* к центру/краям/середине viewport и к границам других элементов.
*
* В Play-режиме:
* - элементы рендерятся «как есть»,
* - кнопки кликабельны (на этапе 2.8.3 будут шлёт событие в скрипты).
*/
const SNAP_THRESHOLD = 1.5; // % — расстояние, в пределах которого срабатывает snap
function GuiOverlay({
elements = [],
isPlaying = false,
selectedId = null,
onSelect,
onUpdate,
onDelete,
onPlayClick,
containerRef,
// resolveAsset(id) → dataURL картинки из AssetManager (для image-GUI).
resolveAsset,
}) {
// snapLines — массив { dir: 'v'|'h', pos: % } для отрисовки направляющих во время drag/resize
const [snapLines, setSnapLines] = React.useState([]);
if (!Array.isArray(elements) || elements.length === 0) {
return null;
}
// Корневые элементы (parentId == null) — рендерятся в overlay-контейнер.
// Дочерние рендерятся внутри своего родителя через рекурсивный GuiElement.
const rootSorted = elements
.filter(e => !e.parentId)
.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0));
// Map id → children для быстрого поиска
const childrenMap = new Map();
for (const el of elements) {
if (!el.parentId) continue;
if (!childrenMap.has(el.parentId)) childrenMap.set(el.parentId, []);
childrenMap.get(el.parentId).push(el);
}
for (const arr of childrenMap.values()) {
arr.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0));
}
return (
{rootSorted.map((el) => (
))}
{/* Snap-направляющие (рендерятся только для корневых элементов) */}
{!isPlaying && snapLines.map((line, i) => (
))}
);
}
function GuiElement({ el, allElements, childrenMap, isPlaying, selectedId, onSelect, onUpdate, onDelete, onPlayClick, containerRef, setSnapLines, resolveAsset }) {
const [dragging, setDragging] = React.useState(false);
const [resizing, setResizing] = React.useState(null); // 'n'|'s'|'e'|'w'|'ne'|'nw'|'se'|'sw' или null
const [hover, setHover] = React.useState(false);
const [pressed, setPressed] = React.useState(false);
const selfRef = React.useRef(null);
const selected = !isPlaying && selectedId === el.id;
const myChildren = childrenMap?.get(el.id) || [];
if (el.visible === false && !isPlaying && !selected) return null;
if (el.visible === false && isPlaying) return null;
const style = elementToStyle(el);
/** Начало drag (перемещение всего элемента). */
const startDrag = (e) => {
if (isPlaying || e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
onSelect?.(el.id);
// Координаты считаем относительно родительского DOM:
// - для root-элементов это viewport (containerRef)
// - для дочерних — DOM-узел Frame'а-родителя.
// Используем parentNode т.к. selfRef.current.parentNode и есть наш контейнер
// (обёртка-родитель в DOM).
const container = el.parentId
? selfRef.current?.parentNode
: containerRef?.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const startMouseX = e.clientX;
const startMouseY = e.clientY;
const startX = el.x ?? 50;
const startY = el.y ?? 50;
setDragging(true);
// Знаки для якорей: позиция X/Y отсчитывается ОТ соответствующего края.
// Для bottom-* — Y растёт ВВЕРХ от низа, поэтому движение мыши вниз
// (положительный dyPct) должно уменьшать Y. То же для right-* по X.
const anchor = el.anchor || 'center';
const xSign = anchor.endsWith('-right') ? -1 : 1;
const ySign = anchor.startsWith('bottom-') ? -1 : 1;
const onMove = (ev) => {
const dxPct = ((ev.clientX - startMouseX) / rect.width) * 100;
const dyPct = ((ev.clientY - startMouseY) / rect.height) * 100;
const step = ev.shiftKey ? 5 : 1;
let nx = Math.max(0, Math.min(100, startX + dxPct * xSign));
let ny = Math.max(0, Math.min(100, startY + dyPct * ySign));
// Snap работает только для корневых элементов (внутри viewport).
// Для дочерних — только округление, без snap-линий.
if (!el.parentId) {
const snaps = computeSnap(el, allElements, nx, ny, el.w, el.h);
if (snaps.x != null) nx = snaps.x;
if (snaps.y != null) ny = snaps.y;
setSnapLines(snaps.lines || []);
}
// Округление к целым (Shift = 5%)
nx = Math.round(nx / step) * step;
ny = Math.round(ny / step) * step;
onUpdate?.(el.id, { x: nx, y: ny });
};
const onUp = () => {
setDragging(false);
setSnapLines([]);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
};
/** Начало resize за одну из 8 ручек. */
const startResize = (handle) => (e) => {
if (isPlaying || e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
const container = el.parentId
? selfRef.current?.parentNode
: containerRef?.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const startMouseX = e.clientX;
const startMouseY = e.clientY;
const startX = el.x ?? 50;
const startY = el.y ?? 50;
const startW = el.w ?? 20;
const startH = el.h ?? 10;
const anchor = el.anchor || 'center';
setResizing(handle);
const onMove = (ev) => {
const dxPct = ((ev.clientX - startMouseX) / rect.width) * 100;
const dyPct = ((ev.clientY - startMouseY) / rect.height) * 100;
const step = ev.shiftKey ? 5 : 1;
let nx = startX, ny = startY, nw = startW, nh = startH;
// Считаем что Y «направлен» от ССЫЛОЧНОГО края к противоположному:
// top-* и center — Y растёт вниз (как мышь), bottom-* — вверх.
// X аналогично: left-* и center — вправо (как мышь), right-* — влево.
const xSign = anchor.endsWith('-right') ? -1 : 1;
const ySign = anchor.startsWith('bottom-') ? -1 : 1;
// dx/dy в координатах элемента (с учётом направления)
const adx = dxPct * xSign;
const ady = dyPct * ySign;
// Какой край тянем в координатах ЭЛЕМЕНТА (не мыши)
// 'e' — мышь вправо, для left-якорей это правый край элемента,
// для right-якорей — левый край (мы дёргаем дальний от ссылки край).
const isPosEdgeX = anchor.endsWith('-right') ? handle.includes('w') : handle.includes('e');
const isNegEdgeX = anchor.endsWith('-right') ? handle.includes('e') : handle.includes('w');
const isPosEdgeY = anchor.startsWith('bottom-') ? handle.includes('n') : handle.includes('s');
const isNegEdgeY = anchor.startsWith('bottom-') ? handle.includes('s') : handle.includes('n');
// Изменение размера и позиции в координатах элемента
if (anchor === 'center') {
// Центрированный якорь: тянем любой край → центр движется на половину дельты
if (handle.includes('e')) { nw = startW + dxPct; nx = startX + dxPct / 2; }
if (handle.includes('w')) { nw = startW - dxPct; nx = startX + dxPct / 2; }
if (handle.includes('s')) { nh = startH + dyPct; ny = startY + dyPct / 2; }
if (handle.includes('n')) { nh = startH - dyPct; ny = startY + dyPct / 2; }
} else {
// Угловые якоря: ближняя к якорю сторона = позиция (фиксирована),
// дальняя сторона = позиция + размер.
if (isPosEdgeX) {
// Тянем «дальний» край — меняется только размер
nw = Math.max(2, startW + adx);
nx = startX;
} else if (isNegEdgeX) {
// Тянем «ближний» край — двигается и позиция, и размер
nx = startX + adx;
nw = Math.max(2, startW - adx);
}
if (isPosEdgeY) {
nh = Math.max(2, startH + ady);
ny = startY;
} else if (isNegEdgeY) {
ny = startY + ady;
nh = Math.max(2, startH - ady);
}
}
// Защита от отрицательных размеров (для center)
nw = Math.max(2, nw);
nh = Math.max(2, nh);
// Округление до целых процентов
nx = Math.max(0, Math.min(100, Math.round(nx / step) * step));
ny = Math.max(0, Math.min(100, Math.round(ny / step) * step));
nw = Math.max(2, Math.min(100, Math.round(nw / step) * step));
nh = Math.max(2, Math.min(100, Math.round(nh / step) * step));
onUpdate?.(el.id, { x: nx, y: ny, w: nw, h: nh });
};
const onUp = () => {
setResizing(null);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
};
const isButton = el.type === 'button';
const isImage = el.type === 'image';
const isTextbox = el.type === 'textbox';
const isScroll = el.type === 'scroll';
const isText = el.type === 'text' || el.type === 'button';
// textbox в Play кликабелен (для фокуса и ввода), как и кнопка
const pointerEvents = isPlaying ? ((isButton || isTextbox) ? 'auto' : 'none') : 'auto';
// В Play на кнопке — hover/pressed эффект (Задача 03).
// Если у элемента задан el.hover/el.active — используем их параметры,
// иначе дефолтные значения.
const playInteractive = isPlaying && isButton;
const hoverCfg = el.hover || { scale: 1.08, brightness: 1.15, rotation: 0 };
const activeCfg = el.active || { scale: 0.94 };
const hoverBrightness = (typeof hoverCfg.brightness === 'number') ? hoverCfg.brightness : 1.15;
const hoverScale = (typeof hoverCfg.scale === 'number') ? hoverCfg.scale : 1.08;
const hoverRotation = (typeof hoverCfg.rotation === 'number') ? hoverCfg.rotation : 0;
const activeScale = (typeof activeCfg.scale === 'number') ? activeCfg.scale : 0.94;
const playFilter = pressed
? 'brightness(0.85)'
: (hover ? `brightness(${hoverBrightness})` : 'none');
const dynScale = pressed ? activeScale : (hover ? hoverScale : 1);
const dynRot = hover ? hoverRotation : 0;
let extraTr = '';
if (dynScale !== 1) extraTr += ` scale(${dynScale})`;
if (dynRot) extraTr += ` rotate(${dynRot}deg)`;
const playTransform = (style.transform || '') + extraTr;
return (
{
if (playInteractive) { setPressed(true); }
startDrag(e);
}}
onMouseUp={() => { if (playInteractive) setPressed(false); }}
onMouseEnter={() => { if (playInteractive) setHover(true); }}
onMouseLeave={() => { if (playInteractive) { setHover(false); setPressed(false); } }}
onClick={(e) => {
if (!isPlaying) return;
e.stopPropagation();
if (el.type === 'button') onPlayClick?.(el.id);
}}
onContextMenu={(e) => {
if (isPlaying) return;
e.preventDefault();
e.stopPropagation();
onSelect?.(el.id);
}}
onWheel={(e) => {
// Прокрутка scroll-контейнера колесом мыши.
if (!isScroll) return;
e.stopPropagation();
const children = childrenMap?.get(el.id) || [];
// Полная высота контента (в % размера экрана) при вертикальной раскладке.
const gap = Number.isFinite(el.layoutGap) ? el.layoutGap : 2;
const pad = Number.isFinite(el.layoutPad) ? el.layoutPad : 3;
let contentH = pad * 2;
for (const ch of children) contentH += (ch.h ?? 10) + gap;
// Сколько контента не влезает = на сколько можно прокрутить.
const maxScroll = Math.max(0, contentH - (el.h ?? 40));
const cur = Number.isFinite(el.scrollY) ? el.scrollY : 0;
// deltaY>0 (колесо вниз) — увеличиваем сдвиг.
const next = Math.max(0, Math.min(maxScroll, cur + (e.deltaY > 0 ? 4 : -4)));
if (next !== cur) onUpdate?.(el.id, { scrollY: next });
}}
style={{
...style,
transform: playInteractive ? playTransform : style.transform,
filter: playInteractive ? playFilter : 'none',
pointerEvents: isScroll && isPlaying ? 'auto' : pointerEvents,
// scroll-контейнер обрезает детей, выходящих за границу.
overflow: isScroll ? 'hidden' : (style.overflow || 'visible'),
cursor: isPlaying ? (isButton ? 'pointer' : 'default') : (dragging ? 'grabbing' : 'grab'),
outline: selected ? '2px solid #66b04a' : (isPlaying ? 'none' : '1px dashed rgba(150,200,120,0.35)'),
outlineOffset: selected ? 2 : 0,
transition: dragging || resizing ? 'none' : 'outline-color 0.12s, filter 0.1s, transform 0.05s',
userSelect: 'none',
}}
title={isPlaying ? undefined : `${el.name} — перетащи мышью, потяни за ручки чтобы изменить размер`}
>
{isImage && (() => {
// imageAsset (картинка из AssetManager) приоритетнее imageUrl.
const assetUrl = el.imageAsset && resolveAsset ? resolveAsset(el.imageAsset) : null;
const src = assetUrl || el.imageUrl;
if (!src) return null;
return (
{ e.currentTarget.style.opacity = '0.3'; }}
/>
);
})()}
{isText && (el.text != null) && (() => {
// Задача 03: text-stroke (обводка текста). Через -webkit-text-stroke
// (хорошая поддержка, чётко на крупном шрифте) + paint-order
// (stroke под fill чтобы текст не «сжимался»).
const ts = el.textStroke;
const strokeStyle = (ts && ts.color && Number.isFinite(ts.width))
? {
WebkitTextStroke: `${ts.width}px ${ts.color}`,
paintOrder: 'stroke fill',
}
: null;
return (