studio/src/editor/HoverPlusMenu.jsx
МИН 31adbf151b Initial public release: Студия Рублокса v1.0
Open-source веб-студия для создания игр Рублокса, двойная лицензия
AGPL-3.0 + Коммерческая.

Главное:
- Vite 5 + React 18 + Babylon 7.54.3 + Monaco Editor + Colyseus 0.16
- Самодостаточный движок ~28к строк (66 файлов): BlockManager,
  TerrainVoxelBuilder, ModelManager, DecoManager, PlayerController,
  ScriptSandboxWorker, MultiplayerSync, 30+ GD-гейммодов
- Главный редактор KubikonEditor (~37к строк) + панели, ScriptEditor (Monaco)
- Витрина игр (KubikonFeed, KubikonStudio, KubikonDocs, KubikonLearn)
- Geometry Dash sub-app (GdMenu, GdShop, GdRules, GdCoverArt)
- 10 admin-preview каталогов для дизайнеров (скины, музыка, порталы и т.д.)
- Конфигурируемый бэкенд через VITE_API_BASE — работает со staging
  (dev-api.rublox.pro) без настройки
- Standalone-режим (VITE_STANDALONE=true) — открыть пустой редактор без бэка
- Полная документация (на русском): README, ARCHITECTURE, CONTRIBUTING,
  SECURITY, CHANGELOG
- ESLint + Prettier + EditorConfig
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Перед публикацией:
- Все импорты из minecraftia заменены на локальные
- Все хардкоды URL (minecraftia-school.ru) и внутренних IP убраны → env
- Admin-эндпоинты Kubikon3DService вырезаны (остаются в приватном репо)
- AdminKubikonModeration не публикуется (модерация — в team.rublox.pro)
- 93 МБ ассетов public/kubikon-assets вынесены в .gitignore
  (раздаются через release artifact)
2026-05-27 23:41:10 +03:00

212 lines
9.1 KiB
JavaScript

import React, { useState, useEffect, useRef } from 'react';
import Icon from './Icon';
// Хелпер: если icon — строка (эмодзи или name), рендерим Icon. Если JSX — как есть.
const renderItemIcon = (val) => {
if (!val) return null;
if (typeof val === 'string') {
const isAscii = /^[\x20-\x7E_-]+$/.test(val);
return isAscii
? <Icon name={val} size={15} />
: <Icon emoji={val} size={15} />;
}
return val;
};
/**
* HoverPlusMenu — компактная «+»-кнопка справа от элемента иерархии.
*
* Отображается только при hover родителя (`visible` пропс). Клик по «+»
* раскрывает выпадающее меню действий вниз.
*
* Пример использования:
* <div onMouseEnter={...} onMouseLeave={...}>
* ...название...
* <HoverPlusMenu
* visible={hovered}
* items={[
* { id: 'add-script', label: 'Скрипт', icon: '📜', onClick: () => addScript(obj) },
* { id: 'duplicate', label: 'Дублировать', icon: '📋', onClick: ... },
* { divider: true },
* { id: 'delete', label: 'Удалить', icon: '🗑', danger: true, onClick: ... },
* ]}
* />
* </div>
*
* Props:
* visible — если false, кнопка не рендерится (или невидима)
* items — массив пунктов меню
* align — 'right' (default) или 'left'
*/
const HoverPlusMenu = ({ visible, items = [], align = 'right', alwaysVisible = false }) => {
const [open, setOpen] = useState(false);
const wrapperRef = useRef(null);
// Закрытие по клику снаружи
useEffect(() => {
if (!open) return;
const onDoc = (e) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
setOpen(false);
}
};
// setTimeout чтобы текущий клик (по +) не закрыл сразу
const t = setTimeout(() => document.addEventListener('mousedown', onDoc), 0);
return () => {
clearTimeout(t);
document.removeEventListener('mousedown', onDoc);
};
}, [open]);
// Закрытие по Esc
useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open]);
if (!visible && !open && !alwaysVisible) return null;
if (!items || items.length === 0) return null;
return (
<div
ref={wrapperRef}
style={{
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
marginLeft: 'auto',
}}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={(e) => { e.stopPropagation(); setOpen(o => !o); }}
title="Добавить"
style={{
width: 20, height: 20,
borderRadius: 6,
border: 'none',
background: open
? 'linear-gradient(135deg, #4f74ff 0%, #3a57d8 100%)'
: 'rgba(79, 116, 255, 0.16)',
color: open ? '#fff' : '#6d8aff',
fontSize: 14,
fontWeight: 800,
lineHeight: 1,
cursor: 'pointer',
padding: 0,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 200ms cubic-bezier(0.34, 1.56, 0.64, 1)',
flexShrink: 0,
fontFamily: 'inherit',
boxShadow: open ? '0 4px 10px rgba(79, 116, 255, 0.45)' : 'none',
}}
onMouseEnter={(e) => {
if (!open) {
e.currentTarget.style.background = 'rgba(79, 116, 255, 0.26)';
e.currentTarget.style.transform = 'scale(1.08)';
}
}}
onMouseLeave={(e) => {
if (!open) {
e.currentTarget.style.background = 'rgba(79, 116, 255, 0.16)';
e.currentTarget.style.transform = 'scale(1)';
}
}}
>
+
</button>
{open && (
<div
style={{
position: 'absolute',
top: '100%',
marginTop: 6,
[align === 'left' ? 'left' : 'right']: 0,
background: '#252525',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
border: '1px solid #3a3a3a',
borderRadius: 12,
boxShadow: '0 16px 36px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(79, 116, 255, 0.12)',
padding: 6,
minWidth: 180,
zIndex: 200,
animation: 'hpmFadeInScale 180ms cubic-bezier(0.34, 1.56, 0.64, 1)',
transformOrigin: align === 'left' ? 'top left' : 'top right',
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
}}
>
<style>{`
@keyframes hpmFadeInScale {
from { opacity: 0; transform: scale(0.92); }
to { opacity: 1; transform: scale(1); }
}
`}</style>
{items.map((it, i) => {
if (it.divider) {
return <div key={`div-${i}`} style={{
height: 1,
background: 'linear-gradient(90deg, transparent, #3a3a3a 20%, #3a3a3a 80%, transparent)',
margin: '4px 2px',
}} />;
}
return (
<button
key={it.id || i}
onClick={(e) => {
e.stopPropagation();
setOpen(false);
try { it.onClick?.(); } catch (err) { /* ignore */ }
}}
disabled={it.disabled}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
width: '100%',
padding: '8px 12px',
background: 'transparent',
border: 'none',
color: it.danger ? '#ff6b6b' : '#e8e8ea',
fontSize: 13,
fontWeight: 700,
textAlign: 'left',
cursor: it.disabled ? 'not-allowed' : 'pointer',
opacity: it.disabled ? 0.5 : 1,
borderRadius: 8,
transition: 'all 150ms ease',
fontFamily: 'inherit',
}}
onMouseEnter={(e) => {
if (!it.disabled) {
e.currentTarget.style.background = it.danger
? 'rgba(239, 68, 68, 0.16)'
: 'rgba(79, 116, 255, 0.16)';
if (!it.danger) e.currentTarget.style.color = '#6d8aff';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = it.danger ? '#ff6b6b' : '#e8e8ea';
}}
title={it.title || it.label}
>
{it.icon && <span style={{
width: 18, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>{renderItemIcon(it.icon)}</span>}
<span>{it.label}</span>
</button>
);
})}
</div>
)}
</div>
);
};
export default HoverPlusMenu;