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)
212 lines
9.1 KiB
JavaScript
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;
|