Загрузка данных
```html
<!DOCTYPE html>
<html lang="ru" class="h-full bg-black">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>ArtStudio — Профессиональный Графический Редактор</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- FontAwesome Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Montserrat:wght@400;700;900&family=Playfair+Display:ital,wght@0,600;1,400&family=Roboto+Mono:wght@400;700&family=Pacifico&display=swap" rel="stylesheet">
<style>
* {
-webkit-tap-highlight-color: transparent;
user-select: none;
-webkit-user-select: none;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Inter", sans-serif;
background-color: #000000;
color: #f5f5f7;
overflow: hidden;
}
::-webkit-scrollbar {
width: 4px;
height: 4px;
}
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); border-radius: 999px; }
.glass-card {
background: rgba(24, 24, 27, 0.75);
backdrop-filter: blur(30px);
-webkit-backdrop-filter: blur(30px);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.glass-nav {
background: rgba(18, 18, 20, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.bg-checkered {
background-color: #0c0c0e;
background-image:
linear-gradient(45deg, #18181b 25%, transparent 25%),
linear-gradient(-45deg, #18181b 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #18181b 75%),
linear-gradient(-45deg, transparent 75%, #18181b 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
}
input[type=range] {
-webkit-appearance: none;
background: rgba(255, 255, 255, 0.15);
border-radius: 999px;
height: 4px;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 2px 8px rgba(0,0,0,0.5);
cursor: pointer;
border: 1px solid rgba(0,0,0,0.1);
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.98); }
to { opacity: 1; transform: scale(1); }
}
.animate-fade-in {
animation: fadeIn 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* SVG Aperture Logo Pulsate Glow */
.logo-aperture {
filter: drop-shadow(0 0 10px rgba(255, 255, 255, 0.2));
transition: transform 0.3s ease;
}
.logo-aperture:hover {
transform: rotate(30deg) scale(1.05);
}
</style>
</head>
<body class="h-full flex flex-col justify-between select-none">
<!-- APP TOP HEADER / NAV BAR -->
<header class="h-14 px-4 sm:px-6 flex items-center justify-between glass-nav z-40 shrink-0">
<!-- Logo & Title -->
<div class="flex items-center space-x-3 cursor-pointer" id="btn-home-logo">
<svg class="w-8 h-8 logo-aperture" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="46" stroke="rgba(255,255,255,0.2)" stroke-width="2"/>
<path d="M50 10 C65 25, 65 40, 50 50 C35 60, 20 50, 10 50 C20 30, 35 10, 50 10 Z" fill="rgba(255,255,255,0.15)" stroke="rgba(255,255,255,0.7)" stroke-width="1.5"/>
<path d="M90 50 C70 65, 55 65, 50 50 C45 35, 55 20, 50 10 C70 20, 90 35, 90 50 Z" fill="rgba(255,255,255,0.15)" stroke="rgba(255,255,255,0.7)" stroke-width="1.5"/>
<path d="M50 90 C35 75, 35 60, 50 50 C65 40, 80 50, 90 50 C80 70, 65 90, 50 90 Z" fill="rgba(255,255,255,0.15)" stroke="rgba(255,255,255,0.7)" stroke-width="1.5"/>
<path d="M10 50 C30 35, 45 35, 50 50 C55 65, 45 80, 50 90 C30 80, 10 65, 10 50 Z" fill="rgba(255,255,255,0.15)" stroke="rgba(255,255,255,0.7)" stroke-width="1.5"/>
<circle cx="50" cy="50" r="14" fill="#000000" stroke="rgba(255,255,255,0.9)" stroke-width="1.5"/>
</svg>
<div>
<h1 class="text-sm font-bold tracking-wider uppercase text-white/90">ArtStudio</h1>
<p id="top-status-text" class="text-[10px] text-white/40">Главное меню</p>
</div>
</div>
<!-- Desktop Navigation Tabs -->
<div id="main-nav-tabs" class="hidden md:flex items-center bg-white/5 p-1 rounded-full border border-white/10 space-x-1">
<button data-view="home" class="view-tab-btn active px-4 py-1.5 rounded-full text-xs font-medium text-white bg-white/10 transition">Главная</button>
<button data-view="projects" class="view-tab-btn px-4 py-1.5 rounded-full text-xs font-medium text-white/60 hover:text-white transition">Мои работы</button>
<button data-view="settings" class="view-tab-btn px-4 py-1.5 rounded-full text-xs font-medium text-white/60 hover:text-white transition">Настройки</button>
</div>
<!-- Top Right Controls -->
<div class="flex items-center space-x-2">
<!-- Audio Lo-Fi Toggle Button -->
<button id="btn-lofi-toggle" title="Lo-Fi Музыка" class="px-3 py-1.5 rounded-full bg-white/5 hover:bg-white/10 text-xs text-white/80 border border-white/10 flex items-center space-x-2 transition">
<i class="fa-solid fa-music text-purple-400"></i>
<span class="hidden sm:inline text-[11px]">Lo-Fi Chill</span>
</button>
<!-- Editor-Only Quick Controls -->
<div id="editor-top-controls" class="hidden flex items-center space-x-2">
<button id="btn-undo" title="Отменить (Ctrl+Z)" class="w-8 h-8 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 text-white/80 transition">
<i class="fa-solid fa-rotate-left text-xs"></i>
</button>
<button id="btn-redo" title="Повторить" class="w-8 h-8 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 text-white/80 transition">
<i class="fa-solid fa-rotate-right text-xs"></i>
</button>
<button id="btn-search-trigger" title="Поиск инструментов" class="w-8 h-8 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 text-white/80 transition">
<i class="fa-solid fa-magnifying-glass text-xs"></i>
</button>
<button id="btn-save-project" title="Сохранить проект" class="px-3 py-1.5 rounded-full bg-white/10 hover:bg-white/20 text-white text-xs font-medium border border-white/15 transition">
<i class="fa-solid fa-bookmark mr-1"></i> Сохранить
</button>
<button id="btn-export-modal" class="px-3.5 py-1.5 rounded-full bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-lg shadow-blue-600/30 transition">
Экспорт
</button>
</div>
</div>
</header>
<!-- VIEW 1: HOME DASHBOARD / MAIN MENU -->
<div id="view-home" class="app-view flex-1 overflow-y-auto p-4 sm:p-8 max-w-6xl mx-auto w-full animate-fade-in">
<!-- Hero Section -->
<div class="glass-card rounded-3xl p-6 sm:p-10 mb-8 border border-white/10 relative overflow-hidden">
<div class="absolute -right-10 -bottom-10 w-72 h-72 bg-blue-600/10 rounded-full blur-3xl pointer-events-none"></div>
<div class="max-w-xl">
<span class="px-3 py-1 rounded-full bg-white/10 text-white/70 text-[11px] font-semibold tracking-wider uppercase border border-white/10">ArtStudio Pro v2.0</span>
<h2 class="text-3xl sm:text-5xl font-extrabold text-white mt-4 tracking-tight leading-tight">Создавайте шедевры без границ.</h2>
<p class="text-sm sm:text-base text-white/60 mt-3 leading-relaxed">
Минималистичный графический редактор в стиле Apple. Поддержка слоев, удаление фона, CapCut эффекты, 3D наклоны и рендер анимаций.
</p>
<div class="mt-6 flex flex-wrap gap-3">
<button id="btn-create-new-project" class="px-6 py-3 rounded-2xl bg-white text-black font-semibold text-sm hover:bg-white/90 active:scale-95 shadow-xl transition flex items-center space-x-2">
<i class="fa-solid fa-plus text-xs"></i>
<span>Создать новый проект</span>
</button>
<button id="btn-open-my-works" class="px-6 py-3 rounded-2xl bg-white/10 hover:bg-white/15 text-white font-medium text-sm border border-white/10 active:scale-95 transition">
Мои работы
</button>
</div>
</div>
</div>
<!-- Presets Grid -->
<div class="mb-8">
<h3 class="text-xs font-semibold text-white/50 uppercase tracking-wider mb-4">Быстрый старт с пресетов</h3>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div onclick="App.createNewProjectWithDimensions(1080, 1920, 'Story / Mobile')" class="glass-card p-5 rounded-2xl cursor-pointer hover:border-blue-500/50 hover:bg-white/10 transition group">
<i class="fa-solid fa-mobile-screen text-2xl text-blue-400 mb-3 group-hover:scale-110 transition-transform"></i>
<h4 class="text-sm font-semibold text-white">Mobile / Stories</h4>
<p class="text-xs text-white/40 mt-1">9:16 (1080 × 1920)</p>
</div>
<div onclick="App.createNewProjectWithDimensions(1080, 1080, 'Квадрат')" class="glass-card p-5 rounded-2xl cursor-pointer hover:border-purple-500/50 hover:bg-white/10 transition group">
<i class="fa-solid fa-square text-2xl text-purple-400 mb-3 group-hover:scale-110 transition-transform"></i>
<h4 class="text-sm font-semibold text-white">Квадрат</h4>
<p class="text-xs text-white/40 mt-1">1:1 (1080 × 1080)</p>
</div>
<div onclick="App.createNewProjectWithDimensions(1920, 1080, 'Desktop 16:9')" class="glass-card p-5 rounded-2xl cursor-pointer hover:border-emerald-500/50 hover:bg-white/10 transition group">
<i class="fa-solid fa-desktop text-2xl text-emerald-400 mb-3 group-hover:scale-110 transition-transform"></i>
<h4 class="text-sm font-semibold text-white">ПК / Landscape</h4>
<p class="text-xs text-white/40 mt-1">16:9 (1920 × 1080)</p>
</div>
<div onclick="App.openCustomProjectModal()" class="glass-card p-5 rounded-2xl cursor-pointer hover:border-amber-500/50 hover:bg-white/10 transition group flex flex-col justify-between">
<i class="fa-solid fa-sliders text-2xl text-amber-400 mb-3 group-hover:scale-110 transition-transform"></i>
<div>
<h4 class="text-sm font-semibold text-white">Свой размер</h4>
<p class="text-xs text-white/40 mt-1">Настроить px</p>
</div>
</div>
</div>
</div>
</div>
<!-- VIEW 2: MY WORKS / SAVED PROJECTS GALLERY -->
<div id="view-projects" class="app-view hidden flex-1 overflow-y-auto p-4 sm:p-8 max-w-6xl mx-auto w-full animate-fade-in">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-2xl font-bold text-white">Мои работы</h2>
<p class="text-xs text-white/50 mt-1">Сохранённые локальные черновики</p>
</div>
<button onclick="App.openTab('home')" class="text-xs text-white/60 hover:text-white">Назад на Главную</button>
</div>
<div id="projects-gallery-grid" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
<!-- Saved projects rendered dynamically -->
</div>
</div>
<!-- VIEW 3: SETTINGS VIEW -->
<div id="view-settings" class="app-view hidden flex-1 overflow-y-auto p-4 sm:p-8 max-w-3xl mx-auto w-full animate-fade-in">
<h2 class="text-2xl font-bold text-white mb-6">Настройки приложения</h2>
<div class="space-y-4">
<div class="glass-card p-5 rounded-2xl flex items-center justify-between">
<div>
<h4 class="text-sm font-semibold text-white">Звуки интерфейса (UI Sounds)</h4>
<p class="text-xs text-white/40 mt-0.5">Тихие приятные клики в стиле Apple</p>
</div>
<input type="checkbox" id="setting-sound-toggle" checked class="w-5 h-5 accent-blue-500 cursor-pointer">
</div>
<div class="glass-card p-5 rounded-2xl flex items-center justify-between">
<div>
<h4 class="text-sm font-semibold text-white">Lo-Fi Фоновая Музыка</h4>
<p class="text-xs text-white/40 mt-0.5">Расслабляющие генеративные синтезаторы</p>
</div>
<input type="checkbox" id="setting-lofi-toggle" class="w-5 h-5 accent-purple-500 cursor-pointer">
</div>
<div class="glass-card p-5 rounded-2xl space-y-3">
<h4 class="text-sm font-semibold text-white">Громкость звука</h4>
<input type="range" id="setting-volume-slider" min="0" max="1" step="0.05" value="0.4" class="w-full">
</div>
<div class="glass-card p-5 rounded-2xl flex items-center justify-between">
<div>
<h4 class="text-sm font-semibold text-white">Автосохранение проекта</h4>
<p class="text-xs text-white/40 mt-0.5">Автоматически сохранять изменения каждые 30 сек</p>
</div>
<input type="checkbox" id="setting-autosave-toggle" checked class="w-5 h-5 accent-blue-500 cursor-pointer">
</div>
<div class="glass-card p-5 rounded-2xl flex items-center justify-between border border-red-500/20">
<div>
<h4 class="text-sm font-semibold text-red-400">Очистить кэш проектов</h4>
<p class="text-xs text-white/40 mt-0.5">Удалит все сохранённые проекты из браузера</p>
</div>
<button onclick="App.clearAllProjects()" class="px-4 py-2 bg-red-600/20 border border-red-500/40 text-red-300 rounded-xl text-xs hover:bg-red-600/40 transition">
Очистить
</button>
</div>
</div>
</div>
<!-- VIEW 4: STUDIO EDITOR WORKSPACE (PC & Mobile Adaptive Studio) -->
<div id="view-editor" class="app-view hidden flex-1 flex flex-col md:flex-row overflow-hidden relative">
<!-- PC LEFT TOOLBAR / MOBILE FLOATING PANEL -->
<aside class="hidden md:flex flex-col w-64 glass-nav border-r border-white/10 p-4 space-y-4 shrink-0 overflow-y-auto">
<h3 class="text-xs font-semibold text-white/50 uppercase tracking-wider">Инструменты</h3>
<div class="space-y-1">
<button onclick="App.openEditorSubTab('canvas')" class="editor-side-btn w-full px-3 py-2.5 rounded-xl text-xs text-left text-white/80 hover:bg-white/10 flex items-center space-x-3 transition">
<i class="fa-solid fa-ruler-combined text-blue-400 w-5"></i>
<span>Холст и Размер</span>
</button>
<button onclick="App.openEditorSubTab('layers')" class="editor-side-btn w-full px-3 py-2.5 rounded-xl text-xs text-left text-white/80 hover:bg-white/10 flex items-center space-x-3 transition">
<i class="fa-solid fa-layer-group text-purple-400 w-5"></i>
<span>Управление Слоями</span>
</button>
<button onclick="App.openEditorSubTab('add')" class="editor-side-btn w-full px-3 py-2.5 rounded-xl text-xs text-left text-white/80 hover:bg-white/10 flex items-center space-x-3 transition">
<i class="fa-solid fa-circle-plus text-emerald-400 w-5"></i>
<span>Добавить Изображения / Текст</span>
</button>
<button onclick="App.openEditorSubTab('adjust')" class="editor-side-btn w-full px-3 py-2.5 rounded-xl text-xs text-left text-white/80 hover:bg-white/10 flex items-center space-x-3 transition">
<i class="fa-solid fa-sliders text-amber-400 w-5"></i>
<span>Цветокоррекция</span>
</button>
<button onclick="App.openEditorSubTab('fx')" class="editor-side-btn w-full px-3 py-2.5 rounded-xl text-xs text-left text-white/80 hover:bg-white/10 flex items-center space-x-3 transition">
<i class="fa-solid fa-wand-magic-sparkles text-pink-400 w-5"></i>
<span>CapCut Эффекты & 3D</span>
</button>
</div>
<!-- Left Panel Dynamic Inspector Content -->
<div class="border-t border-white/10 pt-4 flex-1 overflow-y-auto">
<div id="desktop-inspector-content" class="space-y-4">
<!-- Dynamic controls injected here for PC -->
</div>
</div>
</aside>
<!-- MAIN CANVAS WORKSPACE -->
<main id="workspace-container" class="relative flex-1 bg-checkered overflow-hidden flex items-center justify-center p-4">
<div id="canvas-wrapper" class="relative shadow-2xl transition-transform duration-75 origin-center">
<canvas id="main-canvas" class="block w-full h-full rounded-sm"></canvas>
</div>
<!-- Viewport Reset Overlay -->
<div class="absolute bottom-4 left-4 z-20 flex space-x-2">
<button id="btn-zoom-fit" class="px-3 py-1.5 glass-card rounded-full text-[11px] text-white/70 hover:text-white backdrop-blur-md active:scale-95 transition">
<i class="fa-solid fa-expand mr-1"></i> Сбросить зум
</button>
<button id="btn-toggle-grid" class="w-8 h-8 glass-card rounded-full flex items-center justify-center text-white/70 hover:text-white backdrop-blur-md active:scale-95 transition">
<i class="fa-solid fa-border-top-left"></i>
</button>
</div>
</main>
<!-- MOBILE FLOATING TOOL PARAMETERS SUB-PANEL -->
<div id="sub-panel" class="md:hidden glass-card border-b-0 rounded-t-3xl p-4 transition-all duration-300 transform translate-y-full fixed bottom-16 inset-x-0 z-30 max-h-[55vh] overflow-y-auto">
<div class="flex justify-between items-center mb-3 pb-2 border-b border-white/10">
<h3 id="sub-panel-title" class="text-xs font-semibold tracking-wider text-white/80 uppercase">Настройки</h3>
<button id="btn-close-subpanel" class="w-6 h-6 flex items-center justify-center rounded-full bg-white/10 text-white/60 text-xs">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div id="sub-panel-content" class="space-y-4">
<!-- Dynamic Controls Injected Here for Mobile -->
</div>
</div>
<!-- MOBILE BOTTOM TABS NAVIGATION -->
<nav class="md:hidden h-16 glass-nav flex items-center justify-around px-2 z-30 shrink-0 border-t border-white/10">
<button data-editor-tab="canvas" class="editor-mobile-tab flex flex-col items-center text-white/60 hover:text-white transition py-1 px-2">
<i class="fa-solid fa-ruler-combined text-base mb-1"></i>
<span class="text-[10px]">Холст</span>
</button>
<button data-editor-tab="layers" class="editor-mobile-tab active flex flex-col items-center text-white/60 hover:text-white transition py-1 px-2">
<i class="fa-solid fa-layer-group text-base mb-1"></i>
<span class="text-[10px]">Слои</span>
</button>
<button data-editor-tab="add" class="editor-mobile-tab flex flex-col items-center text-white/60 hover:text-white transition py-1 px-2">
<i class="fa-solid fa-circle-plus text-lg text-blue-500 mb-1"></i>
<span class="text-[10px]">Добавить</span>
</button>
<button data-editor-tab="adjust" class="editor-mobile-tab flex flex-col items-center text-white/60 hover:text-white transition py-1 px-2">
<i class="fa-solid fa-sliders text-base mb-1"></i>
<span class="text-[10px]">Коррекция</span>
</button>
<button data-editor-tab="fx" class="editor-mobile-tab flex flex-col items-center text-white/60 hover:text-white transition py-1 px-2">
<i class="fa-solid fa-wand-magic-sparkles text-base mb-1"></i>
<span class="text-[10px]">Эффекты</span>
</button>
</nav>
</div>
<!-- MOBILE BOTTOM NAVIGATION FOR MAIN MENU -->
<nav id="mobile-main-nav" class="md:hidden h-14 glass-nav flex items-center justify-around px-2 z-40 shrink-0 border-t border-white/10">
<button data-view="home" class="view-tab-btn flex flex-col items-center text-white/60 hover:text-white transition">
<i class="fa-solid fa-house text-sm mb-0.5"></i>
<span class="text-[10px]">Главное</span>
</button>
<button data-view="projects" class="view-tab-btn flex flex-col items-center text-white/60 hover:text-white transition">
<i class="fa-solid fa-folder text-sm mb-0.5"></i>
<span class="text-[10px]">Работы</span>
</button>
<button data-view="settings" class="view-tab-btn flex flex-col items-center text-white/60 hover:text-white transition">
<i class="fa-solid fa-gear text-sm mb-0.5"></i>
<span class="text-[10px]">Настройки</span>
</button>
</nav>
<!-- SEARCH & COMMAND PALETTE MODAL -->
<div id="search-modal" class="hidden fixed inset-0 z-50 bg-black/70 backdrop-blur-md p-4 flex flex-col items-center pt-16">
<div class="w-full max-w-md glass-card rounded-2xl p-3 shadow-2xl animate-fade-in">
<div class="flex items-center px-3 py-2 bg-white/5 rounded-xl border border-white/10 mb-3">
<i class="fa-solid fa-magnifying-glass text-white/40 mr-2 text-sm"></i>
<input type="text" id="search-input" placeholder="Поиск инструментов (текст, блеск, 3D, удаление фона)..." class="bg-transparent text-sm text-white focus:outline-none w-full placeholder-white/40">
<button id="btn-close-search" class="text-xs text-white/40 hover:text-white ml-2">Отмена</button>
</div>
<div id="search-results" class="max-h-64 overflow-y-auto space-y-1"></div>
</div>
</div>
<!-- NEW CUSTOM PROJECT DIMENSIONS MODAL -->
<div id="custom-project-modal" class="hidden fixed inset-0 z-50 bg-black/80 backdrop-blur-xl p-4 flex flex-col items-center justify-center">
<div class="w-full max-w-sm glass-card rounded-3xl p-6 text-center space-y-4 animate-fade-in">
<h3 class="text-base font-semibold text-white">Новый проект</h3>
<p class="text-xs text-white/50">Укажите произвольный размер холста</p>
<div class="grid grid-cols-2 gap-3 text-left">
<div>
<label class="text-[11px] text-white/60">Ширина (px)</label>
<input type="number" id="custom-width" value="1080" class="w-full mt-1 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white">
</div>
<div>
<label class="text-[11px] text-white/60">Высота (px)</label>
<input type="number" id="custom-height" value="1080" class="w-full mt-1 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white">
</div>
</div>
<div class="flex space-x-3 pt-2">
<button onclick="document.getElementById('custom-project-modal').classList.add('hidden')" class="flex-1 py-2.5 rounded-xl bg-white/10 text-white text-xs font-medium hover:bg-white/15 transition">Отмена</button>
<button id="btn-create-custom-project" class="flex-1 py-2.5 rounded-xl bg-blue-600 text-white text-xs font-semibold hover:bg-blue-500 shadow-lg shadow-blue-600/30 transition">Создать</button>
</div>
</div>
</div>
<!-- EXPORT MODAL -->
<div id="export-modal" class="hidden fixed inset-0 z-50 bg-black/80 backdrop-blur-xl p-4 flex flex-col items-center justify-center">
<div class="w-full max-w-sm glass-card rounded-3xl p-6 text-center space-y-5 animate-fade-in">
<div class="w-12 h-12 rounded-full bg-blue-600/20 text-blue-400 flex items-center justify-center mx-auto text-xl">
<i class="fa-solid fa-download"></i>
</div>
<div>
<h3 class="text-base font-semibold text-white">Экспорт ArtStudio</h3>
<p class="text-xs text-white/50 mt-1">Выберите формат для сохранения</p>
</div>
<div class="space-y-3 text-left">
<div>
<label class="text-[11px] text-white/60 font-medium">Формат файла</label>
<select id="export-format" class="w-full mt-1 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none">
<option value="png" class="bg-zinc-900">PNG (Прозрачность)</option>
<option value="jpeg" class="bg-zinc-900">JPEG (Стандарт)</option>
<option value="webm" class="bg-zinc-900">WebM Анимация (Видео)</option>
</select>
</div>
<div id="export-fps-container" class="hidden">
<label class="text-[11px] text-white/60 font-medium">Длительность видео (сек)</label>
<input type="number" id="export-duration" min="1" max="10" value="3" class="w-full mt-1 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white">
</div>
</div>
<div class="flex space-x-3 pt-2">
<button id="btn-cancel-export" class="flex-1 py-2.5 rounded-xl bg-white/10 text-white text-xs font-medium hover:bg-white/15 transition">Отмена</button>
<button id="btn-do-export" class="flex-1 py-2.5 rounded-xl bg-blue-600 text-white text-xs font-semibold hover:bg-blue-500 shadow-lg shadow-blue-600/30 transition">Сохранить</button>
</div>
</div>
</div>
<!-- CORE JAVASCRIPT ENGINE -->
<script>
/**
* Web Audio Synthesizer & Lo-Fi Chill Ambience Engine
*/
const AudioEngine = {
ctx: null,
volume: 0.4,
soundEnabled: true,
lofiPlaying: false,
lofiInterval: null,
init() {
try {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
this.ctx = new AudioCtx();
} catch (e) {
console.log('Audio not supported');
}
},
playClick() {
if (!this.soundEnabled || !this.ctx) return;
if (this.ctx.state === 'suspended') this.ctx.resume();
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(600, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(200, this.ctx.currentTime + 0.04);
gain.gain.setValueAtTime(this.volume * 0.15, this.ctx.currentTime);
gain.gain.linearRampToValueAtTime(0, this.ctx.currentTime + 0.04);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + 0.04);
},
toggleLofi() {
if (!this.ctx) this.init();
if (this.ctx.state === 'suspended') this.ctx.resume();
this.lofiPlaying = !this.lofiPlaying;
const btn = document.getElementById('btn-lofi-toggle');
if (this.lofiPlaying) {
btn.classList.add('bg-purple-600/30', 'border-purple-500/50');
this.startLofiGenerator();
} else {
btn.classList.remove('bg-purple-600/30', 'border-purple-500/50');
this.stopLofiGenerator();
}
},
startLofiGenerator() {
const chords = [
[261.63, 329.63, 392.00, 493.88], // Cmaj7
[220.00, 261.63, 329.63, 392.00], // Am7
[174.61, 220.00, 261.63, 329.63], // Fmaj7
[196.00, 246.94, 293.66, 349.23] // G7
];
let chordIdx = 0;
const playChord = () => {
if (!this.lofiPlaying) return;
const notes = chords[chordIdx];
chordIdx = (chordIdx + 1) % chords.length;
notes.forEach(freq => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
gain.gain.setValueAtTime(0, this.ctx.currentTime);
gain.gain.linearRampToValueAtTime(this.volume * 0.05, this.ctx.currentTime + 1);
gain.gain.linearRampToValueAtTime(0, this.ctx.currentTime + 3.8);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + 4);
});
};
playChord();
this.lofiInterval = setInterval(playChord, 4000);
},
stopLofiGenerator() {
if (this.lofiInterval) clearInterval(this.lofiInterval);
}
};
/**
* ArtStudio - Main Application Engine
*/
const App = {
currentView: 'home', // 'home', 'projects', 'settings', 'editor'
activeEditorTab: 'layers',
// Canvas & Project
currentProjectName: 'Новый проект',
canvas: null,
ctx: null,
width: 1080,
height: 1920,
zoom: 1,
pan: { x: 0, y: 0 },
showGrid: false,
// Layers
layers: [],
selectedLayerId: null,
nextLayerId: 1,
// History
history: [],
historyIndex: -1,
maxHistory: 20,
// Render loop
time: 0,
// Fonts & Assets
customFonts: ["Inter", "Montserrat", "Playfair Display", "Roboto Mono", "Pacifico"],
stockOverlays: [
{ name: "Световой блик", type: "glow", color: "#0A84FF" },
{ name: "Неоновая стрелка", type: "arrow", color: "#FF3B30" },
{ name: "Пыль и Частицы", type: "dust", color: "#FFFFFF" },
{ name: "Золотое свечение", type: "glow", color: "#FFD60A" },
{ name: "Виньетка", type: "vignette", color: "#000000" }
],
init() {
this.canvas = document.getElementById('main-canvas');
this.ctx = this.canvas.getContext('2d');
this.setupNavigation();
this.setupEventListeners();
this.setupTouchPinchZoom();
this.renderLoop();
AudioEngine.init();
this.renderSavedProjectsGallery();
},
// --- VIEW SWITCHING ---
openTab(viewName) {
AudioEngine.playClick();
this.currentView = viewName;
document.querySelectorAll('.app-view').forEach(el => el.classList.add('hidden'));
document.querySelectorAll('.view-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === viewName);
btn.classList.toggle('text-white', btn.dataset.view === viewName);
btn.classList.toggle('text-white/60', btn.dataset.view !== viewName);
});
const topControls = document.getElementById('editor-top-controls');
const mainNav = id => document.getElementById(id);
if (viewName === 'editor') {
mainNav('view-editor').classList.remove('hidden');
topControls.classList.remove('hidden');
mainNav('mobile-main-nav').classList.add('hidden');
mainNav('top-status-text').innerText = `${this.currentProjectName} (${this.width}×${this.height}px)`;
this.fitZoomToScreen();
} else {
topControls.classList.add('hidden');
mainNav('mobile-main-nav').classList.remove('hidden');
if (viewName === 'home') {
mainNav('view-home').classList.remove('hidden');
mainNav('top-status-text').innerText = 'Главное меню';
} else if (viewName === 'projects') {
mainNav('view-projects').classList.remove('hidden');
mainNav('top-status-text').innerText = 'Мои работы';
this.renderSavedProjectsGallery();
} else if (viewName === 'settings') {
mainNav('view-settings').classList.remove('hidden');
mainNav('top-status-text').innerText = 'Настройки';
}
}
},
// --- PROJECT CREATION ---
createNewProjectWithDimensions(w, h, name = 'Черновик') {
this.width = w;
this.height = h;
this.currentProjectName = name;
this.canvas.width = w;
this.canvas.height = h;
const wrapper = document.getElementById('canvas-wrapper');
wrapper.style.width = `${w}px`;
wrapper.style.height = `${h}px`;
this.layers = [];
this.history = [];
this.historyIndex = -1;
this.addSolidColorLayer('#121214', 'Фон');
this.addTextLayer('ArtStudio', 'Inter', 110, '#FFFFFF');
this.openTab('editor');
this.openEditorSubTab('layers');
this.saveHistory();
},
openCustomProjectModal() {
document.getElementById('custom-project-modal').classList.remove('hidden');
},
// --- CANVAS & TRANSFORM ---
fitZoomToScreen() {
const container = document.getElementById('workspace-container');
const padding = 40;
const availableW = container.clientWidth - padding;
const availableH = container.clientHeight - padding;
const scaleW = availableW / this.width;
const scaleH = availableH / this.height;
this.zoom = Math.min(scaleW, scaleH, 1);
this.pan = { x: 0, y: 0 };
this.updateCanvasTransform();
},
updateCanvasTransform() {
const wrapper = document.getElementById('canvas-wrapper');
wrapper.style.transform = `translate(${this.pan.x}px, ${this.pan.y}px) scale(${this.zoom})`;
},
// --- LAYER SYSTEM ---
createBaseLayer(type, name) {
return {
id: this.nextLayerId++,
type: type,
name: name || `Слой ${this.nextLayerId}`,
visible: true,
locked: false,
opacity: 1.0,
blendMode: 'source-over',
x: this.width / 2,
y: this.height / 2,
scaleX: 1,
scaleY: 1,
rotation: 0,
tiltX: 0,
tiltY: 0,
brightness: 100,
contrast: 100,
saturation: 100,
blur: 0,
hue: 0,
invert: 0,
edgeGlow: false,
edgeGlowColor: '#0A84FF',
edgeGlowSize: 20,
outerGlow: false,
outerGlowColor: '#FF375F',
outerGlowBlur: 30,
animation: 'none',
animSpeed: 1,
chromaKey: false,
chromaColor: '#00FF00',
chromaTolerance: 40
};
},
addSolidColorLayer(color = '#1c1c1e', name = 'Фон цветной') {
const layer = this.createBaseLayer('solid', name);
layer.color = color;
this.layers.push(layer);
this.selectedLayerId = layer.id;
this.saveHistory();
this.requestRender();
this.refreshEditorInspector();
},
addTextLayer(text = 'ArtStudio', font = 'Inter', size = 90, color = '#FFFFFF') {
const layer = this.createBaseLayer('text', 'Текст');
layer.text = text;
layer.fontFamily = font;
layer.fontSize = size;
layer.color = color;
layer.useGradient = false;
layer.gradientColor2 = '#0A84FF';
this.layers.push(layer);
this.selectedLayerId = layer.id;
this.saveHistory();
this.requestRender();
this.refreshEditorInspector();
},
addImageLayer(imgElement, name = 'Изображение') {
const layer = this.createBaseLayer('image', name);
layer.image = imgElement;
layer.width = imgElement.width;
layer.height = imgElement.height;
const maxDim = Math.min(this.width * 0.8, this.height * 0.8);
const scale = Math.min(maxDim / layer.width, maxDim / layer.height);
layer.scaleX = scale;
layer.scaleY = scale;
this.layers.push(layer);
this.selectedLayerId = layer.id;
this.saveHistory();
this.requestRender();
this.refreshEditorInspector();
},
addStockOverlay(overlayItem) {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = 600;
tempCanvas.height = 600;
const tCtx = tempCanvas.getContext('2d');
if (overlayItem.type === 'glow') {
const radG = tCtx.createRadialGradient(300, 300, 10, 300, 300, 280);
radG.addColorStop(0, overlayItem.color);
radG.addColorStop(1, 'transparent');
tCtx.fillStyle = radG;
tCtx.fillRect(0, 0, 600, 600);
} else if (overlayItem.type === 'dust') {
tCtx.fillStyle = '#FFFFFF';
for (let i = 0; i < 150; i++) {
tCtx.globalAlpha = Math.random() * 0.7;
tCtx.beginPath();
tCtx.arc(Math.random() * 600, Math.random() * 600, Math.random() * 3, 0, Math.PI * 2);
tCtx.fill();
}
} else if (overlayItem.type === 'arrow') {
tCtx.strokeStyle = overlayItem.color;
tCtx.lineWidth = 12;
tCtx.lineCap = 'round';
tCtx.beginPath();
tCtx.moveTo(100, 300);
tCtx.lineTo(450, 300);
tCtx.lineTo(350, 200);
tCtx.moveTo(450, 300);
tCtx.lineTo(350, 400);
tCtx.stroke();
} else if (overlayItem.type === 'vignette') {
const g = tCtx.createRadialGradient(300, 300, 150, 300, 300, 300);
g.addColorStop(0, 'transparent');
g.addColorStop(1, 'rgba(0,0,0,0.85)');
tCtx.fillStyle = g;
tCtx.fillRect(0, 0, 600, 600);
}
const img = new Image();
img.src = tempCanvas.toDataURL();
img.onload = () => this.addImageLayer(img, overlayItem.name);
},
getSelectedLayer() {
return this.layers.find(l => l.id === this.selectedLayerId);
},
deleteLayer(id) {
AudioEngine.playClick();
this.layers = this.layers.filter(l => l.id !== id);
if (this.selectedLayerId === id) {
this.selectedLayerId = this.layers.length ? this.layers[this.layers.length - 1].id : null;
}
this.saveHistory();
this.requestRender();
this.refreshEditorInspector();
},
moveLayer(id, direction) {
AudioEngine.playClick();
const idx = this.layers.findIndex(l => l.id === id);
if (idx < 0) return;
if (direction === 'up' && idx < this.layers.length - 1) {
const temp = this.layers[idx];
this.layers[idx] = this.layers[idx + 1];
this.layers[idx + 1] = temp;
} else if (direction === 'down' && idx > 0) {
const temp = this.layers[idx];
this.layers[idx] = this.layers[idx - 1];
this.layers[idx - 1] = temp;
}
this.saveHistory();
this.requestRender();
this.refreshEditorInspector();
},
duplicateLayer(id) {
AudioEngine.playClick();
const layer = this.layers.find(l => l.id === id);
if (!layer) return;
const clone = JSON.parse(JSON.stringify(layer));
clone.id = this.nextLayerId++;
clone.name = `${layer.name} (Копия)`;
clone.x += 30;
clone.y += 30;
if (layer.type === 'image' && layer.image) {
clone.image = layer.image;
}
this.layers.push(clone);
this.selectedLayerId = clone.id;
this.saveHistory();
this.requestRender();
this.refreshEditorInspector();
},
// --- CHROMA KEY / BACKGROUND REMOVAL ENGINE ---
applyChromaKey(layer) {
if (layer.type !== 'image' || !layer.image) return;
const tempC = document.createElement('canvas');
tempC.width = layer.image.width;
tempC.height = layer.image.height;
const tCtx = tempC.getContext('2d');
tCtx.drawImage(layer.image, 0, 0);
const imgData = tCtx.getImageData(0, 0, tempC.width, tempC.height);
const data = imgData.data;
const hex = layer.chromaColor.replace('#', '');
const targetR = parseInt(hex.substring(0, 2), 16);
const targetG = parseInt(hex.substring(2, 4), 16);
const targetB = parseInt(hex.substring(4, 6), 16);
const tolerance = layer.chromaTolerance;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const dist = Math.sqrt((r - targetR) ** 2 + (g - targetG) ** 2 + (b - targetB) ** 2);
if (dist < tolerance) {
data[i + 3] = 0;
}
}
tCtx.putImageData(imgData, 0, 0);
const processedImg = new Image();
processedImg.src = tempC.toDataURL();
processedImg.onload = () => {
layer.image = processedImg;
this.requestRender();
};
},
// --- HISTORY (UNDO/REDO) ---
saveHistory() {
if (this.historyIndex < this.history.length - 1) {
this.history = this.history.slice(0, this.historyIndex + 1);
}
const serialized = this.layers.map(l => {
const c = { ...l };
if (l.type === 'image' && l.image) c._imgSrc = l.image.src;
return c;
});
this.history.push(JSON.stringify(serialized));
if (this.history.length > this.maxHistory) this.history.shift();
else this.historyIndex++;
},
undo() {
AudioEngine.playClick();
if (this.historyIndex > 0) {
this.historyIndex--;
this.restoreHistoryState(this.history[this.historyIndex]);
}
},
redo() {
AudioEngine.playClick();
if (this.historyIndex < this.history.length - 1) {
this.historyIndex++;
this.restoreHistoryState(this.history[this.historyIndex]);
}
},
restoreHistoryState(jsonState) {
const parsed = JSON.parse(jsonState);
this.layers = parsed.map(l => {
if (l.type === 'image' && l._imgSrc) {
const img = new Image();
img.src = l._imgSrc;
l.image = img;
}
return l;
});
this.requestRender();
this.refreshEditorInspector();
},
// --- LOCAL STORAGE PROJECTS GALLERY ---
saveCurrentProjectToGallery() {
const thumbData = this.canvas.toDataURL('image/jpeg', 0.5);
const projectObj = {
id: Date.now(),
name: this.currentProjectName,
width: this.width,
height: this.height,
thumbnail: thumbData,
date: new Date().toLocaleDateString('ru-RU'),
layers: this.layers.map(l => {
const c = { ...l };
if (l.type === 'image' && l.image) c._imgSrc = l.image.src;
return c;
})
};
let saved = JSON.parse(localStorage.getItem('artstudio_projects') || '[]');
saved.unshift(projectObj);
localStorage.setItem('artstudio_projects', JSON.stringify(saved));
alert('Проект успешно сохранен в "Мои работы"!');
},
renderSavedProjectsGallery() {
const container = document.getElementById('projects-gallery-grid');
const saved = JSON.parse(localStorage.getItem('artstudio_projects') || '[]');
if (saved.length === 0) {
container.innerHTML = `
<div class="col-span-full py-12 text-center text-white/40 glass-card rounded-2xl">
<i class="fa-solid fa-folder-open text-3xl mb-2"></i>
<p class="text-xs">У вас пока нет сохранённых работ</p>
</div>
`;
return;
}
container.innerHTML = saved.map(p => `
<div class="glass-card rounded-2xl overflow-hidden border border-white/10 group hover:border-blue-500/50 transition">
<div class="h-36 bg-black/50 overflow-hidden relative">
<img src="${p.thumbnail}" class="w-full h-full object-cover group-hover:scale-105 transition duration-300">
</div>
<div class="p-3">
<h4 class="text-xs font-semibold text-white truncate">${p.name}</h4>
<p class="text-[10px] text-white/40 mt-0.5">${p.width}×${p.height} px • ${p.date}</p>
<div class="mt-3 flex space-x-2">
<button onclick="App.loadProjectFromGallery(${p.id})" class="flex-1 py-1.5 bg-blue-600 rounded-xl text-[10px] text-white font-medium hover:bg-blue-500 transition">Открыть</button>
<button onclick="App.deleteProjectFromGallery(${p.id})" class="px-2.5 py-1.5 bg-white/10 rounded-xl text-[10px] text-red-400 hover:bg-red-600/20 transition"><i class="fa-solid fa-trash"></i></button>
</div>
</div>
</div>
`).join('');
},
loadProjectFromGallery(id) {
const saved = JSON.parse(localStorage.getItem('artstudio_projects') || '[]');
const p = saved.find(item => item.id === id);
if (!p) return;
this.width = p.width;
this.height = p.height;
this.currentProjectName = p.name;
this.canvas.width = p.width;
this.canvas.height = p.height;
const wrapper = document.getElementById('canvas-wrapper');
wrapper.style.width = `${p.width}px`;
wrapper.style.height = `${p.height}px`;
this.restoreHistoryState(JSON.stringify(p.layers));
this.openTab('editor');
},
deleteProjectFromGallery(id) {
let saved = JSON.parse(localStorage.getItem('artstudio_projects') || '[]');
saved = saved.filter(p => p.id !== id);
localStorage.setItem('artstudio_projects', JSON.stringify(saved));
this.renderSavedProjectsGallery();
},
clearAllProjects() {
if (confirm('Удалить все сохранённые работы?')) {
localStorage.removeItem('artstudio_projects');
this.renderSavedProjectsGallery();
}
},
// --- RENDERING LOOP ---
requestRender() {
requestAnimationFrame(() => this.render());
},
renderLoop() {
this.time += 0.03;
this.render();
requestAnimationFrame(() => this.renderLoop());
},
render() {
const ctx = this.ctx;
ctx.clearRect(0, 0, this.width, this.height);
this.layers.forEach(layer => {
if (!layer.visible) return;
ctx.save();
ctx.globalAlpha = layer.opacity;
ctx.globalCompositeOperation = layer.blendMode;
let animScale = 1;
let animRot = 0;
let animGlowOffset = 0;
if (layer.animation === 'pulse') {
animScale = 1 + Math.sin(this.time * 3 * layer.animSpeed) * 0.08;
} else if (layer.animation === 'spin') {
animRot = (this.time * 50 * layer.animSpeed) % 360;
} else if (layer.animation === 'float') {
var animY = Math.sin(this.time * 2 * layer.animSpeed) * 20;
} else if (layer.animation === 'glow') {
animGlowOffset = Math.abs(Math.sin(this.time * 4)) * 25;
}
ctx.translate(layer.x, layer.y + (animY || 0));
ctx.rotate(((layer.rotation + animRot) * Math.PI) / 180);
ctx.transform(
layer.scaleX * animScale,
Math.tan((layer.tiltY * Math.PI) / 180),
Math.tan((layer.tiltX * Math.PI) / 180),
layer.scaleY * animScale,
0, 0
);
ctx.filter = `
brightness(${layer.brightness}%)
contrast(${layer.contrast}%)
saturate(${layer.saturation}%)
blur(${layer.blur}px)
hue-rotate(${layer.hue}deg)
invert(${layer.invert}%)
`.trim();
if (layer.edgeGlow || layer.outerGlow) {
ctx.shadowColor = layer.edgeGlow ? layer.edgeGlowColor : layer.outerGlowColor;
ctx.shadowBlur = (layer.edgeGlow ? layer.edgeGlowSize : layer.outerGlowBlur) + animGlowOffset;
} else {
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
}
if (layer.type === 'solid') {
ctx.fillStyle = layer.color;
ctx.fillRect(-this.width / 2, -this.height / 2, this.width, this.height);
} else if (layer.type === 'text') {
ctx.font = `${layer.fontSize}px "${layer.fontFamily}", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (layer.useGradient) {
const grad = ctx.createLinearGradient(-100, 0, 100, 0);
grad.addColorStop(0, layer.color);
grad.addColorStop(1, layer.gradientColor2);
ctx.fillStyle = grad;
} else {
ctx.fillStyle = layer.color;
}
ctx.fillText(layer.text, 0, 0);
} else if (layer.type === 'image' && layer.image && layer.image.complete) {
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
}
ctx.restore();
});
if (this.showGrid) {
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
const step = 100;
for (let x = 0; x < this.width; x += step) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, this.height); ctx.stroke();
}
for (let y = 0; y < this.height; y += step) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(this.width, y); ctx.stroke();
}
ctx.restore();
}
},
// --- TOUCH / MOUSE INTERACTION ENGINE ---
setupTouchPinchZoom() {
const container = document.getElementById('workspace-container');
let isDragging = false;
let startX, startY;
let activeLayer = null;
const getCanvasCoords = (e) => {
const rect = this.canvas.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
return {
x: (clientX - rect.left) * (this.width / rect.width),
y: (clientY - rect.top) * (this.height / rect.height)
};
};
const onPointerDown = (e) => {
if (e.target.closest('#sub-panel') || e.target.closest('header') || e.target.closest('aside')) return;
const coords = getCanvasCoords(e);
activeLayer = this.getSelectedLayer();
if (activeLayer && !activeLayer.locked) {
isDragging = true;
startX = coords.x - activeLayer.x;
startY = coords.y - activeLayer.y;
}
};
const onPointerMove = (e) => {
if (!isDragging || !activeLayer || activeLayer.locked) return;
const coords = getCanvasCoords(e);
activeLayer.x = coords.x - startX;
activeLayer.y = coords.y - startY;
this.requestRender();
};
const onPointerUp = () => {
if (isDragging) {
isDragging = false;
this.saveHistory();
}
};
container.addEventListener('mousedown', onPointerDown);
window.addEventListener('mousemove', onPointerMove);
window.addEventListener('mouseup', onPointerUp);
container.addEventListener('touchstart', onPointerDown, { passive: true });
window.addEventListener('touchmove', onPointerMove, { passive: true });
window.addEventListener('touchend', onPointerUp);
},
// --- UI INSPECTOR PANELS (PC & MOBILE) ---
openEditorSubTab(tabName) {
AudioEngine.playClick();
this.activeEditorTab = tabName;
// Sync mobile active tab
document.querySelectorAll('.editor-mobile-tab').forEach(b => b.classList.toggle('active', b.dataset.editorTab === tabName));
const subPanel = document.getElementById('sub-panel');
subPanel.classList.remove('translate-y-full');
this.refreshEditorInspector();
},
refreshEditorInspector() {
const currentLayer = this.getSelectedLayer();
const tabName = this.activeEditorTab;
// Target desktop and mobile containers
const desktopContent = document.getElementById('desktop-inspector-content');
const mobileContent = document.getElementById('sub-panel-content');
const mobileTitle = document.getElementById('sub-panel-title');
let htmlContent = '';
if (tabName === 'canvas') {
if (mobileTitle) mobileTitle.innerText = 'Размер Холста';
htmlContent = `
<div class="space-y-3">
<button onclick="App.createNewProjectWithDimensions(1080,1920,'9:16 Mobile')" class="w-full p-3 bg-white/5 border border-white/10 rounded-2xl text-left text-xs text-white hover:bg-white/10 flex justify-between items-center">
<div><div class="font-bold">9:16 Mobile</div><div class="text-[10px] text-white/50">1080 × 1920</div></div>
<i class="fa-solid fa-chevron-right text-white/30"></i>
</button>
<button onclick="App.createNewProjectWithDimensions(1080,1080,'1:1 Квадрат')" class="w-full p-3 bg-white/5 border border-white/10 rounded-2xl text-left text-xs text-white hover:bg-white/10 flex justify-between items-center">
<div><div class="font-bold">1:1 Квадрат</div><div class="text-[10px] text-white/50">1080 × 1080</div></div>
<i class="fa-solid fa-chevron-right text-white/30"></i>
</button>
<button onclick="App.createNewProjectWithDimensions(1920,1080,'16:9 Landscape')" class="w-full p-3 bg-white/5 border border-white/10 rounded-2xl text-left text-xs text-white hover:bg-white/10 flex justify-between items-center">
<div><div class="font-bold">16:9 ПК / Landscape</div><div class="text-[10px] text-white/50">1920 × 1080</div></div>
<i class="fa-solid fa-chevron-right text-white/30"></i>
</button>
</div>
`;
} else if (tabName === 'layers') {
if (mobileTitle) mobileTitle.innerText = 'Слои';
htmlContent = `
<div class="space-y-2">
${this.layers.slice().reverse().map(layer => `
<div class="p-3 rounded-2xl ${layer.id === this.selectedLayerId ? 'bg-blue-600/20 border-blue-500/50' : 'bg-white/5 border-white/5'} border flex items-center justify-between">
<div class="flex items-center space-x-3 cursor-pointer flex-1" onclick="App.selectLayer(${layer.id})">
<i class="fa-solid ${layer.type === 'text' ? 'fa-font' : layer.type === 'image' ? 'fa-image' : 'fa-square'} text-white/60 text-sm"></i>
<span class="text-xs text-white font-medium truncate max-w-[100px]">${layer.name}</span>
</div>
<div class="flex items-center space-x-2">
<button onclick="App.toggleLayerVisibility(${layer.id})" class="text-white/40 hover:text-white text-xs"><i class="fa-solid ${layer.visible ? 'fa-eye' : 'fa-eye-slash'}"></i></button>
<button onclick="App.moveLayer(${layer.id}, 'up')" class="text-white/40 hover:text-white text-xs"><i class="fa-solid fa-chevron-up"></i></button>
<button onclick="App.moveLayer(${layer.id}, 'down')" class="text-white/40 hover:text-white text-xs"><i class="fa-solid fa-chevron-down"></i></button>
<button onclick="App.duplicateLayer(${layer.id})" class="text-white/40 hover:text-white text-xs"><i class="fa-solid fa-clone"></i></button>
<button onclick="App.deleteLayer(${layer.id})" class="text-red-400 hover:text-red-300 text-xs"><i class="fa-solid fa-trash"></i></button>
</div>
</div>
`).join('')}
</div>
`;
} else if (tabName === 'add') {
if (mobileTitle) mobileTitle.innerText = 'Добавить';
htmlContent = `
<div class="grid grid-cols-2 gap-3">
<button onclick="document.getElementById('file-upload-input').click()" class="p-3.5 bg-white/5 border border-white/10 rounded-2xl text-left hover:bg-white/10 flex items-center space-x-3">
<i class="fa-solid fa-image text-blue-400 text-lg"></i>
<div>
<div class="text-xs font-semibold text-white">Из галереи</div>
<div class="text-[10px] text-white/40">Файл фото</div>
</div>
</button>
<button onclick="App.addTextLayer()" class="p-3.5 bg-white/5 border border-white/10 rounded-2xl text-left hover:bg-white/10 flex items-center space-x-3">
<i class="fa-solid fa-font text-purple-400 text-lg"></i>
<div>
<div class="text-xs font-semibold text-white">Текст</div>
<div class="text-[10px] text-white/40">Надпись</div>
</div>
</button>
</div>
<div class="mt-3">
<h4 class="text-[11px] font-semibold text-white/60 mb-2">Оверлеи и Эффекты</h4>
<div class="grid grid-cols-2 gap-2">
${this.stockOverlays.map((o, idx) => `
<button onclick="App.addStockOverlay(App.stockOverlays[${idx}])" class="p-2 bg-white/5 border border-white/10 rounded-xl text-xs text-white/80 hover:bg-white/10 text-center truncate">
${o.name}
</button>
`).join('')}
</div>
</div>
`;
} else if (tabName === 'adjust') {
if (mobileTitle) mobileTitle.innerText = 'Коррекция';
if (!currentLayer) {
htmlContent = `<p class="text-xs text-white/40 text-center py-4">Выберите слой для настройки</p>`;
} else {
htmlContent = `
<div class="space-y-3">
<div>
<div class="flex justify-between text-xs text-white/70 mb-1">
<span>Прозрачность</span>
<span>${Math.round(currentLayer.opacity * 100)}%</span>
</div>
<input type="range" min="0" max="1" step="0.01" value="${currentLayer.opacity}" oninput="App.updateLayerProp('opacity', parseFloat(this.value))" class="w-full">
</div>
${currentLayer.type === 'text' ? `
<div>
<label class="text-xs text-white/70 block mb-1">Текст надписи</label>
<input type="text" value="${currentLayer.text}" onchange="App.updateLayerProp('text', this.value)" class="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-1.5 text-xs text-white">
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-white/70 block mb-1">Шрифт</label>
<select onchange="App.updateLayerProp('fontFamily', this.value)" class="w-full bg-white/5 border border-white/10 rounded-xl px-2 py-1.5 text-xs text-white">
${this.customFonts.map(f => `<option value="${f}" ${currentLayer.fontFamily === f ? 'selected' : ''} class="bg-zinc-900">${f}</option>`).join('')}
</select>
</div>
<div>
<label class="text-xs text-white/70 block mb-1">Цвет</label>
<input type="color" value="${currentLayer.color}" onchange="App.updateLayerProp('color', this.value)" class="w-full h-8 bg-transparent cursor-pointer rounded-lg">
</div>
</div>
` : ''}
<div>
<div class="flex justify-between text-xs text-white/70 mb-1"><span>Яркость</span><span>${currentLayer.brightness}%</span></div>
<input type="range" min="0" max="200" value="${currentLayer.brightness}" oninput="App.updateLayerProp('brightness', parseInt(this.value))" class="w-full">
</div>
<div>
<div class="flex justify-between text-xs text-white/70 mb-1"><span>Контраст</span><span>${currentLayer.contrast}%</span></div>
<input type="range" min="0" max="200" value="${currentLayer.contrast}" oninput="App.updateLayerProp('contrast', parseInt(this.value))" class="w-full">
</div>
<div>
<div class="flex justify-between text-xs text-white/70 mb-1"><span>Насыщенность</span><span>${currentLayer.saturation}%</span></div>
<input type="range" min="0" max="200" value="${currentLayer.saturation}" oninput="App.updateLayerProp('saturation', parseInt(this.value))" class="w-full">
</div>
</div>
`;
}
} else if (tabName === 'fx') {
if (mobileTitle) mobileTitle.innerText = 'CapCut FX & 3D';
if (!currentLayer) {
htmlContent = `<p class="text-xs text-white/40 text-center py-4">Выберите слой для эффектов</p>`;
} else {
htmlContent = `
<div class="space-y-3">
<div class="p-3 bg-white/5 border border-white/10 rounded-2xl space-y-2">
<h4 class="text-xs font-semibold text-blue-400 uppercase tracking-wider">3D Угол и Поворот</h4>
<div>
<div class="flex justify-between text-xs text-white/70 mb-1"><span>Угол X</span><span>${currentLayer.tiltX}°</span></div>
<input type="range" min="-45" max="45" value="${currentLayer.tiltX}" oninput="App.updateLayerProp('tiltX', parseInt(this.value))" class="w-full">
</div>
<div>
<div class="flex justify-between text-xs text-white/70 mb-1"><span>Угол Y</span><span>${currentLayer.tiltY}°</span></div>
<input type="range" min="-45" max="45" value="${currentLayer.tiltY}" oninput="App.updateLayerProp('tiltY', parseInt(this.value))" class="w-full">
</div>
<div>
<div class="flex justify-between text-xs text-white/70 mb-1"><span>Поворот</span><span>${currentLayer.rotation}°</span></div>
<input type="range" min="0" max="360" value="${currentLayer.rotation}" oninput="App.updateLayerProp('rotation', parseInt(this.value))" class="w-full">
</div>
</div>
<div class="p-3 bg-white/5 border border-white/10 rounded-2xl space-y-2">
<div class="flex items-center justify-between">
<h4 class="text-xs font-semibold text-pink-400 uppercase tracking-wider">Свечение по краям (CapCut)</h4>
<input type="checkbox" ${currentLayer.edgeGlow ? 'checked' : ''} onchange="App.updateLayerProp('edgeGlow', this.checked)">
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-[10px] text-white/60">Цвет ауры</label>
<input type="color" value="${currentLayer.edgeGlowColor}" onchange="App.updateLayerProp('edgeGlowColor', this.value)" class="w-full h-8 bg-transparent">
</div>
<div>
<label class="text-[10px] text-white/60">Размер</label>
<input type="range" min="5" max="60" value="${currentLayer.edgeGlowSize}" oninput="App.updateLayerProp('edgeGlowSize', parseInt(this.value))" class="w-full">
</div>
</div>
</div>
${currentLayer.type === 'image' ? `
<div class="p-3 bg-white/5 border border-white/10 rounded-2xl space-y-2">
<h4 class="text-xs font-semibold text-green-400 uppercase tracking-wider">Удаление фона (Хромакей)</h4>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-[10px] text-white/60">Удаляемый цвет</label>
<input type="color" value="${currentLayer.chromaColor}" onchange="App.updateLayerProp('chromaColor', this.value)" class="w-full h-8 bg-transparent">
</div>
<div>
<label class="text-[10px] text-white/60">Допуск</label>
<input type="range" min="10" max="150" value="${currentLayer.chromaTolerance}" oninput="App.updateLayerProp('chromaTolerance', parseInt(this.value))" class="w-full">
</div>
</div>
<button onclick="App.applyChromaKey(App.getSelectedLayer())" class="w-full py-1.5 bg-green-600/30 border border-green-500/50 rounded-xl text-xs text-green-300 font-medium hover:bg-green-600/50 transition">
Удалить цвет
</button>
</div>
` : ''}
<div class="p-3 bg-white/5 border border-white/10 rounded-2xl space-y-2">
<h4 class="text-xs font-semibold text-amber-400 uppercase tracking-wider">Анимация элемента</h4>
<select onchange="App.updateLayerProp('animation', this.value)" class="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-1.5 text-xs text-white">
<option value="none" ${currentLayer.animation === 'none' ? 'selected' : ''} class="bg-zinc-900">Без анимации</option>
<option value="pulse" ${currentLayer.animation === 'pulse' ? 'selected' : ''} class="bg-zinc-900">Пульсация</option>
<option value="spin" ${currentLayer.animation === 'spin' ? 'selected' : ''} class="bg-zinc-900">Вращение 360</option>
<option value="float" ${currentLayer.animation === 'float' ? 'selected' : ''} class="bg-zinc-900">Парение</option>
<option value="glow" ${currentLayer.animation === 'glow' ? 'selected' : ''} class="bg-zinc-900">Мерцание</option>
</select>
</div>
</div>
`;
}
}
if (desktopContent) desktopContent.innerHTML = htmlContent;
if (mobileContent) mobileContent.innerHTML = htmlContent;
},
selectLayer(id) {
AudioEngine.playClick();
this.selectedLayerId = id;
this.requestRender();
this.refreshEditorInspector();
},
toggleLayerVisibility(id) {
AudioEngine.playClick();
const layer = this.layers.find(l => l.id === id);
if (layer) {
layer.visible = !layer.visible;
this.requestRender();
this.refreshEditorInspector();
}
},
updateLayerProp(prop, value) {
const layer = this.getSelectedLayer();
if (!layer) return;
layer[prop] = value;
this.requestRender();
},
// --- EXPORT PIPELINE ---
exportCanvas(format, duration = 3) {
AudioEngine.playClick();
if (format === 'png' || format === 'jpeg') {
const dataUrl = this.canvas.toDataURL(`image/${format}`, 0.95);
const link = document.createElement('a');
link.download = `artstudio-export.${format}`;
link.href = dataUrl;
link.click();
} else if (format === 'webm') {
const stream = this.canvas.captureStream(30);
const recorder = new MediaRecorder(stream, { mimeType: 'video/webm' });
const chunks = [];
recorder.ondataavailable = e => chunks.push(e.data);
recorder.onstop = () => {
const blob = new Blob(chunks, { type: 'video/webm' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.download = 'artstudio-animation.webm';
link.href = url;
link.click();
};
recorder.start();
setTimeout(() => recorder.stop(), duration * 1000);
}
},
// --- EVENT LISTENERS & SETUP ---
setupNavigation() {
document.querySelectorAll('.view-tab-btn').forEach(btn => {
btn.addEventListener('click', () => this.openTab(btn.dataset.view));
});
document.getElementById('btn-home-logo').addEventListener('click', () => this.openTab('home'));
document.getElementById('btn-open-my-works').addEventListener('click', () => this.openTab('projects'));
},
setupEventListeners() {
document.getElementById('btn-lofi-toggle').addEventListener('click', () => AudioEngine.toggleLofi());
// Home Action
document.getElementById('btn-create-new-project').addEventListener('click', () => {
this.createNewProjectWithDimensions(1080, 1920, 'Проект ArtStudio');
});
document.getElementById('btn-create-custom-project').addEventListener('click', () => {
const w = parseInt(document.getElementById('custom-width').value) || 1080;
const h = parseInt(document.getElementById('custom-height').value) || 1080;
this.createNewProjectWithDimensions(w, h, 'Свой проект');
document.getElementById('custom-project-modal').classList.add('hidden');
});
// Editor Controls
document.getElementById('btn-close-subpanel').addEventListener('click', () => {
document.getElementById('sub-panel').classList.add('translate-y-full');
});
document.querySelectorAll('.editor-mobile-tab').forEach(btn => {
btn.addEventListener('click', () => this.openEditorSubTab(btn.dataset.editorTab));
});
document.getElementById('btn-undo').addEventListener('click', () => this.undo());
document.getElementById('btn-redo').addEventListener('click', () => this.redo());
document.getElementById('btn-save-project').addEventListener('click', () => this.saveCurrentProjectToGallery());
document.getElementById('btn-zoom-fit').addEventListener('click', () => this.fitZoomToScreen());
document.getElementById('btn-toggle-grid').addEventListener('click', () => {
this.showGrid = !this.showGrid;
this.requestRender();
});
// Export Modal
document.getElementById('btn-export-modal').addEventListener('click', () => {
document.getElementById('export-modal').classList.remove('hidden');
});
document.getElementById('btn-cancel-export').addEventListener('click', () => {
document.getElementById('export-modal').classList.add('hidden');
});
document.getElementById('export-format').addEventListener('change', (e) => {
document.getElementById('export-fps-container').classList.toggle('hidden', e.target.value !== 'webm');
});
document.getElementById('btn-do-export').addEventListener('click', () => {
const fmt = document.getElementById('export-format').value;
const dur = parseInt(document.getElementById('export-duration').value) || 3;
this.exportCanvas(fmt, dur);
document.getElementById('export-modal').classList.add('hidden');
});
// Settings Controls
document.getElementById('setting-sound-toggle').addEventListener('change', (e) => {
AudioEngine.soundEnabled = e.target.checked;
});
document.getElementById('setting-lofi-toggle').addEventListener('change', () => {
AudioEngine.toggleLofi();
});
document.getElementById('setting-volume-slider').addEventListener('input', (e) => {
AudioEngine.volume = parseFloat(e.target.value);
});
// Search Palette
document.getElementById('btn-search-trigger').addEventListener('click', () => {
document.getElementById('search-modal').classList.remove('hidden');
document.getElementById('search-input').focus();
});
document.getElementById('btn-close-search').addEventListener('click', () => {
document.getElementById('search-modal').classList.add('hidden');
});
document.getElementById('search-input').addEventListener('input', (e) => {
this.handleSearch(e.target.value);
});
// File Upload
const hiddenInput = document.createElement('input');
hiddenInput.type = 'file';
hiddenInput.id = 'file-upload-input';
hiddenInput.accept = 'image/*';
hiddenInput.className = 'hidden';
document.body.appendChild(hiddenInput);
hiddenInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (evt) => {
const img = new Image();
img.onload = () => this.addImageLayer(img, file.name);
img.src = evt.target.result;
};
reader.readAsDataURL(file);
});
// Keyboard Shortcuts
window.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'z') {
e.preventDefault();
this.undo();
} else if (e.ctrlKey && e.key === 's') {
e.preventDefault();
if (this.currentView === 'editor') this.saveCurrentProjectToGallery();
}
});
},
handleSearch(query) {
const container = document.getElementById('search-results');
if (!query.trim()) {
container.innerHTML = '';
return;
}
const tools = [
{ name: "Добавить текст", action: () => { this.addTextLayer(); this.openEditorSubTab('adjust'); } },
{ name: "Загрузить картинку", action: () => document.getElementById('file-upload-input').click() },
{ name: "Свечение по краям (CapCut Edge Glow)", action: () => this.openEditorSubTab('fx') },
{ name: "Удаление фона / Хромакей", action: () => this.openEditorSubTab('fx') },
{ name: "3D Угол и Наклон (Tilt)", action: () => this.openEditorSubTab('fx') },
{ name: "Изменить размер холста", action: () => this.openEditorSubTab('canvas') },
{ name: "Добавить оверлей / Блик / Пыль", action: () => this.openEditorSubTab('add') },
{ name: "Сохранить проект в галактику", action: () => this.saveCurrentProjectToGallery() }
];
const filtered = tools.filter(t => t.name.toLowerCase().includes(query.toLowerCase()));
container.innerHTML = filtered.map((t, idx) => `
<div onclick="App.executeSearchResult(${idx})" class="p-3 bg-white/5 hover:bg-white/10 rounded-xl cursor-pointer text-xs text-white flex items-center justify-between">
<span>${t.name}</span>
<i class="fa-solid fa-chevron-right text-white/30 text-[10px]"></i>
</div>
`).join('') || `<p class="text-xs text-white/30 p-2">Ничего не найдено</p>`;
this.currentSearchResults = filtered;
},
executeSearchResult(idx) {
if (this.currentSearchResults && this.currentSearchResults[idx]) {
this.currentSearchResults[idx].action();
document.getElementById('search-modal').classList.add('hidden');
}
}
};
window.onload = () => {
App.init();
};
</script>
</body>
</html>
```