| Current Path : /var/www/html/unity/development/ui-builder/ |
| Current File : /var/www/html/unity/development/ui-builder/ui-builder.html |
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>UI Builder — Game Interface Designer</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/OwlCarousel2/2.3.4/assets/owl.carousel.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/OwlCarousel2/2.3.4/assets/owl.theme.default.min.css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/OwlCarousel2/2.3.4/owl.carousel.min.js"></script>
<script src="https://unpkg.com/blockly/blockly_compressed.js"></script>
<script src="https://unpkg.com/blockly/blocks_compressed.js"></script>
<script src="https://unpkg.com/blockly/javascript_compressed.js"></script>
<script src="https://unpkg.com/blockly/msg/en.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&family=Orbitron:wght@400;500;600;700&display=swap');
:root {
--toolbar-height: 56px;
--palette-width: 200px;
--props-width: 280px;
--bg-dark: #0a0c14;
--bg-panel: rgba(18,20,30,0.95);
--bg-panel-hover: #252836;
--bg-input: rgba(15,17,23,0.8);
--accent: #00e5a0;
--accent-glow: rgba(0,229,160,0.3);
--accent2: #a78bfa;
--accent2-glow: rgba(167,139,250,0.3);
--accent3: #f59e0b;
--danger: #ff4d6a;
--text-primary: #e8eaf0;
--text-secondary: #7a7f94;
--text-muted: #555a6e;
--border-color: #2a2d3a;
--canvas-bg: #14161f;
}
/* ── Light Theme ── */
[data-theme="light"] {
--bg-dark: #d5d7e2;
--bg-panel: rgba(245,245,250,0.96);
--bg-panel-hover: #dddde8;
--bg-input: rgba(255,255,255,0.8);
--accent: #059669;
--accent-glow: rgba(5,150,105,0.25);
--accent2: #7c3aed;
--accent2-glow: rgba(124,58,237,0.2);
--accent3: #b45309;
--danger: #dc2626;
--text-primary: #1a1a2e;
--text-secondary: #4a4a65;
--text-muted: #888;
--border-color: #c8c8d8;
--canvas-bg: #cccdd6;
}
[data-theme="light"] .pp-input, [data-theme="light"] .pp-textarea,
[data-theme="light"] .pp-select { color:#1a1a2e; }
[data-theme="light"] #canvas-viewport { background:#e8e8f0; }
[data-theme="light"] .toast { background:rgba(255,255,255,0.95); }
[data-theme="light"] .pal-item .bi { color:var(--text-secondary); }
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family:'Rajdhani',sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
overflow: hidden;
height: 100vh;
user-select: none;
}
/* ── Toolbar (top) ── */
#toolbar {
position: fixed; top:0; left:0; right:0;
height: var(--toolbar-height);
background: var(--bg-panel);
border-bottom: 1px solid var(--border-color);
display: flex; align-items: center;
padding: 0 12px; gap: 8px; z-index: 100;
}
.tb-logo {
font-family:'Orbitron',sans-serif;
font-size: 0.7rem; font-weight: 700;
color: var(--accent); letter-spacing: 2px;
margin-right: 12px; white-space: nowrap;
}
.tb-sep { width:1px; height:28px; background:var(--border-color); margin:0 4px; }
.tb-btn {
display: flex; align-items: center; gap: 5px;
padding: 6px 14px; border-radius: 8px;
font-family:'Rajdhani',sans-serif; font-size: 0.75rem; font-weight: 600;
border: 1px solid var(--border-color);
background: transparent; color: var(--text-secondary);
cursor: pointer; transition: all 0.15s; white-space: nowrap;
}
.tb-btn:hover { background: var(--bg-panel-hover); color: var(--text-primary); }
.tb-btn.active { background: rgba(0,229,160,0.1); border-color: var(--accent); color: var(--accent); }
.tb-btn.accent2 { border-color: var(--accent2); color: var(--accent2); }
.tb-btn.accent2:hover { background: rgba(167,139,250,0.1); }
.tb-btn.accent3 { border-color: var(--accent3); color: var(--accent3); }
.tb-btn.accent3:hover { background: rgba(245,158,11,0.1); }
.tb-btn.danger { border-color: var(--danger); color: var(--danger); }
.tb-btn.danger:hover { background: rgba(255,77,106,0.1); }
.tb-spacer { flex:1; }
.tb-name {
font-family:'Rajdhani',sans-serif; font-size:0.8rem; font-weight:600;
color:var(--text-primary); background:transparent; border:1px solid var(--border-color);
border-radius:6px; padding:5px 10px; width:180px;
}
.tb-name:focus { outline:none; border-color:var(--accent); }
/* ── Device preview tabs ── */
.device-tabs { display:flex; gap:2px; background:rgba(0,0,0,0.3); border-radius:8px; padding:2px; }
.device-tab {
padding:5px 10px; border-radius:6px; border:none;
background:transparent; color:var(--text-muted); cursor:pointer;
font-family:'Rajdhani',sans-serif; font-size:0.7rem; font-weight:600;
transition:all 0.15s;
}
.device-tab.active { background:var(--accent); color:#000; }
/* ── Left palette ── */
#palette {
position: fixed; top: var(--toolbar-height); left:0; bottom:0;
width: var(--palette-width);
background: var(--bg-panel);
border-right: 1px solid var(--border-color);
overflow: hidden; z-index: 50;
display:flex; flex-direction:column;
}
/* ── Left panel tabs ── */
#pal-tabs {
display:flex; flex-shrink:0;
border-bottom:1px solid var(--border-color);
background:rgba(0,0,0,0.2);
}
.pal-tab {
flex:1; display:flex; align-items:center; justify-content:center; gap:5px;
padding:9px 4px; border:none; background:transparent;
color:var(--text-muted); cursor:pointer;
font-family:'Orbitron',sans-serif; font-size:0.5rem;
letter-spacing:1px; text-transform:uppercase;
border-bottom:2px solid transparent;
transition:all 0.15s;
}
.pal-tab .bi { font-size:0.75rem; }
.pal-tab:hover { color:var(--text-secondary); background:var(--bg-panel-hover); }
.pal-tab.active { color:var(--accent); border-bottom-color:var(--accent); background:rgba(0,229,160,0.05); }
.pal-tab-count {
font-family:'Rajdhani',sans-serif; font-size:0.5rem; font-weight:700;
background:rgba(255,255,255,0.08); color:var(--text-muted);
border-radius:8px; padding:0 5px; min-width:16px; line-height:14px;
}
.pal-tab.active .pal-tab-count { background:rgba(0,229,160,0.15); color:var(--accent); }
.pal-tab-panel { display:none; flex:1; overflow-y:auto; }
.pal-tab-panel.active { display:block; }
.pal-cat {
border-bottom: 1px solid var(--border-color);
}
.pal-cat-header {
display:flex; align-items:center; gap:6px;
padding: 10px 12px; cursor:pointer;
font-size:0.7rem; font-weight:700; text-transform:uppercase;
letter-spacing:1px; color:var(--accent);
font-family:'Orbitron',sans-serif;
transition: background 0.15s;
}
.pal-cat-header:hover { background: var(--bg-panel-hover); }
.pal-cat-header .bi { font-size:0.8rem; }
.pal-cat-header .arrow { margin-left:auto; transition:transform 0.2s; font-size:0.6rem; color:var(--text-muted); }
.pal-cat.collapsed .pal-cat-items { display:none; }
.pal-cat.collapsed .arrow { transform:rotate(-90deg); }
.pal-cat-items { padding:4px 8px 8px; display:grid; grid-template-columns:1fr 1fr; gap:4px; }
.pal-item {
display:flex; flex-direction:column; align-items:center; gap:3px;
padding:8px 4px; border-radius:8px;
border:1px solid transparent;
background:rgba(255,255,255,0.02);
cursor:grab; transition:all 0.15s;
}
.pal-item:hover { background:rgba(0,229,160,0.06); border-color:rgba(0,229,160,0.2); }
.pal-item:active { cursor:grabbing; }
.pal-item .bi { font-size:1.1rem; color:var(--text-secondary); }
.pal-item span { font-size:0.55rem; color:var(--text-muted); text-align:center; line-height:1.2; }
/* ── Right properties panel ── */
#props-panel {
position:fixed; top:var(--toolbar-height); right:0; bottom:0;
width:var(--props-width);
background:var(--bg-panel);
border-left:1px solid var(--border-color);
overflow:hidden; z-index:50;
display:none;
}
#props-panel.visible { display:flex; flex-direction:column; }
#pp-context { flex-shrink:0; }
/* ── Right panel tabs (Style / Properties) ── */
#pp-tabs {
display:flex; flex-shrink:0;
border-top:1px solid var(--border-color);
border-bottom:1px solid var(--border-color);
background:rgba(0,0,0,0.2);
}
.pp-tab {
flex:1; display:flex; align-items:center; justify-content:center; gap:5px;
padding:8px 4px; border:none; background:transparent;
color:var(--text-muted); cursor:pointer;
font-family:'Orbitron',sans-serif; font-size:0.48rem;
letter-spacing:1px; text-transform:uppercase;
border-bottom:2px solid transparent; transition:all 0.15s;
}
.pp-tab .bi { font-size:0.72rem; }
.pp-tab:hover { color:var(--text-secondary); background:var(--bg-panel-hover); }
.pp-tab.active { color:var(--accent); border-bottom-color:var(--accent); background:rgba(0,229,160,0.05); }
#pp-content { flex:1; overflow-y:auto; }
.pp-tab-panel { display:none; }
.pp-tab-panel.active { display:block; }
.pp-header {
display:flex; align-items:center; justify-content:space-between;
padding:12px 14px; border-bottom:1px solid var(--border-color);
}
.pp-header h6 {
font-family:'Orbitron',sans-serif; font-size:0.6rem;
letter-spacing:1.5px; text-transform:uppercase; color:var(--accent);
margin:0;
}
.pp-close {
background:none; border:none; color:var(--text-muted);
cursor:pointer; font-size:0.9rem; opacity:0.6;
}
.pp-close:hover { opacity:1; }
.pp-section {
padding:10px 14px; border-bottom:1px solid var(--border-color);
}
.pp-section h6 {
font-family:'Orbitron',sans-serif; font-size:0.5rem;
letter-spacing:1px; text-transform:uppercase; color:var(--accent3);
margin-bottom:8px;
}
.pp-row {
display:flex; align-items:center; gap:6px; margin-bottom:5px;
}
.pp-label {
font-size:0.65rem; color:var(--text-secondary); min-width:55px;
flex-shrink:0;
}
.pp-input {
flex:1; background:var(--bg-input); border:1px solid var(--border-color);
border-radius:4px; padding:4px 6px; font-size:0.7rem;
color:var(--text-primary); font-family:'Rajdhani',sans-serif;
min-width:0;
}
.pp-input:focus { outline:none; border-color:var(--accent); }
.pp-select {
flex:1; background:var(--bg-input); border:1px solid var(--border-color);
border-radius:4px; padding:4px 6px; font-size:0.7rem;
color:var(--text-primary); font-family:'Rajdhani',sans-serif;
cursor:pointer;
}
.pp-textarea {
width:100%; background:var(--bg-input); border:1px solid var(--border-color);
border-radius:4px; padding:6px; font-size:0.7rem;
color:var(--text-primary); font-family:'Rajdhani',sans-serif;
resize:vertical; min-height:50px;
}
.pp-textarea:focus { outline:none; border-color:var(--accent); }
.pp-color {
width:32px; height:24px; border:1px solid var(--border-color);
border-radius:4px; cursor:pointer; padding:1px;
}
.pp-slider {
flex:1; accent-color:var(--accent); cursor:pointer;
}
.pp-val {
min-width:28px; text-align:right; font-size:0.65rem; color:var(--text-secondary);
}
.pp-check {
width:15px; height:15px; accent-color:var(--accent); cursor:pointer;
}
.pp-btn {
padding:6px 12px; border-radius:6px; font-family:'Rajdhani',sans-serif;
font-size:0.7rem; font-weight:600; cursor:pointer; border:1px solid;
transition:all 0.15s;
}
/* ── Canvas area ── */
#canvas-area {
position: fixed;
top: var(--toolbar-height);
left: var(--palette-width);
right: var(--props-width);
bottom: 0;
background: var(--canvas-bg);
display: flex; align-items: center; justify-content: center;
overflow: hidden;
}
#canvas-area.props-hidden { right:0; }
#canvas-viewport {
position: relative;
background: #0f1118;
border: 1px solid var(--border-color);
overflow: hidden;
transition: width 0.3s, height 0.3s;
box-shadow: 0 0 40px rgba(0,0,0,0.5);
}
/* Grid overlay */
#canvas-viewport.show-grid::before {
content:'';
position:absolute; top:0; left:0; right:0; bottom:0;
background-image:
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
background-size: 5% 5%;
pointer-events:none; z-index:1;
}
/* Safe area guides */
#canvas-viewport.show-guides::after {
content:'';
position:absolute;
top:5%; left:5%; right:5%; bottom:5%;
border:1px dashed rgba(0,229,160,0.15);
pointer-events:none; z-index:1;
}
/* ── Canvas elements ── */
.ui-el {
position: absolute;
cursor: move;
outline: 2px solid transparent;
outline-offset: 2px;
transition: outline-color 0.1s;
z-index: 10;
}
.ui-el:hover { outline-color: rgba(0,229,160,0.25); }
.ui-el.selected {
outline-color: var(--accent) !important;
outline-style: solid;
}
.ui-el.locked { cursor:default; }
/* Resize handles */
.resize-handle {
position:absolute; width:8px; height:8px;
background:var(--accent); border:1px solid #000;
border-radius:2px; z-index:20; display:none;
}
.ui-el.selected .resize-handle { display:block; }
.rh-tl { top:-4px; left:-4px; cursor:nwse-resize; }
.rh-tr { top:-4px; right:-4px; cursor:nesw-resize; }
.rh-bl { bottom:-4px; left:-4px; cursor:nesw-resize; }
.rh-br { bottom:-4px; right:-4px; cursor:nwse-resize; }
.rh-t { top:-4px; left:50%; transform:translateX(-50%); cursor:ns-resize; }
.rh-b { bottom:-4px; left:50%; transform:translateX(-50%); cursor:ns-resize; }
.rh-l { top:50%; left:-4px; transform:translateY(-50%); cursor:ew-resize; }
.rh-r { top:50%; right:-4px; transform:translateY(-50%); cursor:ew-resize; }
/* ── Layer list (Hierarchy tab) ── */
#layer-list { padding:6px 0 12px; }
#layer-empty {
display:none; padding:26px 16px; text-align:center;
font-size:0.65rem; color:var(--text-muted); line-height:1.6;
}
#layer-empty span { font-size:0.58rem; opacity:0.7; }
#layer-list.empty #layer-empty { display:block; }
.layer-item {
display:flex; align-items:center; gap:6px;
padding:5px 12px; cursor:pointer; font-size:0.7rem;
color:var(--text-secondary); transition:background 0.1s;
}
.layer-item:hover { background:var(--bg-panel-hover); }
.layer-item.selected { background:rgba(0,229,160,0.08); color:var(--accent); }
.layer-item .li-icon { font-size:0.75rem; opacity:0.6; }
.layer-item .li-name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.layer-item .li-vis { background:none; border:none; color:var(--text-muted); cursor:pointer; font-size:0.7rem; padding:2px; }
.layer-item .li-vis:hover { color:var(--text-primary); }
/* ── Carousel item editor (props panel) ── */
.car-ed-item {
border:1px solid var(--border-color); border-radius:8px;
margin-bottom:6px; background:rgba(255,255,255,0.02); overflow:hidden;
}
.car-ed-head {
display:flex; align-items:center; gap:6px; padding:6px 8px;
cursor:pointer; font-size:0.62rem; color:var(--text-secondary);
}
.car-ed-head:hover { background:var(--bg-panel-hover); }
.car-ed-head .car-ed-thumb {
width:22px; height:22px; border-radius:4px; object-fit:cover;
background:rgba(255,255,255,0.06); flex-shrink:0;
display:flex; align-items:center; justify-content:center; font-size:0.6rem; color:var(--text-muted);
}
.car-ed-head .car-ed-name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.car-ed-head .car-ed-btn {
background:none; border:none; color:var(--text-muted); cursor:pointer;
font-size:0.6rem; padding:2px 3px;
}
.car-ed-head .car-ed-btn:hover { color:var(--text-primary); }
.car-ed-head .car-ed-btn.del:hover { color:var(--danger); }
.car-ed-body { display:none; padding:6px 8px 8px; border-top:1px solid var(--border-color); }
.car-ed-item.open .car-ed-body { display:block; }
.car-ed-add {
width:100%; padding:7px; border-radius:8px; cursor:pointer;
background:rgba(0,229,160,0.08); border:1px dashed var(--accent); color:var(--accent);
font-family:'Rajdhani',sans-serif; font-size:0.62rem; font-weight:600;
}
.car-ed-add:hover { background:rgba(0,229,160,0.14); }
/* ── Toast ── */
#toast-container {
position:fixed; bottom:20px; left:50%; transform:translateX(-50%);
z-index:9999; display:flex; flex-direction:column; gap:6px; align-items:center;
}
.toast {
padding:8px 20px; border-radius:8px; font-size:0.75rem;
font-family:'Rajdhani',sans-serif; font-weight:600;
background:var(--bg-panel); border:1px solid var(--accent);
color:var(--accent); backdrop-filter:blur(12px);
animation:fadeInUp 0.3s;
}
.toast.error { border-color:var(--danger); color:var(--danger); }
.toast.warn { border-color:var(--accent3); color:var(--accent3); }
@keyframes fadeInUp { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:translateY(0); } }
/* ── Drag ghost ── */
#drag-ghost {
position:fixed; pointer-events:none; z-index:9000;
padding:6px 14px; border-radius:8px;
background:rgba(0,229,160,0.15); border:1px solid var(--accent);
color:var(--accent); font-size:0.7rem; font-weight:600;
display:none;
}
/* ── Settings Overlay ── */
#settings-overlay {
position:fixed; inset:0; z-index:200;
background:rgba(0,0,0,0.5); backdrop-filter:blur(4px);
display:none; align-items:center; justify-content:center;
}
#settings-overlay.visible { display:flex; }
#settings-box {
background:var(--bg-panel); border:1px solid var(--border-color);
border-radius:16px; padding:24px; width:340px; max-height:80vh;
overflow-y:auto; box-shadow:0 8px 40px rgba(0,0,0,0.5);
}
#settings-box h5 {
font-family:'Orbitron',sans-serif; font-size:0.65rem;
letter-spacing:2px; text-transform:uppercase; color:var(--accent);
margin-bottom:16px;
}
.setting-row {
display:flex; align-items:center; justify-content:space-between;
padding:8px 0; border-bottom:1px solid var(--border-color);
}
.setting-row:last-child { border-bottom:none; }
.setting-label { font-size:0.7rem; color:var(--text-secondary); }
.toggle-switch {
position:relative; width:40px; height:22px; cursor:pointer;
}
.toggle-switch input { opacity:0; width:0; height:0; }
.toggle-track {
position:absolute; inset:0; background:var(--border-color);
border-radius:11px; transition:background 0.2s;
}
.toggle-switch input:checked + .toggle-track { background:var(--accent); }
.toggle-track::after {
content:''; position:absolute; left:3px; top:3px;
width:16px; height:16px; border-radius:50%;
background:white; transition:transform 0.2s;
}
.toggle-switch input:checked + .toggle-track::after { transform:translateX(18px); }
/* ── Action Chain Cards ── */
.act-chain { margin-top:6px; }
.act-card {
background:rgba(255,255,255,0.02); border:1px solid var(--border-color);
border-radius:8px; padding:8px; margin-bottom:4px;
}
.act-card-header {
display:flex; align-items:center; justify-content:space-between;
margin-bottom:4px;
}
.act-card-header span {
font-family:'Orbitron',sans-serif; font-size:0.45rem;
letter-spacing:1px; color:var(--accent3);
}
.act-remove {
background:none; border:none; color:var(--danger);
cursor:pointer; font-size:0.65rem; padding:0 2px;
opacity:0.5;
}
.act-remove:hover { opacity:1; }
/* ── Parent-child layer indentation ── */
.layer-item.child { padding-left:28px; }
.layer-item.child .li-icon { font-size:0.6rem; }
.layer-item .li-parent-badge {
font-size:0.45rem; background:rgba(167,139,250,0.15); color:var(--accent2);
padding:1px 4px; border-radius:3px; margin-left:4px;
}
/* ── Panel child highlight ── */
.ui-el.drop-target { outline:2px dashed var(--accent2) !important; }
.pp-device-badge {
display:inline-flex; align-items:center; gap:4px;
font-family:'Orbitron',sans-serif; font-size:0.4rem;
letter-spacing:1px; text-transform:uppercase;
padding:3px 8px; border-radius:4px; margin-bottom:6px;
background:rgba(167,139,250,0.1); border:1px solid var(--accent2);
color:var(--accent2);
}
.pp-device-badge.desktop { background:rgba(0,229,160,0.08); border-color:var(--accent); color:var(--accent); }
.pp-row.has-override .pp-label::after {
content:'●'; color:var(--accent2); margin-left:3px; font-size:0.5rem;
}
.pp-responsive-toggle {
display:flex; align-items:center; gap:8px; padding:4px 0 8px;
border-bottom:1px solid var(--border-color); margin-bottom:6px;
}
.pp-responsive-toggle span { font-size:0.55rem; color:var(--text-secondary); }
/* ── Code editor ── */
.code-textarea {
width:100%; min-height:120px; background:#0d0f18; color:#9cdcfe;
border:1px solid var(--border-color); border-radius:6px;
font-family:'Courier New',monospace; font-size:0.65rem;
padding:8px; resize:vertical; tab-size:2; line-height:1.5;
}
[data-theme="light"] .code-textarea { background:#f5f5fa; color:#1a1a2e; }
/* ── Code editor overlay (full) ── */
#code-overlay {
position:fixed; inset:0; z-index:250;
background:rgba(0,0,0,0.7); backdrop-filter:blur(6px);
display:none; flex-direction:column;
}
#code-overlay.visible { display:flex; }
#code-header {
display:flex; align-items:center; justify-content:space-between;
padding:10px 16px; background:var(--bg-panel);
border-bottom:1px solid var(--border-color);
}
#code-header h5 {
font-family:'Orbitron',sans-serif; font-size:0.6rem;
letter-spacing:2px; color:var(--accent); margin:0;
}
#code-header .code-btns { display:flex; gap:6px; }
#code-body { display:flex; flex:1; overflow:hidden; }
#code-editor-area {
flex:1; display:flex; flex-direction:column; background:#0d0f18;
border-right:1px solid var(--border-color);
}
[data-theme="light"] #code-editor-area { background:#f5f5fa; }
.code-tabs {
display:flex; gap:0; background:var(--bg-panel);
border-bottom:1px solid var(--border-color);
}
.code-tab {
padding:6px 16px; font-family:'Orbitron',sans-serif; font-size:0.4rem;
letter-spacing:1px; text-transform:uppercase; color:var(--text-muted);
cursor:pointer; border:none; background:none; border-bottom:2px solid transparent;
transition:color 0.2s, border-color 0.2s;
}
.code-tab:hover { color:var(--text-primary); }
.code-tab.active { color:var(--accent); border-bottom-color:var(--accent); }
.code-tab-content { display:none; flex:1; flex-direction:column; overflow:hidden; }
.code-tab-content.active { display:flex; }
.func-bar {
display:flex; gap:6px; align-items:center; padding:4px 8px;
background:rgba(0,0,0,0.15); border-bottom:1px solid var(--border-color);
}
.func-bar .func-chip {
padding:3px 10px; font-size:0.45rem; border-radius:4px; cursor:pointer;
background:rgba(255,255,255,0.04); border:1px solid var(--border-color);
color:var(--text-secondary); font-family:'Courier New',monospace;
}
.func-bar .func-chip.active { background:rgba(0,229,160,0.08); border-color:var(--accent); color:var(--accent); }
.func-bar .func-add {
padding:3px 8px; font-size:0.45rem; border-radius:4px; cursor:pointer;
background:rgba(167,139,250,0.08); border:1px dashed var(--accent2);
color:var(--accent2);
}
#blockly-area { flex:1; position:relative; }
#blockly-generated {
height:120px; overflow:auto; background:#0a0c14; border-top:1px solid var(--border-color);
padding:8px 12px; font-family:'Courier New',monospace; font-size:0.55rem;
color:#6a9955; white-space:pre-wrap;
}
[data-theme="light"] #blockly-generated { background:#eee; color:#2e7d32; }
#code-editor-area .code-label {
padding:6px 12px; font-family:'Orbitron',sans-serif; font-size:0.45rem;
letter-spacing:1px; color:var(--accent3); text-transform:uppercase;
border-bottom:1px solid var(--border-color); background:var(--bg-panel);
}
#code-main {
flex:1; width:100%; background:transparent; color:#9cdcfe;
border:none; outline:none; font-family:'Courier New',monospace;
font-size:0.75rem; padding:12px 16px; resize:none;
tab-size:2; line-height:1.6;
}
[data-theme="light"] #code-main { color:#1a1a2e; }
#code-ref {
width:280px; overflow-y:auto; background:var(--bg-panel);
padding:14px; font-size:0.6rem; color:var(--text-secondary);
line-height:1.6;
}
#code-ref h6 {
font-family:'Orbitron',sans-serif; font-size:0.5rem;
color:var(--accent); margin:10px 0 6px; letter-spacing:1px;
}
#code-ref h6:first-child { margin-top:0; }
#code-ref code {
background:rgba(0,229,160,0.08); color:var(--accent);
padding:1px 4px; border-radius:3px; font-size:0.55rem;
}
#code-ref .sig {
font-family:'Courier New',monospace; color:#9cdcfe;
font-size:0.55rem; display:block; margin:3px 0;
padding:3px 6px; background:rgba(0,0,0,0.2); border-radius:4px;
}
[data-theme="light"] #code-ref .sig { background:rgba(0,0,0,0.05); color:#1a1a2e; }
#code-ref .desc { color:var(--text-muted); font-size:0.5rem; margin-bottom:6px; }
/* ── Color+text combo ── */
.color-combo {
display:flex; align-items:center; gap:4px; flex:1;
}
.color-combo .pp-color { flex-shrink:0; }
.color-combo .pp-input { flex:1; min-width:0; }
/* ── Scrollbar ── */
::-webkit-scrollbar { width:5px; }
::-webkit-scrollbar-track { background:transparent; }
::-webkit-scrollbar-thumb { background:var(--border-color); border-radius:3px; }
::-webkit-scrollbar-thumb:hover { background:var(--text-muted); }
/* ── Mobile ── */
@media (max-width:900px) {
:root { --palette-width:0px; --props-width:0px; }
#palette, #props-panel { display:none !important; }
}
</style>
</head>
<body>
<!-- ══ Toolbar ══ -->
<div id="toolbar">
<span class="tb-logo"><i class="bi bi-layout-wtf"></i> UI BUILDER</span>
<div class="tb-sep"></div>
<input type="text" class="tb-name" id="ui-name" value="Game HUD" placeholder="UI Name...">
<div class="tb-sep"></div>
<div class="device-tabs">
<button class="device-tab active" data-device="desktop" title="Desktop"><i class="bi bi-display"></i></button>
<button class="device-tab" data-device="tablet" title="Tablet"><i class="bi bi-tablet"></i></button>
<button class="device-tab" data-device="phone" title="Phone"><i class="bi bi-phone"></i></button>
<button class="device-tab" data-device="phone-land" title="Phone Landscape"><i class="bi bi-phone-landscape"></i></button>
</div>
<div class="tb-sep"></div>
<button class="tb-btn" id="btn-grid" title="Toggle Grid"><i class="bi bi-grid-3x3"></i> Grid</button>
<button class="tb-btn" id="btn-guides" title="Toggle Safe Area"><i class="bi bi-bounding-box"></i> Guides</button>
<button class="tb-btn" id="btn-snap" title="Toggle Snap"><i class="bi bi-magnet"></i> Snap</button>
<span class="tb-spacer"></span>
<button class="tb-btn" id="btn-bg-image" title="Set background image"><i class="bi bi-image"></i> BG</button>
<button class="tb-btn accent2" id="btn-preview" title="Preview Mode"><i class="bi bi-eye"></i> Preview</button>
<div class="tb-sep"></div>
<button class="tb-btn accent3" id="btn-save"><i class="bi bi-download"></i> Save</button>
<button class="tb-btn" id="btn-load"><i class="bi bi-upload"></i> Load</button>
<input type="file" id="file-load" accept=".json,.ui.json" style="display:none">
<div class="tb-sep"></div>
<button class="tb-btn" id="btn-code" title="Code Editor"><i class="bi bi-code-slash"></i></button>
<button class="tb-btn" id="btn-settings" title="Settings"><i class="bi bi-gear"></i></button>
</div>
<!-- ══ Code Editor Overlay ══ -->
<div id="code-overlay">
<div id="code-header">
<h5><i class="bi bi-code-slash"></i> CODE EDITOR</h5>
<div class="code-btns">
<button class="pp-btn" id="code-run" style="background:rgba(0,229,160,0.08);border-color:var(--accent);color:var(--accent);">
<i class="bi bi-play-fill"></i> Run
</button>
<button class="pp-btn" id="code-save" style="background:rgba(245,158,11,0.08);border-color:var(--accent3);color:var(--accent3);">
<i class="bi bi-check-lg"></i> Save & Close
</button>
<button class="pp-btn" id="code-close" style="background:rgba(255,255,255,0.04);border-color:var(--border-color);color:var(--text-secondary);">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<div id="code-body">
<div id="code-editor-area">
<div class="code-tabs">
<button class="code-tab active" data-tab="code"><i class="bi bi-code-slash"></i> Code</button>
<button class="code-tab" data-tab="blocks"><i class="bi bi-puzzle"></i> Blocks</button>
</div>
<!-- Code Tab -->
<div class="code-tab-content active" id="tab-code">
<div class="func-bar" id="func-bar">
<span style="font-size:0.4rem;color:var(--text-muted);font-family:'Orbitron',sans-serif;letter-spacing:1px;">FN:</span>
<select id="code-func-select" style="background:var(--bg-panel);color:var(--text-primary);border:1px solid var(--border-color);border-radius:4px;padding:2px 8px;font-size:0.5rem;font-family:'Courier New',monospace;max-width:200px;">
<option value="">(select function)</option>
</select>
<button class="func-add" id="btn-add-func"><i class="bi bi-plus"></i> New</button>
<button class="func-add" id="btn-edit-func" style="background:rgba(245,158,11,0.06);border-color:var(--accent3);color:var(--accent3);border-style:solid;"><i class="bi bi-pencil"></i> Edit Args</button>
<button class="func-add" id="btn-del-func" style="background:rgba(255,77,106,0.06);border-color:var(--danger);color:var(--danger);border-style:solid;"><i class="bi bi-trash3"></i> Delete</button>
</div>
<textarea id="code-main" spellcheck="false" placeholder="// Functions are called by element actions (callFunction) function onButtonClick(sourceEl, targetEl) { GameAPI.setUIValue('score', 0); GameAPI.setUIVisible('el_3', true); } // onReady() runs automatically after UI loads function onReady() { console.log('UI initialized!'); GameAPI.setUIValue('lives', 3); }"></textarea>
</div>
<!-- Blocks Tab -->
<div class="code-tab-content" id="tab-blocks">
<div class="func-bar" id="blockly-func-bar">
<span style="font-size:0.4rem;color:var(--text-muted);font-family:'Orbitron',sans-serif;letter-spacing:1px;">FN:</span>
<select id="blockly-func-select" style="background:var(--bg-panel);color:var(--text-primary);border:1px solid var(--border-color);border-radius:4px;padding:2px 8px;font-size:0.5rem;font-family:'Courier New',monospace;">
<option value="">(select function)</option>
</select>
<button class="func-add" id="blockly-add-func"><i class="bi bi-plus"></i> New Function</button>
<button class="func-add" id="blockly-gen-func" style="background:rgba(0,229,160,0.08);border-color:var(--accent);color:var(--accent);border-style:solid;"><i class="bi bi-arrow-down-circle"></i> Generate & Insert</button>
<button class="func-add" id="blockly-clear" style="background:rgba(255,77,106,0.06);border-color:var(--danger);color:var(--danger);border-style:solid;"><i class="bi bi-trash3"></i> Clear</button>
<button class="func-add" id="blockly-del-func" style="background:rgba(255,77,106,0.06);border-color:var(--danger);color:var(--danger);border-style:solid;"><i class="bi bi-x-circle"></i> Delete Fn</button>
<button class="func-add" id="blockly-edit-args" style="background:rgba(245,158,11,0.06);border-color:var(--accent3);color:var(--accent3);border-style:solid;"><i class="bi bi-pencil"></i> Edit Args</button>
<span style="flex:1;"></span>
<button class="func-add" id="blockly-build-block" style="background:rgba(0,229,160,0.06);border-color:var(--accent);color:var(--accent);border-style:solid;"><i class="bi bi-bricks"></i> Block Builder</button>
</div>
<div id="blockly-area"></div>
<div id="blockly-generated">
<div style="color:var(--text-muted);font-size:0.45rem;font-family:'Orbitron',sans-serif;letter-spacing:1px;padding:0 0 4px;">GENERATED CODE PREVIEW</div>
<pre id="blockly-code-output" style="margin:0;"></pre>
</div>
</div>
</div>
<div id="code-ref">
<h6>UI API</h6>
<span class="sig">GameAPI.setUIValue(var, value)</span>
<span class="sig">GameAPI.getUIValue(var) → value</span>
<span class="sig">GameAPI.setUIVisible(elId, bool)</span>
<span class="sig">GameAPI.setUIContent(elId, text)</span>
<span class="sig">GameAPI.setUIStyle(elId, prop, val)</span>
<h6>Key Simulation</h6>
<span class="sig">GameAPI.pressKey(key)</span>
<span class="sig">GameAPI.releaseKey(key)</span>
<span class="sig">GameAPI.simulateKey(key, ms)</span>
<span class="sig">GameAPI.releaseAllKeys()</span>
<span class="cat">Globals</span>
<span class="sig">GameAPI.setGlobal(name, value)</span>
<span class="sig">GameAPI.getGlobal(name) → value</span>
<h6>3D Objects</h6>
<span class="sig">GameAPI.getObject(id) → entry</span>
<span class="sig">GameAPI.getObjects(category) → []</span>
<span class="sig">GameAPI.setPosition(id, x, y, z)</span>
<span class="sig">GameAPI.setRotation(id, rx, ry, rz)</span>
<span class="sig">GameAPI.setScale(id, sx, sy, sz)</span>
<span class="sig">GameAPI.setAnimation(id, clipName)</span>
<span class="sig">GameAPI.getAnimations(id) → []</span>
<span class="sig">GameAPI.setVisible(id, bool)</span>
<span class="sig">GameAPI.destroy(id)</span>
<h6>Queries</h6>
<span class="sig">GameAPI.getPosition(id) → {x,y,z}</span>
<span class="sig">GameAPI.getDistance(id1, id2) → num</span>
<span class="sig">GameAPI.getProperty(id, prop) → val</span>
<span class="sig">GameAPI.setProperty(id, prop, val)</span>
<h6>Scene</h6>
<span class="sig">GameAPI.switchCamera(camId)</span>
<span class="sig">GameAPI.showDialog(text, opts)</span>
<span class="sig">GameAPI.showToast(message)</span>
<span class="sig">GameAPI.playAudio(url) → Audio</span>
<h6>Function Signature</h6>
<span class="sig">function myFunc(sourceEl, targetEl) { }</span>
<span class="desc"><code>sourceEl</code> = clicked element, <code>targetEl</code> = action target.</span>
<h6>Example: D-Pad Button</h6>
<span class="sig">function moveUp() {
GameAPI.simulateKey('w', 200);
}</span>
<h6>Example: Collect Coin</h6>
<span class="sig">function collectCoin() {
let s = GameAPI.getUIValue('score')||0;
GameAPI.setUIValue('score', s+10);
GameAPI.destroy('coin_1');
GameAPI.playAudio('coin.mp3');
}</span>
</div>
</div>
</div>
<!-- ══ Settings Overlay ══ -->
<div id="settings-overlay">
<div id="settings-box">
<h5><i class="bi bi-gear"></i> Settings</h5>
<div class="setting-row">
<span class="setting-label">Theme</span>
<label class="toggle-switch">
<input type="checkbox" id="set-theme">
<span class="toggle-track"></span>
</label>
</div>
<div class="setting-row">
<span class="setting-label">Snap to Grid</span>
<label class="toggle-switch">
<input type="checkbox" id="set-snap" checked>
<span class="toggle-track"></span>
</label>
</div>
<div class="setting-row">
<span class="setting-label">Show Grid</span>
<label class="toggle-switch">
<input type="checkbox" id="set-grid" checked>
<span class="toggle-track"></span>
</label>
</div>
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
<span class="setting-label">Custom Game Functions</span>
<textarea class="code-textarea" id="set-custom-code" placeholder="// Write JS functions callable from UI actions // e.g. function openInventory() { ... } // Then select 'Call Function' action on a button"></textarea>
</div>
<div style="display:flex;gap:8px;margin-top:16px;">
<button class="pp-btn" id="set-save" style="flex:1;background:rgba(0,229,160,0.08);border-color:var(--accent);color:var(--accent);">
<i class="bi bi-check-lg"></i> Save Config
</button>
<button class="pp-btn" id="set-close" style="flex:1;background:rgba(255,255,255,0.04);border-color:var(--border-color);color:var(--text-secondary);">
Close
</button>
</div>
</div>
</div>
<!-- ══ Left Panel (Tools / Hierarchy tabs) ══ -->
<div id="palette">
<div id="pal-tabs">
<button class="pal-tab active" data-tab="tools"><i class="bi bi-tools"></i> Tools</button>
<button class="pal-tab" data-tab="hierarchy"><i class="bi bi-diagram-3"></i> Hierarchy <span class="pal-tab-count" id="hierarchy-count">0</span></button>
</div>
<div class="pal-tab-panel active" id="pal-panel-tools">
<div id="palette-items"></div>
</div>
<div class="pal-tab-panel" id="pal-panel-hierarchy">
<div id="layer-list">
<div id="layer-items"></div>
<div id="layer-empty">No elements yet.<br><span>Add one from the Tools tab.</span></div>
</div>
</div>
</div>
<!-- ══ Canvas Area ══ -->
<div id="canvas-area">
<div id="canvas-viewport" class="show-grid show-guides"></div>
</div>
<!-- ══ Properties Panel ══ -->
<div id="props-panel">
<div class="pp-header">
<h6 id="pp-title">Properties</h6>
<button class="pp-close" id="pp-close"><i class="bi bi-x-lg"></i></button>
</div>
<div id="pp-context"></div>
<div id="pp-tabs">
<button class="pp-tab" data-tab="style"><i class="bi bi-palette"></i> Style</button>
<button class="pp-tab active" data-tab="props"><i class="bi bi-sliders2"></i> Properties</button>
</div>
<div id="pp-content">
<div class="pp-tab-panel" id="pp-panel-style"></div>
<div class="pp-tab-panel active" id="pp-panel-props"></div>
</div>
</div>
<!-- ══ Drag Ghost ══ -->
<div id="drag-ghost"></div>
<!-- ══ Toast Container ══ -->
<div id="toast-container"></div>
<script>
/* ═══════════════════════════════════════════════════════════
UI BUILDER — Game Interface Designer
═══════════════════════════════════════════════════════════ */
// ── Config ──
let ELEMENT_CONFIG = [];
const DEVICE_SIZES = {
desktop: { w:960, h:540 },
tablet: { w:512, h:683 },
phone: { w:320, h:568 },
'phone-land': { w:568, h:320 },
};
// ── State ──
let elements = [];
let selectedId = null;
let nextId = 1;
let deviceMode = 'desktop';
let projectScaleMode = 'fit'; // 'fit' = keep design proportions (letterbox), 'fill' = cover, 'stretch' = legacy
let snapEnabled = true;
let snapSize = 1; // percentage
let uiName = 'Game HUD';
let bgImage = '';
let previewMode = false;
let isDragging = false;
let isResizing = false;
let dragData = null;
let clipboard = null; // cut/copy buffer
let customCode = ''; // user custom JS functions
// DOM refs
const viewport = document.getElementById('canvas-viewport');
const canvasArea = document.getElementById('canvas-area');
const propsPanel = document.getElementById('props-panel');
const ppContent = document.getElementById('pp-content');
const ppContext = document.getElementById('pp-context');
const ppStyle = document.getElementById('pp-panel-style');
const ppProps = document.getElementById('pp-panel-props');
const ghostEl = document.getElementById('drag-ghost');
// ── Load element config ──
async function loadConfig() {
try {
const resp = await fetch('ui-builder.json');
const data = await resp.json();
ELEMENT_CONFIG = data.categories || [];
} catch(e) {
console.warn('Could not load ui-builder.json, using built-in defaults');
ELEMENT_CONFIG = getBuiltinConfig();
}
buildPalette();
resizeViewport();
}
function getBuiltinConfig() {
// Minimal fallback
return [
{ id:'text', name:'Text', icon:'bi-fonts', items:[
{ id:'heading', name:'Heading', icon:'bi-type-h1', type:'text', defaults:{ content:'Heading', fontFamily:"'Orbitron',sans-serif", fontSize:2.0, fontWeight:'700', color:'#ffffff', textAlign:'center' }},
{ id:'label', name:'Label', icon:'bi-type', type:'text', defaults:{ content:'Label', fontFamily:"'Rajdhani',sans-serif", fontSize:0.9, fontWeight:'500', color:'#c0c4d0' }},
]},
{ id:'buttons', name:'Buttons', icon:'bi-hand-index', items:[
{ id:'button_primary', name:'Button', icon:'bi-play-circle', type:'button', defaults:{ content:'Button', fontFamily:"'Rajdhani',sans-serif", fontSize:1.0, fontWeight:'700', color:'#ffffff', background:'linear-gradient(135deg,#00e5a0,#00b87a)', borderRadius:'8px', padding:'12px 32px', border:'none', cursor:'pointer' }},
]},
{ id:'layout', name:'Layout', icon:'bi-grid', items:[
{ id:'panel', name:'Panel', icon:'bi-square', type:'panel', defaults:{ w:25, h:20, background:'rgba(10,12,20,0.75)', borderRadius:'12px', border:'1px solid rgba(255,255,255,0.08)', padding:'16px' }},
]},
];
}
// ── Toast ──
function showToast(msg, type='') {
const t = document.createElement('div');
t.className = 'toast' + (type ? ' '+type : '');
t.textContent = msg;
document.getElementById('toast-container').appendChild(t);
setTimeout(() => t.remove(), 2500);
}
// ══════════════════════════════════════
// PALETTE
// ══════════════════════════════════════
function buildPalette() {
const container = document.getElementById('palette-items');
container.innerHTML = '';
ELEMENT_CONFIG.forEach(cat => {
const div = document.createElement('div');
div.className = 'pal-cat';
const header = document.createElement('div');
header.className = 'pal-cat-header';
header.innerHTML = `<i class="bi ${cat.icon}"></i> ${cat.name} <span class="arrow"><i class="bi bi-chevron-down"></i></span>`;
header.onclick = () => div.classList.toggle('collapsed');
div.appendChild(header);
const items = document.createElement('div');
items.className = 'pal-cat-items';
(cat.items || []).forEach(item => {
const el = document.createElement('div');
el.className = 'pal-item';
el.innerHTML = `<i class="bi ${item.icon}"></i><span>${item.name}</span>`;
el.draggable = true;
el.addEventListener('dragstart', e => {
dragData = { source:'palette', itemId:item.id, catId:cat.id };
ghostEl.textContent = item.name;
ghostEl.style.display = 'block';
e.dataTransfer.setDragImage(ghostEl, 0, 0);
e.dataTransfer.effectAllowed = 'copy';
});
el.addEventListener('dragend', () => { ghostEl.style.display = 'none'; dragData = null; });
// Click to add at center
el.addEventListener('dblclick', () => { addElementAtCenter(item, cat); });
items.appendChild(el);
});
div.appendChild(items);
container.appendChild(div);
});
}
// ══════════════════════════════════════
// ELEMENT MANAGEMENT
// ══════════════════════════════════════
function findTemplate(itemId) {
for (const cat of ELEMENT_CONFIG) {
const item = (cat.items || []).find(i => i.id === itemId);
if (item) return item;
}
return null;
}
function createElement(template, x, y) {
const id = 'el_' + (nextId++);
const d = template.defaults || {};
const el = {
id,
templateId: template.id,
type: template.type || 'text',
name: template.name,
// Position (percentage 0-100)
x: x !== undefined ? x : 50,
y: y !== undefined ? y : 50,
// Size (percentage, 0 = auto)
w: d.w || 0,
h: d.h || 0,
// Anchor: how x,y relate to element (affects CSS transform)
anchor: 'center',
// Content
content: d.content || '',
src: d.src || '',
iconClass: d.iconClass || '',
// Style
fontFamily: d.fontFamily || "'Rajdhani',sans-serif",
fontSize: d.fontSize || 1,
fontUnit: d.fontUnit || 'rem',
fontWeight: d.fontWeight || '400',
color: d.color || '#ffffff',
background: d.background || 'transparent',
borderRadius: d.borderRadius || '0',
padding: d.padding || '0',
border: d.border || 'none',
opacity: d.opacity !== undefined ? d.opacity : 1,
textAlign: d.textAlign || 'center',
textShadow: d.textShadow || '',
boxShadow: d.boxShadow || '',
letterSpacing: d.letterSpacing || '',
lineHeight: d.lineHeight || '',
textTransform: d.textTransform || '',
backdropFilter: d.backdropFilter || '',
objectFit: d.objectFit || 'contain',
aspectRatio: d.aspectRatio || '',
minWidth: d.minWidth || '',
minHeight: d.minHeight || '',
cursor: d.cursor || '',
display: d.display || '',
justifyContent: d.justifyContent || '',
alignItems: d.alignItems || '',
borderTop: d.borderTop || '',
// Video
videoFit: d.videoFit || 'contain',
playMode: d.playMode || 'auto',
loopMode: d.loopMode || 'loop',
loopCount: d.loopCount !== undefined ? d.loopCount : 1,
muted: d.muted !== undefined ? d.muted : true,
controls: d.controls || false,
volume: d.volume !== undefined ? d.volume : 1,
videoPoster: d.videoPoster || '',
// Chroma key (make a colour transparent)
chromaKey: d.chromaKey || false,
chromaColor: d.chromaColor || '#00ff00',
chromaSimilarity: d.chromaSimilarity !== undefined ? d.chromaSimilarity : 0.4,
chromaSmoothness: d.chromaSmoothness !== undefined ? d.chromaSmoothness : 0.08,
chromaSpill: d.chromaSpill !== undefined ? d.chromaSpill : 0.1,
videoBlend: d.videoBlend || 'none',
// ── Carousel (Owl Carousel 2) ──
carouselItems: d.carouselItems ? JSON.parse(JSON.stringify(d.carouselItems)) : [],
carItems: d.carItems !== undefined ? d.carItems : 3,
carItemsTablet: d.carItemsTablet !== undefined ? d.carItemsTablet : 2,
carItemsPhone: d.carItemsPhone !== undefined ? d.carItemsPhone : 1,
carMargin: d.carMargin !== undefined ? d.carMargin : 12,
carStagePadding: d.carStagePadding !== undefined ? d.carStagePadding : 0,
carAutoWidth: d.carAutoWidth || false,
carLoop: d.carLoop !== undefined ? d.carLoop : true,
carCenter: d.carCenter !== undefined ? d.carCenter : true,
carAutoplay: d.carAutoplay || false,
carAutoplayTimeout: d.carAutoplayTimeout !== undefined ? d.carAutoplayTimeout : 4000,
carAutoplayHoverPause: d.carAutoplayHoverPause !== undefined ? d.carAutoplayHoverPause : true,
carSpeed: d.carSpeed !== undefined ? d.carSpeed : 450,
carMouseDrag: d.carMouseDrag !== undefined ? d.carMouseDrag : true,
carNav: d.carNav !== undefined ? d.carNav : true,
carDots: d.carDots !== undefined ? d.carDots : true,
carStart: d.carStart !== undefined ? d.carStart : 0,
// Center emphasis (active item bigger)
carCenterScale: d.carCenterScale !== undefined ? d.carCenterScale : 1.18,
carSideScale: d.carSideScale !== undefined ? d.carSideScale : 0.88,
carSideOpacity: d.carSideOpacity !== undefined ? d.carSideOpacity : 0.55,
carCenterOffsetX: d.carCenterOffsetX !== undefined ? d.carCenterOffsetX : 0,
carCenterOffsetY: d.carCenterOffsetY !== undefined ? d.carCenterOffsetY : 0,
carCenterWidth: d.carCenterWidth || '',
carCenterHeight: d.carCenterHeight || '',
carItemWidth: d.carItemWidth || '',
carItemHeight: d.carItemHeight || '',
carClickToCenter: d.carClickToCenter !== undefined ? d.carClickToCenter : true,
carActiveSlot: d.carActiveSlot !== undefined ? d.carActiveSlot : '',
carActiveZ: d.carActiveZ !== undefined ? d.carActiveZ : 10,
carPadTop: d.carPadTop !== undefined ? d.carPadTop : 0,
carPadBottom: d.carPadBottom !== undefined ? d.carPadBottom : 0,
carDeviceOverrides: d.carDeviceOverrides ? JSON.parse(JSON.stringify(d.carDeviceOverrides)) : { tablet:{}, phone:{}, 'phone-land':{} },
carNavOffsetX: d.carNavOffsetX !== undefined ? d.carNavOffsetX : 0,
carNavOffsetY: d.carNavOffsetY !== undefined ? d.carNavOffsetY : 0,
carNavPrevX: d.carNavPrevX !== undefined ? d.carNavPrevX : 0,
carNavPrevY: d.carNavPrevY !== undefined ? d.carNavPrevY : 0,
carNavNextX: d.carNavNextX !== undefined ? d.carNavNextX : 0,
carNavNextY: d.carNavNextY !== undefined ? d.carNavNextY : 0,
carNavInset: d.carNavInset !== undefined ? d.carNavInset : (d.carNavOffset !== undefined ? d.carNavOffset : 0),
carDotOffsetX: d.carDotOffsetX !== undefined ? d.carDotOffsetX : 0,
carDotOffsetY: d.carDotOffsetY !== undefined ? d.carDotOffsetY : 0,
carItemsPhoneLand: d.carItemsPhoneLand !== undefined ? d.carItemsPhoneLand : '',
carDotPosition: d.carDotPosition || 'below',
carDotBottom: d.carDotBottom !== undefined ? d.carDotBottom : 8,
carResetVisibility: d.carResetVisibility !== undefined ? d.carResetVisibility : true,
carItemMargin: d.carItemMargin || '0',
carCenterPadding: d.carCenterPadding || '',
carCenterMargin: d.carCenterMargin || '',
carCenterRadius: d.carCenterRadius || '',
carCenterBg: d.carCenterBg || '',
carCenterBorder: d.carCenterBorder || '',
carCenterShadow: d.carCenterShadow || '',
carShowFields: d.carShowFields ? d.carShowFields.slice() : [],
carFieldSize: d.carFieldSize !== undefined ? d.carFieldSize : 0.6,
carTitleUnit: d.carTitleUnit || 'rem',
carTextUnit: d.carTextUnit || 'rem',
carFieldUnit: d.carFieldUnit || 'rem',
carFieldColor: d.carFieldColor || '#8a93a8',
carNavSize: d.carNavSize !== undefined ? d.carNavSize : 28,
carNavRadius: d.carNavRadius || '50%',
carNavIconSize: d.carNavIconSize !== undefined ? d.carNavIconSize : 0.8,
carNavBorder: d.carNavBorder || 'none',
carNavOffset: d.carNavOffset !== undefined ? d.carNavOffset : 0,
carNavPrevIcon: d.carNavPrevIcon || 'bi-chevron-left',
carNavNextIcon: d.carNavNextIcon || 'bi-chevron-right',
carDotWidth: d.carDotWidth !== undefined ? d.carDotWidth : 7,
carDotHeight: d.carDotHeight !== undefined ? d.carDotHeight : 7,
carDotRadius: d.carDotRadius || '50%',
carDotGap: d.carDotGap !== undefined ? d.carDotGap : 6,
carDotActiveWidth: d.carDotActiveWidth || '',
carDotActiveHeight: d.carDotActiveHeight || '',
carDotMarginTop: d.carDotMarginTop !== undefined ? d.carDotMarginTop : 6,
carLinks: d.carLinks ? JSON.parse(JSON.stringify(d.carLinks)) : [],
carChangeFunc: d.carChangeFunc || '',
// Item design (replicated for every item)
carItemBg: d.carItemBg || 'rgba(255,255,255,0.05)',
carItemBorder: d.carItemBorder || '1px solid rgba(255,255,255,0.1)',
carItemRadius: d.carItemRadius || '12px',
carItemPadding: d.carItemPadding || '10px',
carItemShadow: d.carItemShadow || '0 6px 20px rgba(0,0,0,0.35)',
carItemAlign: d.carItemAlign || 'center',
carShowImage: d.carShowImage !== undefined ? d.carShowImage : true,
carShowTitle: d.carShowTitle !== undefined ? d.carShowTitle : true,
carShowText: d.carShowText !== undefined ? d.carShowText : true,
carImgHeight: d.carImgHeight || '110px',
carImgFit: d.carImgFit || 'cover',
carImgRadius: d.carImgRadius || '8px',
carFgInset: d.carFgInset !== undefined ? d.carFgInset : 12,
carFgPadding: d.carFgPadding !== undefined ? d.carFgPadding : 0,
carFgWidth: d.carFgWidth || '100%',
carFgHeight: d.carFgHeight || '100%',
carFgAlignH: d.carFgAlignH || 'center',
carFgAlignV: d.carFgAlignV || 'center',
carFgFit: d.carFgFit || 'contain',
carFgPosition: d.carFgPosition || 'center',
carFgRadius: d.carFgRadius || '0',
carFgShadow: d.carFgShadow || '',
carModelX: d.carModelX !== undefined ? d.carModelX : 0,
carModelZ: d.carModelZ !== undefined ? d.carModelZ : 0,
carImgMode: d.carImgMode || 'inline', // 'inline' | 'cover'
carImgPosition: d.carImgPosition || 'center',
carCoverAlign: d.carCoverAlign || 'bottom',
carTitleSize: d.carTitleSize !== undefined ? d.carTitleSize : 0.85,
carTitleColor: d.carTitleColor || '#ffffff',
carTitleWeight: d.carTitleWeight || '700',
carTextSize: d.carTextSize !== undefined ? d.carTextSize : 0.65,
carTextColor: d.carTextColor || '#a0b0c0',
carNavColor: d.carNavColor || '#ffffff',
carNavBg: d.carNavBg || 'rgba(0,0,0,0.4)',
carDotColor: d.carDotColor || 'rgba(255,255,255,0.25)',
carDotActiveColor: d.carDotActiveColor || '#00e5a0',
carItemTemplate: d.carItemTemplate || '',
carItemClick: d.carItemClick || 'none', // none | link | function
carItemFunc: d.carItemFunc || '',
carLinkTarget: d.carLinkTarget || 'newTab',
// ── Hero section ──
heroSlides: d.heroSlides ? JSON.parse(JSON.stringify(d.heroSlides)) : [],
heroTitle: d.heroTitle !== undefined ? d.heroTitle : '',
heroSubtitle: d.heroSubtitle !== undefined ? d.heroSubtitle : '',
heroAutoplay: d.heroAutoplay !== undefined ? d.heroAutoplay : true,
heroInterval: d.heroInterval !== undefined ? d.heroInterval : 6000,
heroTransition: d.heroTransition || 'fade',
heroTransDuration: d.heroTransDuration !== undefined ? d.heroTransDuration : 900,
heroKenBurns: d.heroKenBurns !== undefined ? d.heroKenBurns : false,
heroKenScale: d.heroKenScale !== undefined ? d.heroKenScale : 1.12,
heroStart: d.heroStart !== undefined ? d.heroStart : 0,
heroBgFit: d.heroBgFit || 'cover',
heroBgPosition: d.heroBgPosition || 'center',
heroEmptyBg: d.heroEmptyBg || 'linear-gradient(135deg,#1b2030,#0b0e17)',
heroOverlay: d.heroOverlay || 'linear-gradient(180deg,rgba(0,0,0,0.15),rgba(0,0,0,0.55))',
heroOverlayOpacity: d.heroOverlayOpacity !== undefined ? d.heroOverlayOpacity : 1,
heroAlignH: d.heroAlignH || 'center',
heroAlignV: d.heroAlignV || 'center',
heroPadding: d.heroPadding || '6%',
heroGap: d.heroGap || '10px',
heroMaxWidth: d.heroMaxWidth || '70%',
heroTitleFont: d.heroTitleFont || "'Orbitron',sans-serif",
heroTitleSize: d.heroTitleSize !== undefined ? d.heroTitleSize : 2.4,
heroTitleUnit: d.heroTitleUnit || 'rem',
heroTitleWeight: d.heroTitleWeight || '700',
heroTitleColor: d.heroTitleColor || '#ffffff',
heroTitleLineHeight: d.heroTitleLineHeight || '1.1',
heroTitleSpacing: d.heroTitleSpacing || 'normal',
heroTitleShadow: d.heroTitleShadow || '0 2px 18px rgba(0,0,0,0.5)',
heroSubFont: d.heroSubFont || "'Rajdhani',sans-serif",
heroSubSize: d.heroSubSize !== undefined ? d.heroSubSize : 1,
heroSubUnit: d.heroSubUnit || 'rem',
heroSubWeight: d.heroSubWeight || '400',
heroSubColor: d.heroSubColor || '#d5dce8',
heroSubLineHeight: d.heroSubLineHeight || '1.5',
heroSubShadow: d.heroSubShadow || '0 1px 10px rgba(0,0,0,0.45)',
heroDots: d.heroDots !== undefined ? d.heroDots : true,
heroDotWidth: d.heroDotWidth !== undefined ? d.heroDotWidth : 10,
heroDotHeight: d.heroDotHeight !== undefined ? d.heroDotHeight : 10,
heroDotRadius: d.heroDotRadius || '50%',
heroDotGap: d.heroDotGap !== undefined ? d.heroDotGap : 8,
heroDotBottom: d.heroDotBottom !== undefined ? d.heroDotBottom : 18,
heroDotColor: d.heroDotColor || 'rgba(255,255,255,0.35)',
heroDotActiveColor: d.heroDotActiveColor || '#ffffff',
heroDotActiveWidth: d.heroDotActiveWidth || '',
heroArrows: d.heroArrows !== undefined ? d.heroArrows : false,
heroArrowSize: d.heroArrowSize !== undefined ? d.heroArrowSize : 36,
heroArrowRadius: d.heroArrowRadius || '50%',
heroArrowBg: d.heroArrowBg || 'rgba(0,0,0,0.35)',
heroArrowColor: d.heroArrowColor || '#ffffff',
heroArrowIconSize: d.heroArrowIconSize !== undefined ? d.heroArrowIconSize : 1,
heroArrowInset: d.heroArrowInset !== undefined ? d.heroArrowInset : 12,
heroPrevIcon: d.heroPrevIcon || 'bi-chevron-left',
heroNextIcon: d.heroNextIcon || 'bi-chevron-right',
heroChangeFunc: d.heroChangeFunc || '',
// ── Iframe / embed ──
frameTitle: d.frameTitle || '',
frameAllow: d.frameAllow || 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
frameAllowFullscreen: d.frameAllowFullscreen !== undefined ? d.frameAllowFullscreen : true,
frameLazy: d.frameLazy !== undefined ? d.frameLazy : false,
frameSandbox: d.frameSandbox !== undefined ? d.frameSandbox : false,
frameSandboxRules: d.frameSandboxRules || 'allow-scripts allow-same-origin allow-popups allow-forms',
frameScroll: d.frameScroll !== undefined ? d.frameScroll : true,
frameBorder: d.frameBorder || 'none',
frameBg: d.frameBg || 'rgba(0,0,0,0.2)',
framePreviewOnly: d.framePreviewOnly !== undefined ? d.framePreviewOnly : true,
// ── 3D model (GLB) ──
modelBg: d.modelBg || 'transparent',
modelAutoRotate: d.modelAutoRotate !== undefined ? d.modelAutoRotate : true,
modelRotateSpeed: d.modelRotateSpeed !== undefined ? d.modelRotateSpeed : 2,
modelControls: d.modelControls !== undefined ? d.modelControls : false,
modelZoom: d.modelZoom !== undefined ? d.modelZoom : true,
modelPan: d.modelPan !== undefined ? d.modelPan : false,
modelDragRotate: d.modelDragRotate !== undefined ? d.modelDragRotate : true,
modelDragSpeed: d.modelDragSpeed !== undefined ? d.modelDragSpeed : 1,
modelZoomSpeed: d.modelZoomSpeed !== undefined ? d.modelZoomSpeed : 1,
modelDamping: d.modelDamping !== undefined ? d.modelDamping : true,
modelStopSpinOnInteract: d.modelStopSpinOnInteract !== undefined ? d.modelStopSpinOnInteract : true,
modelResumeSpin: d.modelResumeSpin !== undefined ? d.modelResumeSpin : 0,
modelMinDistance: d.modelMinDistance !== undefined ? d.modelMinDistance : '',
modelMaxDistance: d.modelMaxDistance !== undefined ? d.modelMaxDistance : '',
modelMinPolar: d.modelMinPolar !== undefined ? d.modelMinPolar : '',
modelMaxPolar: d.modelMaxPolar !== undefined ? d.modelMaxPolar : '',
modelAutoFit: d.modelAutoFit !== undefined ? d.modelAutoFit : true,
modelDistance: d.modelDistance !== undefined ? d.modelDistance : 4,
modelFov: d.modelFov !== undefined ? d.modelFov : 45,
modelCamHeight: d.modelCamHeight !== undefined ? d.modelCamHeight : 0.4,
modelTargetY: d.modelTargetY !== undefined ? d.modelTargetY : 0,
modelX: d.modelX !== undefined ? d.modelX : 0,
modelY: d.modelY !== undefined ? d.modelY : 0,
modelZ: d.modelZ !== undefined ? d.modelZ : 0,
modelScale: d.modelScale !== undefined ? d.modelScale : 1,
modelRotX: d.modelRotX !== undefined ? d.modelRotX : 0,
modelRotY: d.modelRotY !== undefined ? d.modelRotY : 0,
modelRotZ: d.modelRotZ !== undefined ? d.modelRotZ : 0,
modelLighting: d.modelLighting || 'studio',
modelLightIntensity: d.modelLightIntensity !== undefined ? d.modelLightIntensity : 1,
modelShadows: d.modelShadows !== undefined ? d.modelShadows : false,
modelHemi: d.modelHemi !== undefined ? d.modelHemi : true,
modelPlayClips: d.modelPlayClips !== undefined ? d.modelPlayClips : true,
modelLiveOnCanvas: d.modelLiveOnCanvas !== undefined ? d.modelLiveOnCanvas : true,
// carousel-wide model options
carModelHeight: d.carModelHeight || '',
carModelBg: d.carModelBg || 'transparent',
carModelAutoRotate: d.carModelAutoRotate !== undefined ? d.carModelAutoRotate : true,
carModelRotateSpeed: d.carModelRotateSpeed !== undefined ? d.carModelRotateSpeed : 2,
carModelControls: d.carModelControls !== undefined ? d.carModelControls : false,
carModelDragRotate: d.carModelDragRotate !== undefined ? d.carModelDragRotate : true,
carModelZoom: d.carModelZoom !== undefined ? d.carModelZoom : false,
carModelPan: d.carModelPan !== undefined ? d.carModelPan : false,
carModelDragSpeed: d.carModelDragSpeed !== undefined ? d.carModelDragSpeed : 1,
carModelZoomSpeed: d.carModelZoomSpeed !== undefined ? d.carModelZoomSpeed : 1,
carModelDamping: d.carModelDamping !== undefined ? d.carModelDamping : true,
carModelStopSpinOnInteract: d.carModelStopSpinOnInteract !== undefined ? d.carModelStopSpinOnInteract : true,
carModelResumeSpin: d.carModelResumeSpin !== undefined ? d.carModelResumeSpin : 0,
carModelMinDistance: d.carModelMinDistance !== undefined ? d.carModelMinDistance : '',
carModelMaxDistance: d.carModelMaxDistance !== undefined ? d.carModelMaxDistance : '',
carModelMinPolar: d.carModelMinPolar !== undefined ? d.carModelMinPolar : '',
carModelMaxPolar: d.carModelMaxPolar !== undefined ? d.carModelMaxPolar : '',
carModelDistance: d.carModelDistance !== undefined ? d.carModelDistance : 4,
carModelFov: d.carModelFov !== undefined ? d.carModelFov : 45,
carModelLighting: d.carModelLighting || 'studio',
carModelLightIntensity: d.carModelLightIntensity !== undefined ? d.carModelLightIntensity : 1,
carModelScale: d.carModelScale !== undefined ? d.carModelScale : 1,
carModelWidth: d.carModelWidth || '',
carModelInset: d.carModelInset !== undefined ? d.carModelInset : 0,
carModelRotX: d.carModelRotX !== undefined ? d.carModelRotX : 0,
carModelRotY: d.carModelRotY !== undefined ? d.carModelRotY : 0,
carModelRotZ: d.carModelRotZ !== undefined ? d.carModelRotZ : 0,
carModelY: d.carModelY !== undefined ? d.carModelY : 0,
carModelCamHeight: d.carModelCamHeight !== undefined ? d.carModelCamHeight : 0.4,
carModelActiveRotX: d.carModelActiveRotX !== undefined ? d.carModelActiveRotX : '',
carModelActiveRotY: d.carModelActiveRotY !== undefined ? d.carModelActiveRotY : '',
carModelActiveRotZ: d.carModelActiveRotZ !== undefined ? d.carModelActiveRotZ : '',
carModelActiveScale: d.carModelActiveScale !== undefined ? d.carModelActiveScale : '',
carModelActiveDistance: d.carModelActiveDistance !== undefined ? d.carModelActiveDistance : '',
carModelSideRotX: d.carModelSideRotX !== undefined ? d.carModelSideRotX : '',
carModelSideRotY: d.carModelSideRotY !== undefined ? d.carModelSideRotY : '',
carModelSideRotZ: d.carModelSideRotZ !== undefined ? d.carModelSideRotZ : '',
carModelSideScale: d.carModelSideScale !== undefined ? d.carModelSideScale : '',
carModelSideDistance: d.carModelSideDistance !== undefined ? d.carModelSideDistance : '',
carModelSpinActiveOnly: d.carModelSpinActiveOnly !== undefined ? d.carModelSpinActiveOnly : false,
carSideGap: d.carSideGap !== undefined ? d.carSideGap : 0,
carSideGapStacks: d.carSideGapStacks !== undefined ? d.carSideGapStacks : true,
// Binding
binding: d.binding || '',
onClick: d.onClick || '',
// Progress bar
barValue: d.barValue || 0,
barColor: d.barColor || '#00e5a0',
barBgColor: d.barBgColor || 'rgba(255,255,255,0.08)',
barHeight: d.barHeight || '12px',
showLabel: d.showLabel || false,
labelText: d.labelText || '',
// Layer
zIndex: 10 + elements.length,
locked: false,
visible: true,
disabled: false, // permanently hidden, ignores all actions
// Hierarchy
parentId: null,
// Events → action chains keyed by event type
events: { click:[], hover:[], mousedown:[], mouseup:[], dblclick:[] },
// Interaction states
hoverBackground: '', hoverColor: '', hoverScale: '', hoverSrc: '', hoverBoxShadow: '',
activeBackground: '', activeColor: '', activeScale: '', activeSrc: '', activeBoxShadow: '',
// Image background mode
bgSize: d.bgSize || '', bgPosition: d.bgPosition || '', bgRepeat: d.bgRepeat || '',
// Responsive: per-device overrides (desktop values are the base on el itself)
responsive: false,
overrides: { tablet:{}, phone:{}, 'phone-land':{} },
// Viewport visibility: which devices this element appears on (empty = all)
showOn: d.showOn || [],
// Control mode for panels: 'none', 'dpad', 'joystick'
controlMode: d.controlMode || 'none',
controlBg: d.controlBg || '',
controlColor: d.controlColor || '',
controlKnob: d.controlKnob || '',
// Custom CSS
customCSS: d.customCSS || '',
// Animate.css (legacy single animation — kept for old projects)
animateClass: d.animateClass || '',
animateDelay: d.animateDelay || '',
animateDuration: d.animateDuration || '',
animateRepeat: d.animateRepeat || '',
// Animation sequence: entrance → attention → exit (all optional, none by default)
animTrigger: d.animTrigger || 'load', // 'load' | 'manual'
animEnterClass: d.animEnterClass || '',
animEnterDelay: d.animEnterDelay !== undefined ? d.animEnterDelay : 0,
animEnterDuration: d.animEnterDuration !== undefined ? d.animEnterDuration : 800,
animEnterRepeat: d.animEnterRepeat || '1',
animAttnClass: d.animAttnClass || '',
animAttnDelay: d.animAttnDelay !== undefined ? d.animAttnDelay : 400,
animAttnDuration: d.animAttnDuration !== undefined ? d.animAttnDuration : 1000,
animAttnRepeat: d.animAttnRepeat || '1',
animExitClass: d.animExitClass || '',
animExitDelay: d.animExitDelay !== undefined ? d.animExitDelay : 1000,
animExitDuration: d.animExitDuration !== undefined ? d.animExitDuration : 800,
animExitRepeat: d.animExitRepeat || '1',
animExitHide: d.animExitHide !== undefined ? d.animExitHide : true,
};
elements.push(el);
renderElement(el);
buildLayerList();
selectElement(id);
return el;
}
function addElementAtCenter(template) {
createElement(template, 50, 50);
showToast(`Added ${template.name}`);
}
function removeElement(id) {
const idx = elements.findIndex(e => e.id === id);
if (idx < 0) return;
// Unparent any children
elements.forEach(e => { if (e.parentId === id) e.parentId = null; });
const dom = viewport.querySelector(`[data-id="${id}"]`);
if (dom) dom.remove();
elements.splice(idx, 1);
if (selectedId === id) { selectedId = null; propsPanel.classList.remove('visible'); canvasArea.classList.add('props-hidden'); }
renderAllElements();
buildLayerList();
showToast('Element removed');
}
function duplicateElement(id) {
const src = elements.find(e => e.id === id);
if (!src) return;
const copy = JSON.parse(JSON.stringify(src));
copy.id = 'el_' + (nextId++);
copy.name = src.name + ' copy';
copy.x = Math.min(src.x + 3, 95);
copy.y = Math.min(src.y + 3, 95);
copy.zIndex = 10 + elements.length;
elements.push(copy);
renderElement(copy);
buildLayerList();
selectElement(copy.id);
showToast('Duplicated');
}
// ══════════════════════════════════════
// RENDER ELEMENT ON CANVAS
// ══════════════════════════════════════
function isEffectivelyVisible(el) {
if (getVal(el,'visible') === false) return false;
if (!el.visible) return false;
if (el.parentId) {
const parent = elements.find(e => e.id === el.parentId);
if (parent) return isEffectivelyVisible(parent);
}
return true;
}
function rerenderChildren(parentId) {
elements.filter(e => e.parentId === parentId).forEach(child => {
renderElement(child);
rerenderChildren(child.id); // recurse for nested panels
});
}
/* ── Multi-stage element animation (entrance → attention → exit) ── */
function uiAnimHasSequence(el) {
return !!(el.animEnterClass || el.animAttnClass || el.animExitClass || el.animateClass);
}
function clearUIAnim(inner) {
if (!inner) return;
if (inner._animTimers) inner._animTimers.forEach(function(t) { window.clearTimeout(t); });
inner._animTimers = [];
Array.prototype.slice.call(inner.classList).forEach(function(c) {
if (c.indexOf('animate__') === 0) inner.classList.remove(c);
});
inner.style.removeProperty('--animate-duration');
inner.style.removeProperty('animation-delay');
inner.style.removeProperty('animation-duration');
inner.style.removeProperty('animation-iteration-count');
inner.style.removeProperty('opacity');
inner.style.removeProperty('pointer-events');
}
/** Plays one animate.css class. `done` is not called for infinite repeats. */
function playUIAnimStage(inner, cls, durationMs, repeat, done) {
if (!inner) return;
if (!cls) { if (done) done(); return; }
Array.prototype.slice.call(inner.classList).forEach(function(c) {
if (c.indexOf('animate__') === 0) inner.classList.remove(c);
});
void inner.offsetWidth; // reflow so the animation can restart
var dur = parseInt(durationMs);
if (!dur || dur < 0) dur = 800;
var infinite = (String(repeat) === 'infinite');
var rep = infinite ? 'infinite' : (parseInt(repeat) || 1);
inner.style.setProperty('--animate-duration', (dur / 1000) + 's');
inner.style.animationDuration = (dur / 1000) + 's';
inner.style.animationIterationCount = rep;
inner.classList.add('animate__animated', cls.indexOf('animate__') === 0 ? cls : 'animate__' + cls);
if (infinite || !done) return;
var t = window.setTimeout(done, dur * rep + 30);
(inner._animTimers = inner._animTimers || []).push(t);
}
/**
* Runs the element's animation sequence.
* stage: 'all' (default) | 'entrance' | 'attention' | 'exit' | 'stop'
*/
function runUIAnimSequence(inner, el, stage) {
if (!inner || !el) return;
stage = stage || 'all';
clearUIAnim(inner);
if (stage === 'stop') return;
inner._animTimers = inner._animTimers || [];
// Legacy single-animation projects behave like an entrance
var enterCls = el.animEnterClass || (!el.animAttnClass && !el.animExitClass ? el.animateClass : '') || '';
var enterDur = el.animEnterDuration !== undefined && el.animEnterDuration !== ''
? el.animEnterDuration
: (parseFloat(el.animateDuration) ? parseFloat(el.animateDuration) * 1000 : 800);
var enterDelay = el.animEnterDelay !== undefined && el.animEnterDelay !== ''
? el.animEnterDelay
: (parseFloat(el.animateDelay) ? parseFloat(el.animateDelay) * 1000 : 0);
var enterRep = el.animEnterRepeat || (el.animateRepeat && !el.animEnterClass ? el.animateRepeat : 1);
function wait(ms, fn) {
var t = window.setTimeout(fn, Math.max(0, parseInt(ms) || 0));
inner._animTimers.push(t);
return t;
}
function doExit() {
if (!el.animExitClass) return;
wait(el.animExitDelay, function() {
playUIAnimStage(inner, el.animExitClass, el.animExitDuration, el.animExitRepeat || 1, function() {
if (el.animExitHide !== false) {
inner.style.opacity = '0';
inner.style.pointerEvents = 'none';
}
});
});
}
function doAttention(next) {
if (!el.animAttnClass) { if (next) next(); return; }
wait(el.animAttnDelay, function() {
var infinite = String(el.animAttnRepeat) === 'infinite';
playUIAnimStage(inner, el.animAttnClass, el.animAttnDuration, el.animAttnRepeat || 1, infinite ? null : next);
if (infinite && next) next(); // exit is timed from when the loop starts
});
}
// While an entrance is waiting out its delay, keep the element invisible —
// otherwise it pops in first and only then animates.
function armEntrance(then) {
var d = Math.max(0, parseInt(enterDelay) || 0);
if (enterCls && d > 0) inner.style.opacity = '0';
wait(d, function() {
inner.style.removeProperty('opacity');
then();
});
}
if (stage === 'entrance') { armEntrance(function() { playUIAnimStage(inner, enterCls, enterDur, enterRep, null); }); return; }
if (stage === 'attention') { doAttention(null); return; }
if (stage === 'exit') { doExit(); return; }
// Full sequence
if (enterCls) {
armEntrance(function() {
playUIAnimStage(inner, enterCls, enterDur, enterRep, function() { doAttention(doExit); });
});
} else {
doAttention(doExit);
}
}
/** Visible-item count for the builder's current device preview */
function carItemsForDevice(el) {
const desktop = Math.max(1, parseInt(el.carItems) || 3);
const tablet = Math.max(1, parseInt(el.carItemsTablet) || desktop);
if (deviceMode === 'phone') return Math.max(1, parseInt(el.carItemsPhone) || 1);
if (deviceMode === 'tablet') return tablet;
if (deviceMode === 'phone-land') return el.carItemsPhoneLand ? Math.max(1, parseInt(el.carItemsPhoneLand)) : tablet;
return desktop;
}
window.__uiCarouselItemsResolver = el => carItemsForDevice(el);
// ── Carousel helpers (Owl Carousel 2) — shared shape with runtimes ──
function escAttr(s) { return String(s == null ? '' : s).replace(/"/g, '"'); }
/** Overlay image: an absolutely placed box (margin + padding) holding a sized image */
function carFgHTML(el, item, boxStyle, isCover) {
var pad = item.fgPadding !== undefined && item.fgPadding !== '' ? item.fgPadding : el.carFgPadding;
var w = item.fgWidth !== undefined && item.fgWidth !== '' ? item.fgWidth : el.carFgWidth;
var hgt = item.fgHeight !== undefined && item.fgHeight !== '' ? item.fgHeight : el.carFgHeight;
var fit = item.fgFit || el.carFgFit || 'contain';
if (fit === 'stretch') fit = 'fill';
var imgStyle = 'width:' + cssLen(w, '100%') + ';height:' + cssLen(hgt, '100%') +
';object-fit:' + fit + ';';
return '<div class="car-fg-box' + (isCover ? ' car-fg-cover' : '') + '" style="' + boxStyle +
'padding:' + cssLen(pad, '0px') + ';">' +
'<img class="car-fg" src="' + escAttr(item.foreground) + '" style="' + imgStyle + '" draggable="false"></div>';
}
function carouselItemInnerHTML(el, item) {
if (el.carItemTemplate) {
var out = el.carItemTemplate
.replace(/\{image\}/g, item.image || '')
.replace(/\{title\}/g, item.title || '')
.replace(/\{text\}/g, item.text || '')
.replace(/\{link\}/g, item.link || '');
// Any custom field can be used as {fieldName}
if (item.fields) {
Object.keys(item.fields).forEach(function(k) {
out = out.split('{' + k + '}').join(item.fields[k] == null ? '' : item.fields[k]);
});
}
return out;
}
var h = '';
var cover = el.carImgMode === 'cover';
// What sits on top of the card image: a 3D model, another image, or nothing
var layer = item.layer || (item.model ? 'model' : (item.foreground ? 'image' : 'none'));
var hasModel = layer === 'model' && item.model;
var hasFg = layer === 'image' && item.foreground;
var fgInset = item.fgInset !== undefined && item.fgInset !== '' ? item.fgInset : el.carFgInset;
var fgStyle = 'inset:' + cssLen(fgInset, '0px') + ';';
if (cover) {
// The image fills the whole card and everything else stacks on top of it
if (el.carShowImage !== false && item.image) {
h += '<img class="car-bgimg" src="' + escAttr(item.image) + '" alt="' + escAttr(item.title) + '" draggable="false">';
}
if (hasModel) h += '<div class="car-model car-model-cover" data-model="' + escAttr(item.model) + '"></div>';
else if (hasFg) h += carFgHTML(el, item, fgStyle, true);
} else if (hasModel || hasFg) {
// Background image keeps its normal size; the model / overlay image is layered over it,
// so a card with either is exactly the same size as one without.
h += '<div class="car-media">';
if (el.carShowImage !== false && item.image) {
h += '<img class="car-img" src="' + escAttr(item.image) + '" alt="' + escAttr(item.title) + '" draggable="false">';
}
if (hasModel) h += '<div class="car-model" data-model="' + escAttr(item.model) + '"></div>';
else h += carFgHTML(el, item, fgStyle, false);
h += '</div>';
} else if (el.carShowImage !== false && item.image) {
h += '<img class="car-img" src="' + escAttr(item.image) + '" alt="' + escAttr(item.title) + '" draggable="false">';
}
if (el.carShowTitle !== false && item.title) h += '<div class="car-title">' + item.title + '</div>';
if (el.carShowText !== false && item.text) h += '<div class="car-text">' + item.text + '</div>';
// Extra fields marked "show in card"
(el.carShowFields || []).forEach(function(key) {
var v = item.fields ? item.fields[key] : undefined;
if (v !== undefined && v !== '') h += '<div class="car-field car-field-' + escAttr(key).replace(/[^\w-]/g, '') + '">' + v + '</div>';
});
return h;
}
/** Active-item settings can differ per device; these are the keys that support it */
var CAR_DEVICE_PROPS = ['carActiveSlot','carCenterScale','carSideScale','carSideOpacity',
'carCenterOffsetX','carCenterOffsetY','carCenterWidth','carCenterHeight',
'carCenterPadding','carCenterMargin','carCenterRadius',
// breathing room around the strip, per-device
'carPadTop','carPadBottom',
// arrow + dot placement is per-device too
'carNavInset','carNavOffsetX','carNavOffsetY',
'carNavPrevX','carNavPrevY','carNavNextX','carNavNextY',
'carDotOffsetX','carDotOffsetY','carDotMarginTop','carDotBottom'];
/** el merged with its overrides for `device` ('tablet' | 'phone' | 'phone-land') */
function carMergedFor(el, device) {
var ov = (device && el.carDeviceOverrides) ? el.carDeviceOverrides[device] : null;
if (!ov) return el;
var m = {}, k;
for (k in el) m[k] = el[k];
CAR_DEVICE_PROPS.forEach(function(prop) {
if (ov[prop] !== undefined && ov[prop] !== null && ov[prop] !== '') m[prop] = ov[prop];
else if (prop === 'carActiveSlot' && ov[prop] === '') m[prop] = '';
});
return m;
}
function carHasDeviceOverride(el, device) {
var ov = (el.carDeviceOverrides || {})[device];
if (!ov) return false;
return CAR_DEVICE_PROPS.some(function(p) { return ov[p] !== undefined && ov[p] !== null; });
}
/** Explicit active-slot mode: the active card sits in a chosen visible slot
* instead of being geometrically centred (e.g. 1 card left, 2 cards right). */
function carActiveSlotOf(el, perView) {
var v = el.carActiveSlot;
if (v === '' || v === undefined || v === null || v === 'auto') return null;
var n = parseInt(v);
if (isNaN(n)) return null;
return Math.max(0, Math.min((parseInt(perView) || 3) - 1, n));
}
function carUsesSlotMode(el) {
return carActiveSlotOf(el, el.carItems) !== null;
}
/** Media queries matching the builder's device modes */
var CAR_DEVICE_QUERIES = {
tablet: ['@media (max-width:1024px) and (min-width:481px)'],
phone: ['@media (max-width:480px) and (orientation:portrait)'],
'phone-land': ['@media (orientation:landscape) and (max-height:500px)', '@media (max-width:768px) and (orientation:landscape)']
};
/** The active-item + stage-shift rules for one configuration (base or a device override) */
function carActiveRules(el, cfg, itemCount) {
var s = '.car-' + el.id;
var cScale = cfg.carCenterScale !== undefined ? cfg.carCenterScale : 1;
var sScale = cfg.carSideScale !== undefined ? cfg.carSideScale : 1;
var sOpac = cfg.carSideOpacity !== undefined ? cfg.carSideOpacity : 1;
var cx = parseFloat(cfg.carCenterOffsetX) || 0;
var cy = parseFloat(cfg.carCenterOffsetY) || 0;
var grow = Math.max(0, (cScale - 1) * 60);
var padTop = Math.round(grow + Math.max(0, -cy) + (parseFloat(cfg.carPadTop) || 0));
var padBottom = Math.round(grow + Math.max(0, cy) + (parseFloat(cfg.carPadBottom) || 0));
var centerSizeCss = '';
if (cfg.carCenterWidth) centerSizeCss += 'width:' + cssLen(cfg.carCenterWidth) + '; margin-left:auto; margin-right:auto;';
if (cfg.carCenterHeight) centerSizeCss += 'height:' + cssLen(cfg.carCenterHeight) + ';';
var centerSel = s + ' .owl-item.center .car-item, ' + s + ' .owl-item.car-active .car-item, ' + s + ' .car-fallback .car-item.center';
var out = [
s + ' .owl-stage-outer { padding:' + padTop + 'px 0 ' + padBottom + 'px; }',
s + ' .car-item { transform:translateX(var(--car-shift, 0px)) scale(' + sScale + '); opacity:' + sOpac + '; }',
centerSel + ' { transform:translate(' + cx + 'px, ' + cy + 'px) scale(' + cScale + '); opacity:1; ' + centerSizeCss +
(cfg.carCenterPadding ? ' padding:' + cfg.carCenterPadding + ';' : '') +
(cfg.carCenterMargin ? ' margin:' + cfg.carCenterMargin + ';' : '') +
(cfg.carCenterRadius ? ' border-radius:' + cfg.carCenterRadius + ';' : '') +
(cfg.carCenterBg ? ' background:' + cfg.carCenterBg + ';' : '') +
(cfg.carCenterBorder ? ' border:' + cfg.carCenterBorder + ';' : '') +
(cfg.carCenterShadow ? ' box-shadow:' + cfg.carCenterShadow + ';' : '') + ' }'
];
// Off-balance layout: push the whole strip right by whole slots
var c = Math.max(1, parseInt(itemCount) || 3);
var slot = carActiveSlotOf(cfg, c);
if (slot !== null) out.push(s + ' .owl-stage { margin-left:' + ((slot / c) * 100).toFixed(4) + '%; }');
else out.push(s + ' .owl-stage { margin-left:0; }');
return out.join('\n');
}
/** Arrow + dot placement for one configuration (base or a device override) */
function carPositionRules(el, cfg) {
var s = '.car-' + el.id;
var num = function(v) { return parseFloat(v) || 0; };
var inset = num(cfg.carNavInset !== undefined ? cfg.carNavInset : cfg.carNavOffset);
var prevT = 'translate(' + num(cfg.carNavPrevX) + 'px, ' + num(cfg.carNavPrevY) + 'px)';
var nextT = 'translate(' + num(cfg.carNavNextX) + 'px, ' + num(cfg.carNavNextY) + 'px)';
var dotT = 'translate(' + num(cfg.carDotOffsetX) + 'px, ' + num(cfg.carDotOffsetY) + 'px)';
var topSpace = (cfg.carDotMarginTop === '' || cfg.carDotMarginTop === undefined) ? 6 : num(cfg.carDotMarginTop);
var out = [
s + ' .owl-nav { position:absolute; top:calc(50% + ' + num(cfg.carNavOffsetY) + 'px); left:' + num(cfg.carNavOffsetX) +
'px; width:100%; margin:0 !important; transform:translateY(-50%); display:flex; justify-content:space-between; pointer-events:none; box-sizing:border-box; }',
s + ' .owl-nav button.owl-prev { margin:0 ' + inset + 'px !important; transform:' + prevT + '; }',
s + ' .owl-nav button.owl-next { margin:0 ' + inset + 'px !important; transform:' + nextT + '; }',
// keep the nudge when hovering, or the button would jump back to its default spot
s + ' .owl-nav button.owl-prev:hover { transform:' + prevT + ' scale(1.12); }',
s + ' .owl-nav button.owl-next:hover { transform:' + nextT + ' scale(1.12); }'
];
if (cfg.carDotPosition === 'overlay') {
out.push(s + ' { position:relative; }');
out.push(s + ' .owl-dots { position:absolute; left:0; right:0; bottom:' +
((cfg.carDotBottom === '' || cfg.carDotBottom === undefined) ? 8 : num(cfg.carDotBottom)) +
'px; z-index:3; margin:0 !important; transform:' + dotT + '; }');
} else {
if (cfg.carDotPosition === 'above') {
out.push(s + ' .owl-dots { order:-1; }');
out.push(s + ' .car-wrap { display:flex; flex-direction:column; }');
}
// !important because owl.theme.default.css has a more specific rule
// (`.owl-theme .owl-nav.disabled + .owl-dots { margin-top:10px }`) that would
// otherwise pin the spacing and make "Top space" do nothing.
out.push(s + ' .owl-dots { margin-top:' + topSpace + 'px !important; position:static; transform:' + dotT + '; }');
}
return out.join('\n');
}
/** "160" → "160px"; leaves "160px", "50%", "auto", "" alone */
function cssLen(v, fallback) {
if (v === undefined || v === null || v === '') return fallback;
var str = String(v).trim();
return /^-?\d*\.?\d+$/.test(str) ? str + 'px' : str;
}
function buildCarouselCSS(el) {
var s = '.car-' + el.id;
// The builder previews one device at a time and can't rely on media queries,
// so it asks for that device's values to be baked into the base rules.
var previewDevice = (typeof window !== 'undefined') ? window.__uiCarouselDevice : null;
var base = previewDevice ? carMergedFor(el, previewDevice) : el;
var per = parseInt(el.carItems) || 3;
var gap = parseInt(el.carMargin) || 0;
// Item box sizing
var sizeCss = '';
if (el.carItemWidth) sizeCss += 'width:' + cssLen(el.carItemWidth) + '; margin-left:auto; margin-right:auto;';
if (el.carItemHeight) sizeCss += 'height:' + cssLen(el.carItemHeight) + ';';
// Active-item rules: base first, then a block per device that overrides them
var basePer = previewDevice
? (previewDevice === 'phone' ? (parseInt(el.carItemsPhone) || 1)
: previewDevice === 'tablet' ? (parseInt(el.carItemsTablet) || per)
: (parseInt(el.carItemsPhoneLand) || parseInt(el.carItemsTablet) || per))
: per;
var activeCss = [carActiveRules(el, base, basePer), carPositionRules(el, base)];
if (!previewDevice) {
var counts = {
tablet: parseInt(el.carItemsTablet) || per,
phone: parseInt(el.carItemsPhone) || 1,
'phone-land': parseInt(el.carItemsPhoneLand) || parseInt(el.carItemsTablet) || per
};
Object.keys(CAR_DEVICE_QUERIES).forEach(function(dev) {
var cfg = carMergedFor(el, dev);
if (cfg === el && counts[dev] === per) return; // nothing device-specific
// (cfg !== el means this device carries at least one override)
var rules = carActiveRules(el, cfg, counts[dev]) + '\n' + carPositionRules(el, cfg);
CAR_DEVICE_QUERIES[dev].forEach(function(q) { activeCss.push(q + ' {\n' + rules + '\n}'); });
});
}
var slotCss = activeCss.join('\n');
return [
s + ' { width:100%; }',
// Owl ships `.owl-carousel { display:none }` and only reveals it once it has
// initialised — if anything goes wrong the whole carousel vanishes. Keep it shown.
s + ' .owl-carousel { display:block; }',
s + ' .owl-carousel .owl-item { float:left; }',
s + ' .owl-stage { display:flex; align-items:center; }',
// z-index only applies to positioned elements — without this the *next* card in
// DOM order paints over the enlarged active one.
s + ' .owl-item { display:flex; align-items:center; position:relative; z-index:1; }',
s + ' .owl-item.center, ' + s + ' .owl-item.car-active { z-index:' + (parseInt(el.carActiveZ) || 10) + '; }',
s + ' .car-item { position:relative; }',
s + ' .car-fallback .car-item.center { z-index:' + (parseInt(el.carActiveZ) || 10) + '; }',
s + ' .car-item { background:' + (el.carItemBg || 'transparent') + '; border:' + (el.carItemBorder || 'none') +
'; border-radius:' + (el.carItemRadius || '0') + '; padding:' + (el.carItemPadding || '0') +
'; margin:' + (el.carItemMargin || '0') +
'; box-shadow:' + (el.carItemShadow || 'none') + '; text-align:' + (el.carItemAlign || 'center') +
'; overflow:hidden; box-sizing:border-box; ' + sizeCss +
' transition:transform 0.35s ease, opacity 0.35s ease; }',
s + ' .car-media { position:relative; width:' + cssLen(el.carModelWidth, '100%') + '; height:' +
cssLen(el.carModelHeight, cssLen(el.carImgHeight, '140px')) + '; margin:0 auto; }',
// The canvas covers the media box, so adding a model never resizes the card
s + ' .car-media .car-model { position:absolute; inset:' + (parseFloat(el.carModelInset) || 0) + 'px; }',
s + ' .car-media .car-fg-box { position:absolute; }',
s + ' .car-media .car-img { position:absolute; inset:0; width:100%; height:100%; }',
s + ' .car-model { border-radius:' + (el.carImgRadius || '0') + '; overflow:hidden; z-index:1; }',
// Full-bleed image mode: the picture covers the card, content sits above it
s + ' .car-bgimg { position:absolute; inset:0; width:100%; height:100%; object-fit:' +
(el.carImgFit || 'cover') + '; object-position:' + (el.carImgPosition || 'center') +
'; border-radius:inherit; z-index:0; display:block; }',
s + ' .car-model-cover { position:absolute; inset:' + (parseFloat(el.carModelInset) || 0) + 'px; z-index:1; }',
// Overlay image: sits above the card image, its inset acts as a margin from the edges
// Overlay image: the box handles margin/padding/alignment, the image its own size + fit
s + ' .car-fg-box { position:absolute; inset:' + cssLen(el.carFgInset, '0px') + '; padding:' + cssLen(el.carFgPadding, '0px') +
'; box-sizing:border-box; display:flex; z-index:1; pointer-events:none;' +
' align-items:' + (el.carFgAlignV === 'top' ? 'flex-start' : el.carFgAlignV === 'bottom' ? 'flex-end' : 'center') +
'; justify-content:' + (el.carFgAlignH === 'left' ? 'flex-start' : el.carFgAlignH === 'right' ? 'flex-end' : 'center') + '; }',
s + ' .car-fg { max-width:100%; max-height:100%; object-position:' + (el.carFgPosition || 'center') +
'; border-radius:' + (el.carFgRadius || '0') + '; display:block; ' +
(el.carFgShadow ? 'filter:drop-shadow(' + el.carFgShadow + ');' : '') + ' }',
s + ' .car-item > .car-title, ' + s + ' .car-item > .car-text, ' + s + ' .car-item > .car-field { position:relative; z-index:2; }',
(el.carImgMode === 'cover' ? s + ' .car-item { padding:' + (el.carItemPadding || '0') + '; display:flex; flex-direction:column; justify-content:' +
(el.carCoverAlign === 'top' ? 'flex-start' : el.carCoverAlign === 'center' ? 'center' : 'flex-end') + '; }' : ''),
s + ' .car-img { width:100%; height:' + cssLen(el.carImgHeight, 'auto') + '; object-fit:' + (el.carImgFit || 'cover') +
'; border-radius:' + (el.carImgRadius || '0') + '; display:block; }',
s + ' .car-title { font-size:' + (el.carTitleSize !== undefined ? el.carTitleSize : 0.85) + (el.carTitleUnit || 'rem') + '; color:' + (el.carTitleColor || '#fff') +
'; font-weight:' + (el.carTitleWeight || '700') + '; margin-top:6px; line-height:1.3; }',
s + ' .car-text { font-size:' + (el.carTextSize !== undefined ? el.carTextSize : 0.65) + (el.carTextUnit || 'rem') + '; color:' + (el.carTextColor || '#a0b0c0') +
'; margin-top:3px; line-height:1.4; }',
s + ' .car-field { font-size:' + (el.carFieldSize !== undefined ? el.carFieldSize : 0.6) + (el.carFieldUnit || 'rem') + '; color:' + (el.carFieldColor || '#8a93a8') +
'; margin-top:2px; line-height:1.4; }',
s + ' .owl-nav button.owl-prev, ' + s + ' .owl-nav button.owl-next { pointer-events:auto; color:' + (el.carNavColor || '#fff') +
' !important; background:' + (el.carNavBg || 'rgba(0,0,0,0.4)') + ' !important; width:' + (parseFloat(el.carNavSize) || 28) +
'px; height:' + (parseFloat(el.carNavSize) || 28) + 'px; border-radius:' + (el.carNavRadius || '50%') +
'; border:' + (el.carNavBorder || 'none') + ' !important; display:flex; align-items:center; justify-content:center; font-size:' +
(el.carNavIconSize !== undefined ? el.carNavIconSize : 0.8) + 'rem !important; transition:transform 0.15s, background 0.15s; }',
// Owl adds `disabled` to the nav/dots wrapper whenever every slide already fits on
// screen, and its core CSS hides `.disabled` outright — which silently removes the
// arrows and dots as soon as "Visible" reaches the number of slides. With loop on
// they still work, so keep them shown when the user asked for them.
(el.carNav !== false ? s + ' .owl-nav.disabled { display:flex !important; }' : ''),
(el.carDots !== false ? s + ' .owl-dots.disabled { display:flex !important; }' : ''),
s + ' .owl-dots { display:flex; justify-content:center; align-items:center; }',
s + ' .owl-dots .owl-dot span { background:' + (el.carDotColor || 'rgba(255,255,255,0.25)') + ' !important; width:' +
(parseFloat(el.carDotWidth) || 7) + 'px; height:' + (parseFloat(el.carDotHeight) || 7) + 'px; border-radius:' +
(el.carDotRadius || '50%') + ' !important; margin:0 ' + ((parseFloat(el.carDotGap) || 6) / 2) + 'px !important; transition:all 0.2s; }',
s + ' .owl-dots .owl-dot.active span { background:' + (el.carDotActiveColor || '#00e5a0') + ' !important;' +
(el.carDotActiveWidth ? ' width:' + parseFloat(el.carDotActiveWidth) + 'px !important;' : '') +
(el.carDotActiveHeight ? ' height:' + parseFloat(el.carDotActiveHeight) + 'px !important;' : '') + ' }',
s + ' .car-fallback { display:flex; gap:' + gap + 'px; align-items:center; justify-content:center; width:100%; overflow:hidden; }',
s + ' .car-fallback .car-item { flex:0 0 calc((100% - ' + (gap * (per - 1)) + 'px) / ' + per + '); }',
(el.carItemClick && el.carItemClick !== 'none') ? s + ' .car-item { cursor:pointer; }' : '',
slotCss
].join('\n');
}
function applyCarouselCSS(el) {
var st = document.getElementById('car-style-' + el.id);
if (!st) { st = document.createElement('style'); st.id = 'car-style-' + el.id; document.head.appendChild(st); }
st.textContent = buildCarouselCSS(el);
}
function owlAvailable() {
return typeof window.jQuery !== 'undefined' && window.jQuery.fn && typeof window.jQuery.fn.owlCarousel === 'function';
}
/** Visible item count for the current viewport — mirrors the builder's device modes.
* window.__uiCarouselForceItems lets the builder pin a count for its device preview. */
function carItemsForViewport(el) {
// The builder previews devices by resizing its canvas, not the window, so it installs
// a resolver that answers from the selected device instead of window.innerWidth.
if (typeof window !== 'undefined' && typeof window.__uiCarouselItemsResolver === 'function') {
try {
var r = parseInt(window.__uiCarouselItemsResolver(el));
if (r > 0) return r;
} catch(e) {}
}
var forced = (typeof window !== 'undefined') ? window.__uiCarouselForceItems : null;
if (forced) return Math.max(1, parseInt(forced) || 1);
var desktop = Math.max(1, parseInt(el.carItems) || 3);
var tablet = Math.max(1, parseInt(el.carItemsTablet) || desktop);
var phone = Math.max(1, parseInt(el.carItemsPhone) || 1);
var land = el.carItemsPhoneLand ? Math.max(1, parseInt(el.carItemsPhoneLand)) : tablet;
var w = (typeof window !== 'undefined' && window.innerWidth) ? window.innerWidth : 1280;
var h = (typeof window !== 'undefined' && window.innerHeight) ? window.innerHeight : 800;
var landscape = w > h;
// Phones held sideways are wide but short (844×390, 932×430…), so height matters
// more than width for spotting them.
if (landscape && (w <= 768 || (h <= 500 && w <= 1200))) return land;
if (w <= 480) return phone;
if (w <= 1024) return tablet;
return desktop;
}
function owlOptionsFor(el, live) {
var per = carItemsForViewport(el);
return {
items: per,
margin: parseInt(el.carMargin) || 0,
loop: el.carLoop !== false,
center: carUsesSlotMode(el) ? false : !!el.carCenter,
autoWidth: !!el.carAutoWidth,
stagePadding: parseInt(el.carStagePadding) || 0,
nav: el.carNav !== false,
dots: el.carDots !== false,
dotsEach: 1, // one dot per item (Owl otherwise draws one dot per "page",
// which gives a single dot when every item is already visible)
navText: ['<i class="bi ' + (el.carNavPrevIcon || 'bi-chevron-left') + '"></i>', '<i class="bi ' + (el.carNavNextIcon || 'bi-chevron-right') + '"></i>'],
autoplay: !!(live && el.carAutoplay),
autoplayTimeout: parseInt(el.carAutoplayTimeout) || 4000,
autoplayHoverPause: el.carAutoplayHoverPause !== false,
smartSpeed: parseInt(el.carSpeed) || 450,
mouseDrag: live ? el.carMouseDrag !== false : false,
touchDrag: live ? el.carMouseDrag !== false : false,
pullDrag: false,
rewind: el.carLoop === false,
startPosition: parseInt(el.carStart) || 0,
// One bucket only: the count is resolved by carItemsForViewport(), which understands
// portrait/landscape. Owl's own breakpoints can't express orientation.
responsive: { 0: { items: per } }
};
}
/* ── Linked elements: update other UI elements when the active item changes ── */
function carouselTargetDom(id) {
if (!id) return null;
try { if (typeof gameUIElements !== 'undefined' && gameUIElements && gameUIElements[id]) return gameUIElements[id].dom; } catch(e) {}
try { if (typeof viewport !== 'undefined' && viewport) return viewport.querySelector('[data-id="' + id + '"]'); } catch(e) {}
return document.getElementById('gui-' + id);
}
function carouselFieldValue(item, field, index) {
if (!item) return '';
if (field === 'index') return String(index + 1);
if (field && field.indexOf('field:') === 0) {
var key = field.slice(6);
return (item.fields && item.fields[key] !== undefined) ? item.fields[key] : '';
}
return item[field || 'title'] !== undefined ? item[field || 'title'] : '';
}
/** Tags the current .owl-item with `car-active` (Owl only adds `center` in centre mode) */
function markCarouselActive(el, wrap, $t) {
if (!wrap) return;
var nodes = wrap.querySelectorAll('.owl-item');
if (!nodes.length) return;
var target = wrap.querySelector('.owl-item.center');
if (!target) {
var pos = -1;
try {
var inst = $t && $t.data ? $t.data('owl.carousel') : null;
if (inst) pos = inst.current();
} catch(e) {}
if (pos >= 0 && nodes[pos]) target = nodes[pos];
}
Array.prototype.forEach.call(nodes, function(n) { n.classList.remove('car-active'); });
if (target) target.classList.add('car-active');
applyCarouselSpread(el, wrap, nodes, target);
}
/** Pushes the side cards away from the active one and poses their 3D models */
function applyCarouselSpread(el, wrap, nodes, activeNode) {
nodes = nodes || wrap.querySelectorAll('.owl-item');
if (!nodes.length) return;
var activeIdx = -1;
Array.prototype.forEach.call(nodes, function(n, i) { if (n === activeNode) activeIdx = i; });
var gap = parseFloat(el.carSideGap) || 0;
var stack = el.carSideGapStacks !== false; // 2nd neighbour moves twice as far
Array.prototype.forEach.call(nodes, function(n, i) {
var card = n.querySelector('.car-item');
if (!card) return;
var shift = 0;
if (activeIdx >= 0 && i !== activeIdx && gap) {
var dist = i - activeIdx;
var steps = stack ? Math.abs(dist) : 1;
shift = (dist < 0 ? -1 : 1) * gap * steps;
}
card.style.setProperty('--car-shift', shift + 'px');
// Per-card 3D pose: the active card can sit differently from the side ones
var host = n.querySelector('.car-model .ui-model');
if (host && host._modelApi && host._modelApi.setPose) {
var isActive = (i === activeIdx);
var item = null;
var ci = card.dataset ? parseInt(card.dataset.index) : NaN;
if (!isNaN(ci)) item = (el.carouselItems || [])[ci];
var pose = {
rotX: pickNum(isActive ? el.carModelActiveRotX : el.carModelSideRotX, item && item.modelRotX, el.carModelRotX, 0),
rotY: pickNum(isActive ? el.carModelActiveRotY : el.carModelSideRotY, item && item.modelRotY, el.carModelRotY, 0),
rotZ: pickNum(isActive ? el.carModelActiveRotZ : el.carModelSideRotZ, item && item.modelRotZ, el.carModelRotZ, 0),
scale: pickNum(isActive ? el.carModelActiveScale : el.carModelSideScale, item && item.modelScale, el.carModelScale, 1),
distance: pickNum(isActive ? el.carModelActiveDistance : el.carModelSideDistance, item && item.modelDistance, el.carModelDistance, 4)
};
host._modelApi.setPose(pose);
var spins = carItemSpins(el, item, false);
host._modelApi.setSpin(el.carModelSpinActiveOnly ? (isActive && spins) : spins);
}
});
}
/** Whether an item's model should spin — per item, falling back to the carousel */
function carItemSpins(el, item, atCreation) {
var v = item ? item.modelAutoRotate : undefined;
var on = (v === undefined || v === null || v === '') ? (el.carModelAutoRotate !== false) : (v === true || v === 'true');
// "spin active only" is applied per card once we know which one is active
if (atCreation && el.carModelSpinActiveOnly) return false;
return on;
}
/** First value that is actually set */
function pickNum() {
for (var i = 0; i < arguments.length; i++) {
var v = arguments[i];
if (v !== undefined && v !== null && v !== '' && !isNaN(parseFloat(v))) return parseFloat(v);
}
return 0;
}
function carouselActiveIndex(wrap) {
if (!wrap) return 0;
var node = wrap.querySelector('.owl-item.center .car-item')
|| wrap.querySelector('.owl-item.car-active .car-item')
|| wrap.querySelector('.owl-item.active .car-item')
|| wrap.querySelector('.car-item.center')
|| wrap.querySelector('.car-item');
if (!node) return 0;
var i = parseInt(node.dataset.index);
return isNaN(i) ? 0 : i;
}
/** Base (designed) visibility of another element, so per-item show/hide can be reset */
function carouselTargetBaseVisible(id) {
try { if (typeof gameUIElements !== 'undefined' && gameUIElements && gameUIElements[id]) return gameUIElements[id].data.visible !== false; } catch(e) {}
try {
if (typeof elements !== 'undefined' && elements && elements.find) {
var m = elements.find(function(o) { return o.id === id; });
if (m) return m.visible !== false;
}
} catch(e) {}
return true;
}
function carouselSetTargetVisible(dom, show) {
if (!dom) return;
if (dom.classList.contains('gui-el')) {
dom.classList.toggle('gui-hidden', !show);
if (show) dom.style.removeProperty('display');
} else {
dom.style.display = show ? '' : 'none';
dom.classList.toggle('el-hidden', !show);
}
}
/** Applies one action (shared by carousel links and per-item triggers) */
function applyCarouselAction(dom, action, value) {
if (!dom) return;
var inner = dom.querySelector('.gui-inner') || dom.querySelector('.ui-el-inner') || dom.querySelector('.el-inner') || dom.firstElementChild || dom;
switch (action || 'setText') {
case 'setText': inner.textContent = value; break;
case 'setHTML': inner.innerHTML = value; break;
case 'setImage': {
var img = inner.querySelector('img');
if (img) img.src = value;
else {
inner.style.backgroundImage = value ? 'url(' + value + ')' : 'none';
inner.style.backgroundSize = inner.style.backgroundSize || 'cover';
inner.style.backgroundPosition = inner.style.backgroundPosition || 'center';
}
break;
}
case 'setBackground':
inner.style.backgroundImage = value ? 'url(' + value + ')' : 'none';
inner.style.backgroundSize = 'cover';
inner.style.backgroundPosition = 'center';
break;
case 'setVideo': {
var vid = inner.querySelector('video');
if (vid && value) { vid.src = value; vid.load(); }
break;
}
case 'setColor': inner.style.color = value; break;
case 'show': carouselSetTargetVisible(dom, true); break;
case 'hide': carouselSetTargetVisible(dom, false); break;
}
}
/** Every element touched by any item's show/hide actions — reset before each slide */
function carouselManagedVisibility(el) {
var ids = {};
(el.carouselItems || []).forEach(function(it) {
(it.actions || []).forEach(function(a) {
if (a && a.targetId && (a.action === 'show' || a.action === 'hide')) ids[a.targetId] = true;
});
});
return Object.keys(ids);
}
function applyCarouselLinks(el, index) {
var items = el.carouselItems || [];
if (!items.length) return;
if (index < 0 || index >= items.length) index = 0;
var item = items[index];
// 1. Reset anything a slide controls, so show/hide is per-slide rather than sticky
if (el.carResetVisibility !== false) {
carouselManagedVisibility(el).forEach(function(id) {
carouselSetTargetVisible(carouselTargetDom(id), carouselTargetBaseVisible(id));
});
}
// 2. Carousel-wide links (same mapping for every slide)
(el.carLinks || []).forEach(function(link) {
if (!link || !link.targetId) return;
applyCarouselAction(carouselTargetDom(link.targetId), link.action, carouselFieldValue(item, link.field, index));
});
// 3. This slide's own triggers
(item.actions || []).forEach(function(a) {
if (!a || !a.targetId) return;
var val = a.value;
if (a.useField) val = carouselFieldValue(item, a.useField, index);
applyCarouselAction(carouselTargetDom(a.targetId), a.action, val);
});
if (el.carChangeFunc && typeof window[el.carChangeFunc] === 'function') {
try { window[el.carChangeFunc](item, index, el); } catch(err) { console.warn('[Carousel] change function error:', err); }
}
}
/** Current real item index — asks Owl first (clone-safe), then falls back to the DOM */
function carouselCurrentIndex(el, wrap, $t) {
var n = (el.carouselItems || []).length;
if (n) {
try {
var inst = $t && $t.data ? $t.data('owl.carousel') : null;
if (inst && typeof inst.relative === 'function') {
var rel = inst.relative(inst.current());
if (!isNaN(rel)) return ((rel % n) + n) % n;
}
} catch(e) {}
}
return carouselActiveIndex(wrap);
}
function syncCarouselLinks(el, wrap, $t) {
applyCarouselLinks(el, carouselCurrentIndex(el, wrap, $t));
}
function renderUICarousel(container, el, live) {
var items = el.carouselItems || [];
if (typeof disposeUIModel === 'function') {
Array.prototype.forEach.call(container.querySelectorAll('.car-model'), function(m) { disposeUIModel(m); });
}
container.innerHTML = '';
applyCarouselCSS(el);
if (items.length === 0) return;
var wrap = document.createElement('div');
wrap.className = 'car-wrap car-' + el.id;
wrap.style.cssText = 'width:100%;';
var ready = owlAvailable();
var track = document.createElement('div');
track.className = ready ? 'owl-carousel owl-theme' : 'car-fallback';
items.forEach(function(it, idx) {
var box = document.createElement('div');
box.className = 'car-item';
box.dataset.index = idx;
box.innerHTML = carouselItemInnerHTML(el, it);
if (!ready) {
var per = parseInt(el.carItems) || 3;
var slotIdx = carUsesSlotMode(el) ? carActiveSlotOf(el, per) : (el.carCenter ? Math.floor(Math.min(items.length, per) / 2) : -1);
if (slotIdx >= 0 && idx === slotIdx) box.classList.add('center');
}
var modelBox = box.querySelector('.car-model');
if (modelBox && typeof renderUIModel === 'function') {
renderUIModel(modelBox, {
id: el.id + '_m' + idx,
src: it.model,
borderRadius: el.carImgRadius,
modelBg: it.modelBg || el.carModelBg || 'transparent',
modelStubBg: (el.carShowImage !== false && it.image) ? 'transparent' : null,
modelAutoRotate: carItemSpins(el, it, true),
modelRotateSpeed: el.carModelRotateSpeed,
modelControls: el.carModelControls,
modelDragRotate: el.carModelDragRotate,
modelZoom: el.carModelZoom,
modelPan: el.carModelPan,
modelDragSpeed: el.carModelDragSpeed,
modelZoomSpeed: el.carModelZoomSpeed,
modelMinDistance: el.carModelMinDistance,
modelMaxDistance: el.carModelMaxDistance,
modelMinPolar: el.carModelMinPolar,
modelMaxPolar: el.carModelMaxPolar,
modelDamping: el.carModelDamping,
modelStopSpinOnInteract: el.carModelStopSpinOnInteract,
modelResumeSpin: el.carModelResumeSpin,
modelDistance: it.modelDistance || el.carModelDistance,
modelFov: el.carModelFov,
modelLighting: el.carModelLighting,
modelLightIntensity: el.carModelLightIntensity,
modelScale: it.modelScale !== undefined ? it.modelScale : el.carModelScale,
modelRotX: it.modelRotX !== undefined ? it.modelRotX : el.carModelRotX,
modelRotY: it.modelRotY !== undefined ? it.modelRotY : el.carModelRotY,
modelRotZ: it.modelRotZ !== undefined ? it.modelRotZ : el.carModelRotZ,
modelY: it.modelY !== undefined ? it.modelY : el.carModelY,
modelX: it.modelX !== undefined ? it.modelX : el.carModelX,
modelZ: it.modelZ !== undefined ? it.modelZ : el.carModelZ,
modelInset: it.modelInset,
onModelReady: function() { markCarouselActive(el, wrap, $track); },
modelCamHeight: el.carModelCamHeight,
modelAutoFit: true
}, live);
}
if (live && el.carItemClick && el.carItemClick !== 'none') {
box.addEventListener('click', function() {
if (el.carItemClick === 'link' && it.link) {
var t = el.carLinkTarget || 'newTab';
if (t === 'same') window.location.href = it.link;
else if (t === 'iframe' && typeof showIframeOverlay === 'function') showIframeOverlay(it.link);
else window.open(it.link, '_blank');
} else if (el.carItemClick === 'function' && el.carItemFunc && typeof window[el.carItemFunc] === 'function') {
try { window[el.carItemFunc](it, idx, el); } catch(err) { console.warn('[Carousel]', err); }
}
});
}
track.appendChild(box);
});
wrap.appendChild(track);
container.appendChild(wrap);
var $track = null;
if (ready) {
try {
var $t = window.jQuery(track);
$track = $t;
$t.on('initialized.owl.carousel changed.owl.carousel translated.owl.carousel', function(e) {
// Prefer Owl's own index (clone-safe), fall back to reading the DOM
var idx = null;
try {
if (e && e.item && e.relatedTarget && typeof e.relatedTarget.relative === 'function') {
idx = e.relatedTarget.relative(e.item.index);
}
} catch(err) {}
markCarouselActive(el, wrap, $t);
var n = items.length;
if (idx !== null && !isNaN(idx) && n) applyCarouselLinks(el, ((idx % n) + n) % n);
else syncCarouselLinks(el, wrap, $t);
// Re-read once the slide has settled
window.setTimeout(function() {
markCarouselActive(el, wrap, $t);
syncCarouselLinks(el, wrap, $t);
}, (parseInt(el.carSpeed) || 450) + 40);
});
// Clicking a side item brings it to the centre (feels natural with center mode)
if ((el.carCenter || carUsesSlotMode(el)) && live && el.carClickToCenter !== false) {
$t.on('click', '.owl-item:not(.center):not(.car-active) .car-item', function(e) {
if (el.carModelControls && e.target && e.target.closest && e.target.closest('.car-model')) return;
var i = parseInt(this.dataset.index);
if (!isNaN(i)) $t.trigger('to.owl.carousel', [i, parseInt(el.carSpeed) || 450]);
});
}
$t.owlCarousel(owlOptionsFor(el, live));
} catch(err) {
console.warn('[Carousel] init failed:', err);
track.classList.add('owl-loaded'); // show the cards rather than nothing
}
}
window.setTimeout(function() {
markCarouselActive(el, wrap, $track);
syncCarouselLinks(el, wrap, $track);
}, 30);
// Keep Owl in step with size / orientation changes. Owl caches item widths at init,
// so without this a resized (or rotated) carousel shows the wrong number of items.
if (ready && $track) {
var lastPer = owlOptionsFor(el, live).items;
var reflowTimer = null;
var reflow = function() {
window.clearTimeout(reflowTimer);
reflowTimer = window.setTimeout(function() {
if (!document.body.contains(track)) return; // element was re-rendered
var per = carItemsForViewport(el);
if (per !== lastPer) {
lastPer = per;
try {
$track.trigger('destroy.owl.carousel');
} catch(e) { console.warn('[Carousel] destroy failed:', e); }
try {
$track.addClass('owl-carousel owl-theme');
$track.owlCarousel(owlOptionsFor(el, live));
} catch(e) {
console.warn('[Carousel] re-init failed:', e);
$track.addClass('owl-loaded'); // fall back to a plain visible row
}
} else {
try { $track.trigger('refresh.owl.carousel'); } catch(e) {}
}
markCarouselActive(el, wrap, $track);
syncCarouselLinks(el, wrap, $track);
}, 120);
};
window.addEventListener('resize', reflow);
window.addEventListener('orientationchange', reflow);
if (typeof ResizeObserver !== 'undefined') {
try { new ResizeObserver(reflow).observe(wrap); } catch(e) {}
}
}
}
function controlUICarousel(dom, cmd, index) {
if (!owlAvailable() || !dom) return;
var $t = window.jQuery(dom).find('.owl-carousel');
if (!$t.length) return;
switch (cmd || 'next') {
case 'next': $t.trigger('next.owl.carousel'); break;
case 'prev': $t.trigger('prev.owl.carousel'); break;
case 'goTo': $t.trigger('to.owl.carousel', [parseInt(index) || 0, 300]); break;
case 'play': $t.trigger('play.owl.autoplay'); break;
case 'stop': $t.trigger('stop.owl.autoplay'); break;
case 'refresh': $t.trigger('refresh.owl.carousel'); break;
}
}
/* ── Design stage: reproduce the builder's canvas exactly ───────────────────
The builder lays out on a fixed canvas (960×540 on desktop) and scales it to
fit. Positions are percentages, but font sizes, image heights, paddings and
radii are px/rem — so rendering the same UI into a raw browser window of a
different size changes every one of those proportions. Runtimes therefore
size the overlay to the design resolution and scale it, exactly like the
builder does. */
var UI_DESIGN_SIZES = {
desktop: { w: 960, h: 540 },
tablet: { w: 512, h: 683 },
phone: { w: 320, h: 568 },
'phone-land': { w: 568, h: 320 }
};
/** Anchor → translate offsets (the element's own position is a % of the stage) */
function uiAnchorTranslate(anchor) {
switch (anchor || 'center') {
case 'top-left': return { tx: '0', ty: '0' };
case 'top-center': return { tx: '-50%', ty: '0' };
case 'top-right': return { tx: '-100%', ty: '0' };
case 'center-left': return { tx: '0', ty: '-50%' };
case 'center-right': return { tx: '-100%', ty: '-50%' };
case 'bottom-left': return { tx: '0', ty: '-100%' };
case 'bottom-center': return { tx: '-50%', ty: '-100%' };
case 'bottom-right': return { tx: '-100%', ty: '-100%' };
default: return { tx: '-50%', ty: '-50%' };
}
}
/** The transform an element carries: anchor offset plus a hover/active scale hook */
function uiAnchorTransform(anchor) {
var a = uiAnchorTranslate(anchor);
return 'translate(' + a.tx + ', ' + a.ty + ') scale(var(--gui-scale, 1))';
}
function uiDeviceBucket() {
var w = (typeof window !== 'undefined' && window.innerWidth) ? window.innerWidth : 1280;
var h = (typeof window !== 'undefined' && window.innerHeight) ? window.innerHeight : 800;
var landscape = w > h;
if (landscape && (w <= 768 || (h <= 500 && w <= 1200))) return 'phone-land';
if (w <= 480) return 'phone';
if (w <= 1024) return 'tablet';
return 'desktop';
}
function uiDesignSize(uiData) {
var sizes = (uiData && uiData.designSizes) || UI_DESIGN_SIZES;
return sizes[uiDeviceBucket()] || sizes.desktop || UI_DESIGN_SIZES.desktop;
}
function applyUIStage(uiData, overlayId) {
var overlay = document.getElementById(overlayId || 'game-ui-overlay');
if (!overlay) return;
var mode = (uiData && uiData.scaleMode) || 'fit';
if (mode === 'stretch') { // legacy: fill the window, px sizes unscaled
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.left = '0';
overlay.style.top = '0';
overlay.style.transform = '';
overlay.style.transformOrigin = '';
return;
}
var size = uiDesignSize(uiData);
var w = window.innerWidth, h = window.innerHeight;
var scale = mode === 'fill'
? Math.max(w / size.w, h / size.h)
: Math.min(w / size.w, h / size.h);
overlay.style.width = size.w + 'px';
overlay.style.height = size.h + 'px';
overlay.style.left = '50%';
overlay.style.top = '50%';
overlay.style.transformOrigin = 'center center';
overlay.style.transform = 'translate(-50%, -50%) scale(' + scale + ')';
}
/** Re-apply on resize / rotate; safe to call repeatedly. */
function watchUIStage(getUIData, overlayId) {
if (window.__uiStageWatch) window.removeEventListener('resize', window.__uiStageWatch);
var timer = null;
window.__uiStageWatch = function() {
window.clearTimeout(timer);
timer = window.setTimeout(function() { applyUIStage(getUIData(), overlayId); }, 60);
};
window.addEventListener('resize', window.__uiStageWatch);
window.addEventListener('orientationchange', window.__uiStageWatch);
}
/* ── 3D model viewer (Three.js + GLB) ──────────────────────────────────────
Loads Three.js on demand the first time a model element is rendered, so a
project without one pays nothing. */
var UI_THREE_VERSION = '0.160.0';
var UI_THREE_CDN = 'https://cdn.jsdelivr.net/npm/three@' + UI_THREE_VERSION;
var UI_THREE_ESM = 'https://esm.sh/three@' + UI_THREE_VERSION;
var _uiThreePromise = null;
var _uiThreeError = '';
/**
* Three's add-ons (GLTFLoader, OrbitControls) are published as ES modules that
* `import ... from 'three'` — a bare specifier the browser can only resolve through
* an import map. Loading them straight from a CDN therefore fails with
* "Failed to resolve module specifier". So: make sure an import map exists first,
* and keep a self-resolving CDN as a backup.
*/
function ensureThreeImportMap() {
if (typeof document === 'undefined') return false;
var existing = document.querySelector('script[type="importmap"]');
if (existing) {
// A host page (game-level) already maps "three" — use whatever it points at
return true;
}
try {
var map = document.createElement('script');
map.type = 'importmap';
map.textContent = JSON.stringify({
imports: {
'three': UI_THREE_CDN + '/build/three.module.js',
'three/': UI_THREE_CDN + '/',
'three/addons/': UI_THREE_CDN + '/examples/jsm/'
}
});
document.head.appendChild(map);
return true;
} catch(e) {
return false;
}
}
function loadUIThree() {
if (typeof window !== 'undefined' && window.__uiThreeStub) return Promise.resolve(window.__uiThreeStub);
if (_uiThreePromise) return _uiThreePromise;
var viaImportMap = function() {
ensureThreeImportMap();
return Promise.all([
import(/* webpackIgnore: true */ 'three'),
import(/* webpackIgnore: true */ 'three/addons/loaders/GLTFLoader.js'),
import(/* webpackIgnore: true */ 'three/addons/controls/OrbitControls.js')
]);
};
// esm.sh rewrites the add-ons' internal "three" imports, so it needs no map
var viaEsmSh = function() {
return Promise.all([
import(/* webpackIgnore: true */ UI_THREE_ESM),
import(/* webpackIgnore: true */ UI_THREE_ESM + '/examples/jsm/loaders/GLTFLoader.js'),
import(/* webpackIgnore: true */ UI_THREE_ESM + '/examples/jsm/controls/OrbitControls.js')
]);
};
_uiThreePromise = viaImportMap()
.catch(function(err) {
console.warn('[Model3D] import-map load failed (' + (err && err.message) + '), retrying via esm.sh');
return viaEsmSh();
})
.then(function(m) {
return { THREE: m[0], GLTFLoader: m[1].GLTFLoader, OrbitControls: m[2].OrbitControls };
})
.catch(function(err) {
_uiThreeError = (err && err.message) || 'unknown error';
console.warn('[Model3D] Three.js could not be loaded:', _uiThreeError,
'\nIf you are offline, host three.module.js yourself and add an import map to the page.');
return null;
});
return _uiThreePromise;
}
function disposeUIModel(container) {
if (!container) return;
var host = container.querySelector('.ui-model');
if (host && host._modelCleanup) { try { host._modelCleanup(); } catch(e) {} }
}
function modelPlaceholder(el, message, spinner) {
return '<div class="ui-model-stub" style="width:100%;height:100%;min-height:60px;display:flex;flex-direction:column;' +
'gap:6px;align-items:center;justify-content:center;background:' +
(el.modelStubBg ? el.modelStubBg : (el.modelBg && el.modelBg !== 'transparent' ? el.modelBg : 'rgba(0,0,0,0.3)')) +
';border-radius:' + (el.borderRadius || '8px') + ';color:#c9d2e0;text-align:center;padding:8px;box-sizing:border-box;">' +
'<i class="bi ' + (spinner ? 'bi-arrow-repeat' : 'bi-box') + '" style="font-size:1.5rem;opacity:0.65;' +
(spinner ? 'animation:uiModelSpin 1.1s linear infinite;' : '') + '"></i>' +
'<span style="font-size:0.5rem;opacity:0.75;">' + message + '</span></div>';
}
function renderUIModel(container, el, live) {
disposeUIModel(container);
container.innerHTML = '';
if (!document.getElementById('ui-model-spin-css')) {
var st = document.createElement('style');
st.id = 'ui-model-spin-css';
st.textContent = '@keyframes uiModelSpin { to { transform:rotate(360deg); } }';
document.head.appendChild(st);
}
if (!el.src) {
container.innerHTML = modelPlaceholder(el, 'Set a .glb / .gltf path', false);
return null;
}
var host = document.createElement('div');
host.className = 'ui-model';
host.style.cssText = 'position:relative;width:100%;height:100%;min-height:40px;overflow:hidden;border-radius:' +
(el.borderRadius || '0') + ';' + (el.modelBg && el.modelBg !== 'transparent' ? 'background:' + el.modelBg + ';' : '');
var loading = document.createElement('div');
loading.className = 'ui-model-loading';
loading.style.cssText = 'position:absolute;inset:0;';
loading.innerHTML = modelPlaceholder(el, 'Loading 3D model…', true);
host.appendChild(loading);
container.appendChild(host);
var cancelled = false;
host._modelEl = el;
host._modelCleanup = function() { cancelled = true; };
loadUIThree().then(function(libs) {
if (cancelled || !host.isConnected) return;
if (!libs || !libs.THREE) {
host.innerHTML = modelPlaceholder(el, '3D viewer unavailable' + (_uiThreeError ? ' — ' + _uiThreeError : ''), false);
return;
}
var THREE = libs.THREE;
var w = host.clientWidth || 300, h = host.clientHeight || 200;
var renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(w, h, false);
renderer.domElement.style.cssText = 'width:100%;height:100%;display:block;';
if (renderer.outputColorSpace !== undefined && THREE.SRGBColorSpace) renderer.outputColorSpace = THREE.SRGBColorSpace;
if (el.modelShadows && renderer.shadowMap) { renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; }
var scene = new THREE.Scene();
if (el.modelBg && el.modelBg !== 'transparent') {
try { scene.background = new THREE.Color(el.modelBg); } catch(e) {}
} else {
renderer.setClearColor(0x000000, 0);
}
var camera = new THREE.PerspectiveCamera(parseFloat(el.modelFov) || 45, w / h, 0.01, 2000);
// Lighting presets
var preset = el.modelLighting || 'studio';
var intensity = el.modelLightIntensity !== undefined ? parseFloat(el.modelLightIntensity) : 1;
var amb = new THREE.AmbientLight(0xffffff, (preset === 'dramatic' ? 0.25 : preset === 'soft' ? 0.9 : 0.55) * intensity);
scene.add(amb);
var key = new THREE.DirectionalLight(0xffffff, (preset === 'dramatic' ? 2.2 : preset === 'soft' ? 0.8 : 1.4) * intensity);
key.position.set(3, 5, 4);
if (el.modelShadows) { key.castShadow = true; }
scene.add(key);
if (preset !== 'dramatic') {
var fill = new THREE.DirectionalLight(0xffffff, 0.6 * intensity);
fill.position.set(-4, 2, -3);
scene.add(fill);
}
if (el.modelHemi !== false && THREE.HemisphereLight) {
scene.add(new THREE.HemisphereLight(0xffffff, 0x223344, 0.4 * intensity));
}
var root = new THREE.Group();
scene.add(root);
var controls = null;
if (el.modelControls && libs.OrbitControls) {
controls = new libs.OrbitControls(camera, renderer.domElement);
controls.enableDamping = el.modelDamping !== false;
controls.dampingFactor = parseFloat(el.modelDampingFactor) || 0.08;
controls.enableRotate = el.modelDragRotate !== false;
controls.enableZoom = el.modelZoom !== false;
controls.enablePan = !!el.modelPan;
controls.rotateSpeed = parseFloat(el.modelDragSpeed) || 1;
controls.zoomSpeed = parseFloat(el.modelZoomSpeed) || 1;
controls.autoRotate = !!(live && el.modelAutoRotate);
controls.autoRotateSpeed = parseFloat(el.modelRotateSpeed) || 2;
if (el.modelMinDistance !== undefined && el.modelMinDistance !== '') controls.minDistance = parseFloat(el.modelMinDistance);
if (el.modelMaxDistance !== undefined && el.modelMaxDistance !== '') controls.maxDistance = parseFloat(el.modelMaxDistance);
if (el.modelMinPolar !== undefined && el.modelMinPolar !== '') controls.minPolarAngle = parseFloat(el.modelMinPolar) * Math.PI / 180;
if (el.modelMaxPolar !== undefined && el.modelMaxPolar !== '') controls.maxPolarAngle = parseFloat(el.modelMaxPolar) * Math.PI / 180;
// Touch gestures: one finger turns, two fingers zoom (and pan when allowed)
if (THREE.TOUCH) {
controls.touches = {
ONE: el.modelDragRotate === false ? THREE.TOUCH.PAN : THREE.TOUCH.ROTATE,
TWO: el.modelPan ? THREE.TOUCH.DOLLY_PAN : THREE.TOUCH.DOLLY_ROTATE
};
}
renderer.domElement.style.touchAction = 'none';
renderer.domElement.style.cursor = 'grab';
// Don't let a carousel (or any parent) steal the gesture
['pointerdown', 'mousedown', 'touchstart', 'wheel', 'click'].forEach(function(evt) {
renderer.domElement.addEventListener(evt, function(e) { e.stopPropagation(); }, { passive: evt === 'wheel' ? false : true });
});
// Spinning pauses while the user is dragging, and can come back afterwards
controls.addEventListener('start', function() {
renderer.domElement.style.cursor = 'grabbing';
if (el.modelStopSpinOnInteract !== false) {
controls.autoRotate = false;
if (host._modelState) host._modelState.spinning = false;
}
if (host._resumeTimer) { window.clearTimeout(host._resumeTimer); host._resumeTimer = null; }
});
controls.addEventListener('end', function() {
renderer.domElement.style.cursor = 'grab';
var delay = parseFloat(el.modelResumeSpin);
if (el.modelStopSpinOnInteract !== false && el.modelAutoRotate && delay > 0) {
host._resumeTimer = window.setTimeout(function() {
controls.autoRotate = true;
if (host._modelState) host._modelState.spinning = true;
}, delay);
}
});
}
function frameModel(obj) {
var box = new THREE.Box3().setFromObject(obj);
if (!isFinite(box.min.x)) return;
var size = box.getSize(new THREE.Vector3());
var center = box.getCenter(new THREE.Vector3());
var maxDim = Math.max(size.x, size.y, size.z) || 1;
if (el.modelAutoFit !== false) {
obj.position.sub(center); // sit on the origin
var target = 1.6;
obj.scale.multiplyScalar(target / maxDim);
}
var dist = (parseFloat(el.modelDistance) || 4);
camera.position.set(0, parseFloat(el.modelCamHeight) || 0.4, dist);
camera.lookAt(0, parseFloat(el.modelTargetY) || 0, 0);
if (controls) { controls.target.set(0, parseFloat(el.modelTargetY) || 0, 0); controls.update(); }
}
function applyTransform(obj) {
obj.position.x += parseFloat(el.modelX) || 0;
obj.position.y += parseFloat(el.modelY) || 0;
obj.position.z += parseFloat(el.modelZ) || 0;
var sc = parseFloat(el.modelScale);
if (!sc && sc !== 0) sc = 1;
obj.scale.multiplyScalar(sc);
obj.rotation.set(
(parseFloat(el.modelRotX) || 0) * Math.PI / 180,
(parseFloat(el.modelRotY) || 0) * Math.PI / 180,
(parseFloat(el.modelRotZ) || 0) * Math.PI / 180
);
}
var mixer = null, clock = new THREE.Clock();
var loader = new libs.GLTFLoader();
loader.load(el.src, function(gltf) {
if (cancelled || !host.isConnected) return;
var obj = gltf.scene || gltf.scenes[0];
if (el.modelShadows) {
obj.traverse(function(n) { if (n.isMesh) { n.castShadow = true; n.receiveShadow = true; } });
}
frameModel(obj);
host._modelBaseScale = obj.scale.x; // scale after auto-fit, before user scale
applyTransform(obj);
root.add(obj);
host._modelObject = obj;
if (gltf.animations && gltf.animations.length && el.modelPlayClips !== false) {
mixer = new THREE.AnimationMixer(obj);
gltf.animations.forEach(function(clip) { mixer.clipAction(clip).play(); });
}
if (loading.parentNode) loading.remove();
// Let the host (e.g. a carousel) re-apply its pose now the model exists
if (typeof el.onModelReady === 'function') {
try { el.onModelReady(host); } catch(e) { console.warn('[Model3D] onModelReady:', e); }
}
}, undefined, function(err) {
var isFile = (location.protocol === 'file:');
console.warn('[Model3D] failed to load', el.src, err && err.message,
isFile ? '\nThe page was opened from disk (file://) — browsers refuse to fetch .glb files that way. ' +
'Tick "Embed 3D models" when exporting, or serve the folder over http.' : '');
host.innerHTML = modelPlaceholder(el,
isFile ? 'Cannot load models from file:// — embed them on export or serve over http'
: 'Could not load ' + el.src, false);
});
host.appendChild(renderer.domElement);
// Keep the drawing buffer in step with the element
var ro = null;
function resize() {
var nw = host.clientWidth || w, nh = host.clientHeight || h;
if (!nw || !nh) return;
camera.aspect = nw / nh;
camera.updateProjectionMatrix();
renderer.setSize(nw, nh, false);
}
if (typeof ResizeObserver !== 'undefined') {
try { ro = new ResizeObserver(resize); ro.observe(host); } catch(e) {}
}
window.addEventListener('resize', resize);
var raf = null, spinning = !!(live && el.modelAutoRotate && !controls);
host._modelState = { spinning: spinning };
function tick() {
if (cancelled || !host.isConnected) return;
raf = requestAnimationFrame(tick);
var dt = clock.getDelta();
if (mixer) mixer.update(dt);
if (host._modelState.spinning && host._modelObject) {
host._modelObject.rotation.y += (parseFloat(el.modelRotateSpeed) || 2) * 0.25 * dt;
}
if (controls) controls.update();
renderer.render(scene, camera);
}
tick();
host._modelCleanup = function() {
cancelled = true;
if (raf) cancelAnimationFrame(raf);
window.removeEventListener('resize', resize);
if (ro) { try { ro.disconnect(); } catch(e) {} }
if (controls) { try { controls.dispose(); } catch(e) {} }
try {
scene.traverse(function(n) {
if (n.geometry) n.geometry.dispose();
if (n.material) {
var mats = Array.isArray(n.material) ? n.material : [n.material];
mats.forEach(function(m) {
Object.keys(m).forEach(function(k) { if (m[k] && m[k].isTexture) m[k].dispose(); });
m.dispose();
});
}
});
renderer.dispose();
} catch(e) {}
};
host._modelApi = {
renderer: renderer, scene: scene, camera: camera, root: root, controls: controls,
setSpin: function(on) {
host._modelState.spinning = !!on && !controls;
if (controls) controls.autoRotate = !!on;
},
setPose: function(pose) {
if (!pose) return;
var obj = host._modelObject;
if (obj) {
if (pose.rotX !== undefined || pose.rotY !== undefined || pose.rotZ !== undefined) {
obj.rotation.set(
(pose.rotX || 0) * Math.PI / 180,
(pose.rotY || 0) * Math.PI / 180,
(pose.rotZ || 0) * Math.PI / 180
);
}
if (pose.scale !== undefined && host._modelBaseScale) {
var sc = host._modelBaseScale * pose.scale;
obj.scale.set(sc, sc, sc);
}
}
if (pose.distance !== undefined && camera) {
camera.position.z = pose.distance;
if (controls) controls.update();
}
},
controlsFor: function() { return controls; },
reset: function() {
if (host._modelObject) {
host._modelObject.rotation.set(
(parseFloat(el.modelRotX) || 0) * Math.PI / 180,
(parseFloat(el.modelRotY) || 0) * Math.PI / 180,
(parseFloat(el.modelRotZ) || 0) * Math.PI / 180
);
}
if (controls) controls.reset();
}
};
});
return host;
}
function controlUIModel(dom, cmd, value) {
var host = dom ? dom.querySelector('.ui-model') : null;
if (!host) return;
var api = host._modelApi;
switch (cmd || 'toggleSpin') {
case 'spin': if (api) api.setSpin(true); break;
case 'stop': if (api) api.setSpin(false); break;
case 'toggleSpin': if (api) api.setSpin(!(host._modelState && host._modelState.spinning)); break;
case 'reset': if (api) api.reset(); break;
case 'setModel':
if (value && host.parentNode) {
var el = host._modelEl || {};
var next = {};
for (var k in el) next[k] = el[k];
next.src = value;
renderUIModel(host.parentNode, next, true);
}
break;
}
}
/* ── Iframe / embed element ── */
function uiEmbedURL(src) {
var u = String(src || '').trim();
if (!u) return '';
// Friendly conversions so a pasted share link just works
var yt = u.match(/(?:youtube\.com\/(?:watch\?v=|live\/|shorts\/)|youtu\.be\/)([\w-]{6,})/i);
if (yt) return 'https://www.youtube.com/embed/' + yt[1];
var vim = u.match(/vimeo\.com\/(?:video\/)?(\d+)/i);
if (vim) return 'https://player.vimeo.com/video/' + vim[1];
var gmap = u.match(/google\.[a-z.]+\/maps\/place\/([^/?]+)/i);
if (gmap && u.indexOf('/embed') === -1) {
return 'https://www.google.com/maps?q=' + encodeURIComponent(decodeURIComponent(gmap[1])) + '&output=embed';
}
return u;
}
function buildUIFrame(el) {
var frame = document.createElement('iframe');
frame.className = 'ui-frame';
frame.src = uiEmbedURL(el.src);
frame.style.cssText = 'width:100%;height:100%;display:block;border:' + (el.frameBorder || 'none') +
';border-radius:' + (el.borderRadius || '0') + ';background:' + (el.frameBg || 'transparent') + ';';
if (el.frameTitle) frame.title = el.frameTitle;
frame.setAttribute('allow', el.frameAllow || 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture');
if (el.frameAllowFullscreen !== false) frame.setAttribute('allowfullscreen', '');
if (el.frameLazy) frame.setAttribute('loading', 'lazy');
if (el.frameSandbox) frame.setAttribute('sandbox', el.frameSandboxRules || 'allow-scripts allow-same-origin allow-popups allow-forms');
if (el.frameScroll === false) frame.setAttribute('scrolling', 'no');
return frame;
}
function renderUIFrame(container, el, live) {
container.innerHTML = '';
if (!el.src) {
container.innerHTML = '<div style="width:100%;height:100%;min-height:60px;display:flex;flex-direction:column;gap:4px;' +
'align-items:center;justify-content:center;background:rgba(0,0,0,0.25);border:1px dashed rgba(255,255,255,0.2);' +
'border-radius:' + (el.borderRadius || '8px') + ';"><i class="bi bi-window" style="font-size:1.5rem;opacity:0.5;"></i>' +
'<span style="font-size:0.5rem;opacity:0.6;">Set a page URL</span></div>';
return null;
}
// While designing, a placeholder avoids loading (and autoplaying) the real page
if (!live && el.framePreviewOnly !== false) {
var box = document.createElement('div');
box.className = 'ui-frame-stub';
box.style.cssText = 'width:100%;height:100%;display:flex;flex-direction:column;gap:6px;align-items:center;justify-content:center;' +
'background:' + (el.frameBg || 'rgba(0,0,0,0.35)') + ';border:' + (el.frameBorder || '1px solid rgba(255,255,255,0.12)') +
';border-radius:' + (el.borderRadius || '8px') + ';color:#cfd6e4;text-align:center;padding:8px;box-sizing:border-box;overflow:hidden;';
box.innerHTML = '<i class="bi bi-window-stack" style="font-size:1.4rem;opacity:0.7;"></i>' +
'<div style="font-size:0.5rem;font-weight:700;letter-spacing:1px;">EMBEDDED PAGE</div>' +
'<div style="font-size:0.45rem;opacity:0.7;word-break:break-all;max-width:90%;">' + escAttr(uiEmbedURL(el.src)) + '</div>' +
'<div style="font-size:0.4rem;opacity:0.5;">loads in Preview and in the player</div>';
container.appendChild(box);
return null;
}
var frame = buildUIFrame(el);
container.appendChild(frame);
return frame;
}
function controlUIFrame(dom, cmd, value) {
var frame = dom ? dom.querySelector('iframe') : null;
if (!frame) return;
switch (cmd || 'reload') {
case 'reload': frame.src = frame.src; break;
case 'setSrc': if (value) frame.src = uiEmbedURL(value); break;
case 'blank': frame.src = 'about:blank'; break;
}
}
/* ── Hero section (full-bleed backgrounds that cross-fade) ── */
function heroSlidesOf(el) {
var list = el.heroSlides || [];
return list.length ? list : [{ id: 'hs_empty', image: '', title: '', text: '' }];
}
function buildHeroCSS(el) {
var s = '.hero-' + el.id;
var dur = parseInt(el.heroTransDuration);
if (!dur && dur !== 0) dur = 900;
var trans = el.heroTransition || 'fade';
var pad = el.heroPadding || '6%';
var alignH = el.heroAlignH || 'center';
var alignV = el.heroAlignV || 'center';
var justify = alignV === 'top' ? 'flex-start' : alignV === 'bottom' ? 'flex-end' : 'center';
var items = alignH === 'left' ? 'flex-start' : alignH === 'right' ? 'flex-end' : 'center';
var textAlign = alignH === 'left' ? 'left' : alignH === 'right' ? 'right' : 'center';
// How an inactive slide sits before/after it becomes the active one
var restState, activeState;
if (trans === 'slide-left') { restState = 'transform:translateX(100%);'; activeState = 'transform:translateX(0);'; }
else if (trans === 'slide-right') { restState = 'transform:translateX(-100%);'; activeState = 'transform:translateX(0);'; }
else if (trans === 'slide-up') { restState = 'transform:translateY(100%);'; activeState = 'transform:translateY(0);'; }
else if (trans === 'zoom') { restState = 'opacity:0; transform:scale(1.15);'; activeState = 'opacity:1; transform:scale(1);'; }
else { restState = 'opacity:0;'; activeState = 'opacity:1;'; }
var out = [
s + ' { position:relative; width:100%; height:100%; overflow:hidden; }',
s + ' .hero-bgs { position:absolute; inset:0; overflow:hidden; }',
s + ' .hero-bg { position:absolute; inset:0; background-size:' + (el.heroBgFit || 'cover') +
'; background-position:' + (el.heroBgPosition || 'center') + '; background-repeat:no-repeat; ' +
restState + ' transition:opacity ' + dur + 'ms ease, transform ' + dur + 'ms ease; }',
s + ' .hero-bg.active { ' + activeState + ' z-index:1; }',
s + ' .hero-bg video { width:100%; height:100%; object-fit:' + (el.heroBgFit || 'cover') + '; display:block; }',
s + ' .hero-overlay { position:absolute; inset:0; z-index:2; pointer-events:none; background:' +
(el.heroOverlay || 'none') + '; opacity:' + (el.heroOverlayOpacity !== undefined ? el.heroOverlayOpacity : 1) + '; }',
s + ' .hero-content { position:absolute; inset:0; z-index:3; display:flex; flex-direction:column; box-sizing:border-box;' +
' justify-content:' + justify + '; align-items:' + items + '; text-align:' + textAlign +
'; padding:' + pad + '; gap:' + (el.heroGap || '10px') + '; pointer-events:none; }',
s + ' .hero-content > * { pointer-events:auto; max-width:' + (el.heroMaxWidth || '100%') + '; }',
s + ' .hero-title { font-family:' + (el.heroTitleFont || el.fontFamily || "'Orbitron',sans-serif") +
'; font-size:' + (el.heroTitleSize !== undefined ? el.heroTitleSize : 2.4) + (el.heroTitleUnit || 'rem') +
'; font-weight:' + (el.heroTitleWeight || '700') + '; color:' + (el.heroTitleColor || '#ffffff') +
'; line-height:' + (el.heroTitleLineHeight || '1.1') + '; letter-spacing:' + (el.heroTitleSpacing || 'normal') +
'; text-shadow:' + (el.heroTitleShadow || 'none') + '; margin:0; }',
s + ' .hero-sub { font-family:' + (el.heroSubFont || el.fontFamily || "'Rajdhani',sans-serif") +
'; font-size:' + (el.heroSubSize !== undefined ? el.heroSubSize : 1) + (el.heroSubUnit || 'rem') +
'; font-weight:' + (el.heroSubWeight || '400') + '; color:' + (el.heroSubColor || '#d0d8e4') +
'; line-height:' + (el.heroSubLineHeight || '1.5') + '; text-shadow:' + (el.heroSubShadow || 'none') + '; margin:0; }',
s + ' .hero-children { position:absolute; inset:0; z-index:4; pointer-events:none; }',
s + ' .hero-dots { position:absolute; z-index:5; left:0; right:0; bottom:' + (parseFloat(el.heroDotBottom) || 18) +
'px; display:flex; justify-content:center; align-items:center; gap:' + (parseFloat(el.heroDotGap) || 8) + 'px; }',
s + ' .hero-dot { width:' + (parseFloat(el.heroDotWidth) || 10) + 'px; height:' + (parseFloat(el.heroDotHeight) || 10) +
'px; border-radius:' + (el.heroDotRadius || '50%') + '; background:' + (el.heroDotColor || 'rgba(255,255,255,0.35)') +
'; cursor:pointer; transition:all 0.25s; border:none; padding:0; }',
s + ' .hero-dot.active { background:' + (el.heroDotActiveColor || '#ffffff') + ';' +
(el.heroDotActiveWidth ? ' width:' + parseFloat(el.heroDotActiveWidth) + 'px;' : '') + ' }',
s + ' .hero-arrow { position:absolute; z-index:5; top:50%; transform:translateY(-50%); width:' +
(parseFloat(el.heroArrowSize) || 36) + 'px; height:' + (parseFloat(el.heroArrowSize) || 36) +
'px; border-radius:' + (el.heroArrowRadius || '50%') + '; background:' + (el.heroArrowBg || 'rgba(0,0,0,0.35)') +
'; color:' + (el.heroArrowColor || '#fff') + '; border:none; cursor:pointer; display:flex; align-items:center; justify-content:center; font-size:' +
(el.heroArrowIconSize !== undefined ? el.heroArrowIconSize : 1) + 'rem; }',
s + ' .hero-arrow.prev { left:' + (parseFloat(el.heroArrowInset) || 12) + 'px; }',
s + ' .hero-arrow.next { right:' + (parseFloat(el.heroArrowInset) || 12) + 'px; }'
];
if (el.heroKenBurns) {
out.push('@keyframes heroKen-' + el.id + ' { from { transform:scale(1); } to { transform:scale(' +
(el.heroKenScale || 1.12) + '); } }');
out.push(s + ' .hero-bg.active { animation:heroKen-' + el.id + ' ' +
((parseInt(el.heroInterval) || 6000) + dur) + 'ms ease-out forwards; }');
}
return out.join('\n');
}
function applyHeroCSS(el) {
var st = document.getElementById('hero-style-' + el.id);
if (!st) { st = document.createElement('style'); st.id = 'hero-style-' + el.id; document.head.appendChild(st); }
st.textContent = buildHeroCSS(el);
}
function heroShowSlide(wrap, el, index, live) {
var slides = heroSlidesOf(el);
var n = slides.length;
if (!n) return;
index = ((index % n) + n) % n;
wrap._heroIndex = index;
var bgs = wrap.querySelectorAll('.hero-bg');
Array.prototype.forEach.call(bgs, function(b, i) { b.classList.toggle('active', i === index); });
var dots = wrap.querySelectorAll('.hero-dot');
Array.prototype.forEach.call(dots, function(d, i) { d.classList.toggle('active', i === index); });
// Per-slide headings fall back to the hero's own text
var slide = slides[index] || {};
var t = wrap.querySelector('.hero-title');
var sub = wrap.querySelector('.hero-sub');
if (t) {
var title = slide.title !== undefined && slide.title !== '' ? slide.title : (el.heroTitle || '');
t.innerHTML = title;
t.style.display = title ? '' : 'none';
}
if (sub) {
var text = slide.text !== undefined && slide.text !== '' ? slide.text : (el.heroSubtitle || '');
sub.innerHTML = text;
sub.style.display = text ? '' : 'none';
}
// Videos only play while their slide is on screen
Array.prototype.forEach.call(bgs, function(b, i) {
var v = b.querySelector('video');
if (!v) return;
if (i === index && live) { v.play().catch(function(){}); }
else { v.pause(); }
});
if (live && el.heroChangeFunc && typeof window[el.heroChangeFunc] === 'function') {
try { window[el.heroChangeFunc](slide, index, el); } catch(e) { console.warn('[Hero]', e); }
}
}
function heroStopAuto(wrap) {
if (wrap && wrap._heroTimer) { window.clearInterval(wrap._heroTimer); wrap._heroTimer = null; }
}
function heroStartAuto(wrap, el, live) {
heroStopAuto(wrap);
if (!live || !el.heroAutoplay) return;
var slides = heroSlidesOf(el);
if (slides.length < 2) return;
wrap._heroTimer = window.setInterval(function() {
if (!document.body.contains(wrap)) { heroStopAuto(wrap); return; }
heroShowSlide(wrap, el, (wrap._heroIndex || 0) + 1, live);
}, Math.max(600, parseInt(el.heroInterval) || 6000));
}
function renderUIHero(container, el, live) {
var prev = container.querySelector('.hero-wrap');
if (prev) heroStopAuto(prev);
container.innerHTML = '';
applyHeroCSS(el);
var wrap = document.createElement('div');
wrap.className = 'hero-wrap hero-' + el.id;
var slides = heroSlidesOf(el);
var bgs = document.createElement('div');
bgs.className = 'hero-bgs';
slides.forEach(function(sl, i) {
var b = document.createElement('div');
b.className = 'hero-bg';
if (sl.video) {
var v = document.createElement('video');
v.src = sl.video; v.muted = true; v.loop = true; v.playsInline = true; v.preload = 'auto';
b.appendChild(v);
} else if (sl.image) {
b.style.backgroundImage = 'url(' + sl.image + ')';
} else {
b.style.background = sl.color || (el.heroEmptyBg || 'linear-gradient(135deg,#1b2030,#0b0e17)');
}
bgs.appendChild(b);
});
wrap.appendChild(bgs);
var ov = document.createElement('div');
ov.className = 'hero-overlay';
wrap.appendChild(ov);
var content = document.createElement('div');
content.className = 'hero-content';
var title = document.createElement('div');
title.className = 'hero-title';
var sub = document.createElement('div');
sub.className = 'hero-sub';
content.appendChild(title);
content.appendChild(sub);
wrap.appendChild(content);
// Anything parented onto the hero lands here
var kids = document.createElement('div');
kids.className = 'hero-children gui-children ui-el-children';
wrap.appendChild(kids);
if (el.heroArrows && slides.length > 1) {
['prev', 'next'].forEach(function(dir) {
var b = document.createElement('button');
b.className = 'hero-arrow ' + dir;
b.innerHTML = '<i class="bi ' + (dir === 'prev'
? (el.heroPrevIcon || 'bi-chevron-left')
: (el.heroNextIcon || 'bi-chevron-right')) + '"></i>';
b.addEventListener('click', function(e) {
e.stopPropagation();
heroShowSlide(wrap, el, (wrap._heroIndex || 0) + (dir === 'next' ? 1 : -1), live);
heroStartAuto(wrap, el, live);
});
wrap.appendChild(b);
});
}
if (el.heroDots && slides.length > 1) {
var dotBox = document.createElement('div');
dotBox.className = 'hero-dots';
slides.forEach(function(sl, i) {
var dt = document.createElement('button');
dt.className = 'hero-dot';
dt.addEventListener('click', function(e) {
e.stopPropagation();
heroShowSlide(wrap, el, i, live);
heroStartAuto(wrap, el, live);
});
dotBox.appendChild(dt);
});
wrap.appendChild(dotBox);
}
wrap._heroEl = el;
wrap._heroLive = live;
container.appendChild(wrap);
heroShowSlide(wrap, el, parseInt(el.heroStart) || 0, live);
heroStartAuto(wrap, el, live);
return wrap;
}
function controlUIHero(dom, cmd, index) {
var wrap = dom ? dom.querySelector('.hero-wrap') : null;
if (!wrap) return;
var el = wrap._heroEl;
if (!el) return;
switch (cmd || 'next') {
case 'next': heroShowSlide(wrap, el, (wrap._heroIndex || 0) + 1, true); break;
case 'prev': heroShowSlide(wrap, el, (wrap._heroIndex || 0) - 1, true); break;
case 'goTo': heroShowSlide(wrap, el, parseInt(index) || 0, true); break;
case 'play': heroStartAuto(wrap, el, true); break;
case 'stop': heroStopAuto(wrap); break;
}
}
/* ── Video chroma key (colour → transparency) ── */
function uiHexToRgb(hex) {
var h = String(hex || '#00ff00').trim();
if (h.charAt(0) === '#') h = h.slice(1);
if (h.length === 3) h = h.charAt(0) + h.charAt(0) + h.charAt(1) + h.charAt(1) + h.charAt(2) + h.charAt(2);
var n = parseInt(h, 16);
if (isNaN(n)) return { r: 0, g: 255, b: 0 };
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
function stopUIVideoChroma(container) {
if (!container) return;
var old = container.querySelector('canvas.ui-chroma');
if (old) { old._stop = true; old.remove(); }
}
/**
* Replaces the <video> with a canvas whose keyed colour is made transparent.
* Falls back to the plain video if the source taints the canvas (cross-origin).
*/
function setupUIVideoChroma(container, vid, el) {
stopUIVideoChroma(container);
if (!container || !vid) return;
vid.style.removeProperty('display');
if (!el.chromaKey) return;
var canvas = document.createElement('canvas');
canvas.className = 'ui-chroma';
canvas.style.cssText = vid.style.cssText;
canvas.style.background = 'transparent';
var ctx = null;
try { ctx = canvas.getContext('2d', { willReadFrequently: true }); } catch(e) {}
if (!ctx) return;
vid.style.display = 'none';
container.appendChild(canvas);
var key = uiHexToRgb(el.chromaColor || '#00ff00');
var sim = el.chromaSimilarity !== undefined ? parseFloat(el.chromaSimilarity) : 0.4;
var smooth = el.chromaSmoothness !== undefined ? parseFloat(el.chromaSmoothness) : 0.08;
var spill = el.chromaSpill !== undefined ? parseFloat(el.chromaSpill) : 0;
var lastTime = -1, lastReady = -1;
function draw() {
if (canvas._stop || !canvas.isConnected) return;
var w = vid.videoWidth, h = vid.videoHeight;
// Only re-key when there is a new frame (a paused video stays cheap)
if (w && h && (vid.currentTime !== lastTime || vid.readyState !== lastReady)) {
lastTime = vid.currentTime; lastReady = vid.readyState;
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
try {
ctx.clearRect(0, 0, w, h);
ctx.drawImage(vid, 0, 0, w, h);
var img = ctx.getImageData(0, 0, w, h), d = img.data;
var range = Math.max(0.0001, smooth);
for (var i = 0; i < d.length; i += 4) {
var dr = d[i] - key.r, dg = d[i + 1] - key.g, db = d[i + 2] - key.b;
var dist = Math.sqrt(dr * dr + dg * dg + db * db) / 441.6729;
if (dist < sim) {
d[i + 3] = 0;
} else if (dist < sim + range) {
var a = (dist - sim) / range;
d[i + 3] = d[i + 3] * a;
if (spill > 0) { // pull the key colour out of the soft edge
d[i] = d[i] + (d[i] - key.r) * spill * (1 - a);
d[i + 1] = d[i + 1] + (d[i + 1] - key.g) * spill * (1 - a);
d[i + 2] = d[i + 2] + (d[i + 2] - key.b) * spill * (1 - a);
}
}
}
ctx.putImageData(img, 0, 0);
} catch(err) {
// Tainted canvas: the browser refuses to read frames from a cross-origin or
// file:// video. Try once with CORS, then fall back to the untouched video.
if (!vid._chromaRetried && /^https?:/i.test(vid.src)) {
vid._chromaRetried = true;
var wasTime = vid.currentTime, wasPlaying = !vid.paused;
vid.crossOrigin = 'anonymous';
vid.load();
vid.addEventListener('loadeddata', function once() {
vid.removeEventListener('loadeddata', once);
try { vid.currentTime = wasTime; } catch(e) {}
if (wasPlaying) vid.play().catch(function(){});
});
lastTime = -1; lastReady = -1;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(draw);
return;
}
console.warn('[Video] chroma key unavailable for this source (' + (err && err.message) + '). ' +
'Frames cannot be read from a file:// or cross-origin video — embed the video in the export, ' +
'or serve it from the same origin with CORS enabled.');
canvas._stop = true;
canvas.remove();
vid.style.removeProperty('display');
return;
}
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(draw);
else window.setTimeout(draw, 40);
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(draw);
else window.setTimeout(draw, 40);
}
// ── Video helpers (shared by canvas + preview) ──
function applyVideoFit(vid, el) {
const fit = el.videoFit || 'contain';
let css = 'display:block;border-radius:' + (el.borderRadius || 0) + ';';
if (fit === 'fit-width') css += 'width:100%;height:auto;';
else if (fit === 'fit-height') css += 'height:100%;width:auto;';
else css += 'width:100%;height:100%;object-fit:' + (fit === 'stretch' ? 'fill' : fit) + ';';
vid.style.cssText = css;
}
function applyVideoPlayback(vid, el, live) {
vid.muted = el.muted !== false;
vid.volume = Math.max(0, Math.min(1, el.volume !== undefined ? el.volume : 1));
vid.loop = el.loopMode === 'loop';
vid.controls = !!(live && el.controls);
// Clear previous handlers
if (vid._endedHandler) { vid.removeEventListener('ended', vid._endedHandler); vid._endedHandler = null; }
if (vid._clickHandler) { vid.removeEventListener('click', vid._clickHandler); vid._clickHandler = null; }
if (!live) {
vid.autoplay = false;
if (!vid.paused) vid.pause();
try { vid.currentTime = 0; } catch(e) {}
return;
}
// Live (preview / runtime) behavior
if (el.loopMode === 'count') {
let played = 1;
vid._endedHandler = () => {
if (played < (parseInt(el.loopCount) || 1)) { played++; vid.currentTime = 0; vid.play().catch(() => {}); }
};
vid.addEventListener('ended', vid._endedHandler);
}
if ((el.playMode || 'auto') === 'auto') {
vid.autoplay = true;
vid.play().catch(() => {});
} else if (el.playMode === 'click' && !el.controls) {
vid.style.cursor = 'pointer';
vid._clickHandler = () => { if (vid.paused) vid.play().catch(() => {}); else vid.pause(); };
vid.addEventListener('click', vid._clickHandler);
}
}
function renderElement(el) {
let dom = viewport.querySelector(`[data-id="${el.id}"]`);
if (!dom) {
dom = document.createElement('div');
dom.className = 'ui-el';
dom.dataset.id = el.id;
// Resize handles
['tl','tr','bl','br','t','b','l','r'].forEach(pos => {
const h = document.createElement('div');
h.className = `resize-handle rh-${pos}`;
h.dataset.handle = pos;
dom.appendChild(h);
});
// Element interaction
dom.addEventListener('pointerdown', e => onElementPointerDown(e, el.id));
}
// Place in correct parent container
let parentContainer = viewport;
if (el.parentId) {
const parentInner = viewport.querySelector(`[data-id="${el.parentId}"] .ui-el-inner .ui-el-children`);
if (parentInner) parentContainer = parentInner;
}
if (dom.parentElement !== parentContainer) {
parentContainer.appendChild(dom);
}
// Position — children use relative positioning within parent
const isChild = el.parentId && parentContainer !== viewport;
const anchor = getVal(el, 'anchor') || 'center';
dom.style.left = getVal(el,'x') + '%';
dom.style.top = getVal(el,'y') + '%';
dom.style.transform = uiAnchorTransform(anchor);
const rw = getVal(el,'w'); const rh = getVal(el,'h');
dom.style.width = rw > 0 ? rw + '%' : 'auto';
dom.style.height = rh > 0 ? rh + '%' : 'auto';
dom.style.zIndex = el.zIndex;
// ── Visibility resolution: reset first, then apply correct state ──
dom.classList.remove('el-hidden-edit', 'el-disabled');
dom.style.display = '';
dom.style.pointerEvents = previewMode && el.locked ? 'none' : 'auto';
dom.style.opacity = getVal(el, 'opacity');
if (el.disabled) {
dom.style.display = 'none';
dom.classList.add('el-hidden-edit', 'el-disabled');
} else if (el.showOn && el.showOn.length > 0 && !el.showOn.includes(deviceMode)) {
if (previewMode) {
dom.style.display = 'none';
} else {
dom.style.opacity = '0.15';
}
dom.classList.add('el-hidden-edit');
} else {
const elVisible = getVal(el,'visible');
const effectivelyVisible = elVisible !== false && isEffectivelyVisible(el);
if (!effectivelyVisible) {
dom.style.opacity = '0';
dom.style.pointerEvents = 'none';
dom.classList.add('el-hidden-edit');
}
}
// Build inner content based on type
let inner = dom.querySelector('.ui-el-inner');
if (!inner) {
inner = document.createElement('div');
inner.className = 'ui-el-inner';
dom.insertBefore(inner, dom.firstChild);
}
// Apply styles (using getVal for overridable properties)
const s = inner.style;
s.width = '100%'; s.height = '100%';
s.fontFamily = el.fontFamily;
s.fontSize = getVal(el,'fontSize') + (getVal(el,'fontUnit') || 'rem');
s.fontWeight = getVal(el,'fontWeight');
s.color = getVal(el,'color');
s.background = getVal(el,'background');
s.borderRadius = getVal(el,'borderRadius');
s.padding = getVal(el,'padding');
s.border = getVal(el,'border');
s.textAlign = getVal(el,'textAlign');
s.textShadow = el.textShadow;
s.boxShadow = getVal(el,'boxShadow');
s.letterSpacing = getVal(el,'letterSpacing');
s.lineHeight = el.lineHeight || 'normal';
s.textTransform = el.textTransform;
s.overflow = el.type === 'panel' ? 'visible' : 'hidden';
s.position = (el.type === 'panel' || el.type === 'hero') ? 'relative' : '';
if (el.backdropFilter) s.backdropFilter = el.backdropFilter;
if (el.objectFit) s.objectFit = el.objectFit;
if (el.aspectRatio) s.aspectRatio = el.aspectRatio;
if (el.minWidth) s.minWidth = el.minWidth;
if (el.minHeight) s.minHeight = el.minHeight;
if (el.cursor) s.cursor = el.cursor;
if (el.display) s.display = el.display;
if (el.justifyContent) s.justifyContent = el.justifyContent;
if (el.alignItems) s.alignItems = el.alignItems;
if (el.borderTop) s.borderTop = el.borderTop;
// Apply custom CSS
if (el.customCSS) {
try {
const pairs = el.customCSS.split(';').filter(p => p.includes(':'));
pairs.forEach(p => {
const [prop, ...rest] = p.split(':');
const cssProp = prop.trim();
const val = rest.join(':').trim();
if (cssProp && val) {
// Convert CSS prop to camelCase for style assignment
const camel = cssProp.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
inner.style[camel] = val;
}
});
} catch(e) {}
}
// Animation sequences run in Preview only — the canvas stays still while designing.
// (Use the Play buttons in the Animation section to test a stage on the canvas.)
if (el.animEnterClass || el.animAttnClass || el.animExitClass) {
if (previewMode) {
window.setTimeout(() => {
const iv = dom.querySelector('.ui-el-inner');
if (iv) runUIAnimSequence(iv, el, 'all');
}, 0);
} else {
clearUIAnim(inner);
}
} else {
// Legacy single animation (older projects)
inner.classList.forEach(c => { if (c.startsWith('animate__')) inner.classList.remove(c); });
if (el.animateClass) {
inner.classList.add('animate__animated', el.animateClass);
if (el.animateDelay) inner.style.animationDelay = el.animateDelay;
else inner.style.removeProperty('animation-delay');
if (el.animateDuration) inner.style.animationDuration = el.animateDuration;
else inner.style.removeProperty('animation-duration');
if (el.animateRepeat) inner.style.animationIterationCount = el.animateRepeat;
else inner.style.removeProperty('animation-iteration-count');
} else {
inner.style.removeProperty('animation-delay');
inner.style.removeProperty('animation-duration');
inner.style.removeProperty('animation-iteration-count');
}
}
// Type-specific rendering
if (el.type === 'text' || el.type === 'button') {
inner.textContent = el.content;
if (el.type === 'button' && !previewMode) { s.cursor = 'inherit'; }
} else if (el.type === 'image') {
if (el.src && el.objectFit === 'background') {
inner.innerHTML = '';
inner.style.backgroundImage = `url(${el.src})`;
inner.style.backgroundSize = el.bgSize || 'cover';
inner.style.backgroundPosition = el.bgPosition || 'center';
inner.style.backgroundRepeat = el.bgRepeat || 'no-repeat';
} else if (el.src) {
inner.innerHTML = `<img src="${el.src}" style="width:100%;height:100%;object-fit:${el.objectFit};border-radius:${el.borderRadius};display:block;" draggable="false">`;
} else {
inner.innerHTML = '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,0.03);border:1px dashed rgba(255,255,255,0.15);border-radius:' + el.borderRadius + ';"><i class="bi bi-image" style="font-size:1.5rem;color:var(--text-muted);"></i></div>';
}
} else if (el.type === 'video') {
if (el.src) {
let vid = inner.querySelector('video');
if (!vid || vid.dataset.src !== el.src) {
inner.innerHTML = '';
vid = document.createElement('video');
vid.dataset.src = el.src;
vid.src = el.src;
vid.preload = 'metadata';
vid.playsInline = true;
inner.appendChild(vid);
}
vid.poster = el.videoPoster || '';
applyVideoFit(vid, el);
vid.style.mixBlendMode = (el.videoBlend && el.videoBlend !== 'none') ? el.videoBlend : '';
setupUIVideoChroma(inner, vid, el);
if (el.videoFit === 'fit-width' || el.videoFit === 'fit-height') {
s.display = 'flex'; s.alignItems = 'center'; s.justifyContent = 'center';
}
applyVideoPlayback(vid, el, previewMode);
// Edit-mode badge showing play mode
let badge = inner.querySelector('.video-badge');
if (!previewMode) {
if (!badge) {
badge = document.createElement('div');
badge.className = 'video-badge';
badge.style.cssText = 'position:absolute;top:4px;left:4px;background:rgba(0,0,0,0.65);color:#00e5a0;font-family:"Orbitron",sans-serif;font-size:0.42rem;letter-spacing:1px;padding:2px 6px;border-radius:4px;pointer-events:none;z-index:2;';
inner.style.position = 'relative';
inner.appendChild(badge);
}
const loopTxt = el.loopMode === 'loop' ? '∞' : (el.loopMode === 'count' ? '×' + (el.loopCount || 1) : '×1');
badge.innerHTML = '<i class="bi bi-film"></i> ' + (el.playMode || 'auto').toUpperCase() + ' ' + loopTxt;
} else if (badge) {
badge.remove();
}
} else {
inner.innerHTML = '<div style="width:100%;height:100%;display:flex;flex-direction:column;gap:4px;align-items:center;justify-content:center;background:rgba(0,0,0,0.35);border:1px dashed rgba(255,255,255,0.2);border-radius:' + el.borderRadius + ';"><i class="bi bi-film" style="font-size:1.6rem;color:var(--text-muted);"></i><span style="font-size:0.5rem;color:var(--text-muted);">Set video src</span></div>';
}
} else if (el.type === 'model3d') {
const modelSig = JSON.stringify([el.src, el.modelBg, el.modelAutoRotate, el.modelRotateSpeed,
el.modelControls, el.modelZoom, el.modelPan, el.modelAutoFit, el.modelDistance, el.modelFov,
el.modelCamHeight, el.modelTargetY, el.modelX, el.modelY, el.modelZ, el.modelScale,
el.modelRotX, el.modelRotY, el.modelRotZ, el.modelLighting, el.modelLightIntensity,
el.modelShadows, el.modelHemi, el.modelPlayClips, el.modelLiveOnCanvas,
el.modelDragRotate, el.modelDragSpeed, el.modelZoomSpeed, el.modelDamping,
el.modelStopSpinOnInteract, el.modelResumeSpin, el.modelMinDistance, el.modelMaxDistance,
el.modelMinPolar, el.modelMaxPolar, previewMode]);
if (dom.dataset.modelSig !== modelSig || !inner.firstChild) {
dom.dataset.modelSig = modelSig;
// On the canvas the model renders but only spins when "Live on canvas" is on
renderUIModel(inner, el, previewMode || el.modelLiveOnCanvas !== false);
}
s.overflow = 'hidden';
} else if (el.type === 'iframe') {
const frameSig = JSON.stringify([el.src, el.frameAllow, el.frameSandbox, el.frameSandboxRules,
el.frameScroll, el.frameLazy, el.frameAllowFullscreen, el.framePreviewOnly, el.borderRadius,
el.frameBorder, el.frameBg, previewMode]);
if (dom.dataset.frameSig !== frameSig || !inner.firstChild) {
dom.dataset.frameSig = frameSig;
renderUIFrame(inner, el, previewMode);
}
} else if (el.type === 'hero') {
const heroSig = JSON.stringify([el.heroSlides, el.heroTitle, el.heroSubtitle, el.heroTransition,
el.heroTransDuration, el.heroKenBurns, el.heroDots, el.heroArrows, el.heroStart,
el.heroAutoplay, el.heroInterval, previewMode]);
if (dom.dataset.heroSig !== heroSig || !inner.querySelector('.hero-wrap')) {
dom.dataset.heroSig = heroSig;
renderUIHero(inner, el, previewMode);
// children live inside the hero, so re-mount them after a rebuild
elements.filter(c => c.parentId === el.id).forEach(c => renderElement(c));
} else {
applyHeroCSS(el);
const hw = inner.querySelector('.hero-wrap');
if (hw) { hw._heroEl = el; heroShowSlide(hw, el, hw._heroIndex || 0, previewMode); }
}
s.overflow = 'hidden'; s.position = 'relative';
} else if (el.type === 'carousel') {
// Only rebuild (and re-init Owl) when content/options change — not on drag/resize
const carSig = JSON.stringify([el.carouselItems, el.carItems, el.carItemsTablet, el.carItemsPhone, el.carMargin,
el.carStagePadding, el.carAutoWidth, el.carLoop, el.carCenter, el.carAutoplay, el.carAutoplayTimeout,
el.carAutoplayHoverPause, el.carSpeed, el.carMouseDrag, el.carNav, el.carDots, el.carStart,
el.carItemTemplate, el.carShowImage, el.carShowTitle, el.carShowText, el.carItemClick,
el.carImgMode, el.carImgPosition, el.carCoverAlign,
el.carFgInset, el.carFgFit, el.carFgPosition, el.carFgRadius, el.carFgShadow, el.carModelX, el.carModelZ,
el.carFgPadding, el.carFgWidth, el.carFgHeight, el.carFgAlignH, el.carFgAlignV,
el.carModelDragRotate, el.carModelZoom, el.carModelPan, el.carModelDragSpeed, el.carModelZoomSpeed,
el.carModelDamping, el.carModelStopSpinOnInteract, el.carModelResumeSpin,
el.carModelMinDistance, el.carModelMaxDistance, el.carModelMinPolar, el.carModelMaxPolar,
el.carLinks, el.carChangeFunc, el.carClickToCenter, el.carShowFields, el.carActiveSlot,
el.carActiveZ, el.carPadTop, el.carPadBottom,
el.carItemsPhoneLand, el.carDotPosition, el.carResetVisibility, deviceMode, el.carDeviceOverrides,
el.carModelHeight, el.carModelBg, el.carModelAutoRotate, el.carModelRotateSpeed, el.carModelControls,
el.carModelDistance, el.carModelFov, el.carModelLighting, el.carModelLightIntensity, el.carModelScale,
el.carModelWidth, el.carModelInset, el.carModelRotX, el.carModelRotY, el.carModelRotZ, el.carModelY,
el.carSideGap, el.carSideGapStacks, el.carModelSpinActiveOnly,
el.carNavPrevIcon, el.carNavNextIcon, previewMode]);
window.__uiCarouselDevice = deviceMode === 'desktop' ? null : deviceMode;
if (dom.dataset.carSig !== carSig || !inner.firstChild) {
dom.dataset.carSig = carSig;
renderUICarousel(inner, el, previewMode);
} else {
applyCarouselCSS(el); // pure styling change → just refresh the CSS
const wrapEl = inner.querySelector('.car-wrap');
if (wrapEl) syncCarouselLinks(el, wrapEl);
}
s.display = 'flex'; s.alignItems = 'center'; s.overflow = 'visible';
} else if (el.type === 'icon') {
inner.innerHTML = `<i class="bi ${el.iconClass}" style="font-size:${el.fontSize}rem;color:${el.color};text-shadow:${el.textShadow};"></i>`;
} else if (el.type === 'progressbar') {
const pct = Math.max(0, Math.min(100, el.barValue));
let labelHtml = '';
if (el.showLabel && el.labelText) {
labelHtml = `<span style="position:absolute;left:6px;top:50%;transform:translateY(-50%);font-family:'Rajdhani',sans-serif;font-size:0.55rem;font-weight:700;color:rgba(255,255,255,0.8);text-shadow:0 1px 2px rgba(0,0,0,0.5);z-index:1;">${el.labelText}</span>`;
}
inner.innerHTML = `
<div style="position:relative;width:100%;height:${el.barHeight};background:${el.barBgColor};border-radius:${el.borderRadius};overflow:hidden;">
${labelHtml}
<div style="width:${pct}%;height:100%;background:${el.barColor};border-radius:${el.borderRadius};transition:width 0.3s;"></div>
</div>`;
} else if (el.type === 'panel') {
// Only clear text, preserve children container
const existingChildren = inner.querySelector('.ui-el-children');
if (!existingChildren) {
inner.innerHTML = '';
const childBox = document.createElement('div');
childBox.className = 'ui-el-children';
childBox.style.cssText = 'position:absolute;inset:0;pointer-events:none;';
inner.appendChild(childBox);
}
// Ensure children container pointer events pass through but children catch them
const cbox = inner.querySelector('.ui-el-children');
if (cbox) {
cbox.querySelectorAll('.ui-el').forEach(c => { c.style.pointerEvents = 'auto'; });
}
// Control mode indicator
let ctrlIndicator = inner.querySelector('.ctrl-indicator');
const cArrow = el.controlKnob || '#00e5a0';
const cBg = el.controlBg || 'rgba(255,255,255,0.08)';
if (el.controlMode === 'dpad') {
if (!ctrlIndicator) { ctrlIndicator = document.createElement('div'); ctrlIndicator.className = 'ctrl-indicator'; inner.appendChild(ctrlIndicator); }
ctrlIndicator.innerHTML = `<div style="position:absolute;inset:5%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;pointer-events:none;">
<div style="display:flex;align-items:center;justify-content:center;width:36%;aspect-ratio:1;border-radius:8px;background:${cBg};border:1px solid rgba(255,255,255,0.15);"><i class="bi bi-caret-up-fill" style="font-size:1.2rem;color:${cArrow};"></i></div>
<div style="display:flex;gap:2px;width:100%;justify-content:center;">
<div style="display:flex;align-items:center;justify-content:center;width:36%;aspect-ratio:1;border-radius:8px;background:${cBg};border:1px solid rgba(255,255,255,0.15);"><i class="bi bi-caret-left-fill" style="font-size:1.2rem;color:${cArrow};"></i></div>
<div style="display:flex;align-items:center;justify-content:center;width:36%;aspect-ratio:1;border-radius:8px;background:${cBg};border:1px solid rgba(255,255,255,0.15);"><i class="bi bi-caret-right-fill" style="font-size:1.2rem;color:${cArrow};"></i></div>
</div>
<div style="display:flex;align-items:center;justify-content:center;width:36%;aspect-ratio:1;border-radius:8px;background:${cBg};border:1px solid rgba(255,255,255,0.15);"><i class="bi bi-caret-down-fill" style="font-size:1.2rem;color:${cArrow};"></i></div>
</div>`;
} else if (el.controlMode === 'joystick') {
if (!ctrlIndicator) { ctrlIndicator = document.createElement('div'); ctrlIndicator.className = 'ctrl-indicator'; inner.appendChild(ctrlIndicator); }
const knobC = el.controlKnob || 'rgba(0,229,160,0.5)';
ctrlIndicator.innerHTML = `<div style="position:absolute;inset:10%;border-radius:50%;background:${cBg};border:1px solid rgba(255,255,255,0.1);display:flex;justify-content:center;align-items:center;pointer-events:none;"><div style="width:40%;height:40%;border-radius:50%;background:${knobC};border:2px solid ${knobC};opacity:0.7;"></div></div>`;
} else if (ctrlIndicator) {
ctrlIndicator.remove();
}
}
// Locked / selected state
dom.classList.toggle('locked', el.locked);
dom.classList.toggle('selected', el.id === selectedId);
// Store actions ref for preview click delegation
dom._elEvents = el.events || {};
dom._elData = el;
// ── Hover / Active interaction states ──
const inner2 = dom.querySelector('.ui-el-inner');
if (inner2) {
// Store originals for restore
const origBg = el.background;
const origColor = el.color;
const origTransform = dom.style.transform;
// Remove old listeners (use named refs on dom)
if (dom._hEnter) inner2.removeEventListener('mouseenter', dom._hEnter);
if (dom._hLeave) inner2.removeEventListener('mouseleave', dom._hLeave);
if (dom._aDown) inner2.removeEventListener('mousedown', dom._aDown);
if (dom._aUp) inner2.removeEventListener('mouseup', dom._aUp);
const hasHover = el.hoverBackground || el.hoverColor || el.hoverScale || el.hoverSrc || el.hoverBoxShadow;
const hasActive = el.activeBackground || el.activeColor || el.activeScale || el.activeSrc || el.activeBoxShadow;
if (hasHover || hasActive) {
dom._hEnter = () => {
if (!previewMode) return;
if (el.hoverBackground) inner2.style.background = el.hoverBackground;
if (el.hoverColor) inner2.style.color = el.hoverColor;
if (el.hoverScale) dom.style.transform = origTransform + ` scale(${el.hoverScale})`;
if (el.hoverBoxShadow) inner2.style.boxShadow = el.hoverBoxShadow;
if (el.hoverSrc && el.type === 'image') {
const img = inner2.querySelector('img');
if (img) img.src = el.hoverSrc;
}
inner2.style.transition = 'all 0.15s ease';
};
dom._hLeave = () => {
if (!previewMode) return;
inner2.style.background = origBg;
inner2.style.color = el.color;
inner2.style.boxShadow = el.boxShadow || '';
dom.style.transform = origTransform;
if (el.hoverSrc && el.type === 'image') {
const img = inner2.querySelector('img');
if (img) img.src = el.src;
}
};
dom._aDown = () => {
if (!previewMode) return;
if (el.activeBackground) inner2.style.background = el.activeBackground;
if (el.activeColor) inner2.style.color = el.activeColor;
if (el.activeScale) dom.style.transform = origTransform + ` scale(${el.activeScale})`;
if (el.activeBoxShadow) inner2.style.boxShadow = el.activeBoxShadow;
if (el.activeSrc && el.type === 'image') {
const img = inner2.querySelector('img');
if (img) img.src = el.activeSrc;
}
};
dom._aUp = () => {
if (!previewMode) return;
if (el.hoverBackground) inner2.style.background = el.hoverBackground;
else inner2.style.background = origBg;
if (el.hoverColor) inner2.style.color = el.hoverColor;
else inner2.style.color = el.color;
inner2.style.boxShadow = el.hoverBoxShadow || el.boxShadow || '';
dom.style.transform = origTransform;
};
inner2.addEventListener('mouseenter', dom._hEnter);
inner2.addEventListener('mouseleave', dom._hLeave);
inner2.addEventListener('mousedown', dom._aDown);
inner2.addEventListener('mouseup', dom._aUp);
}
}
}
// ── Preview action execution (mirrors game-level) ──
function previewSetVisible(target, visible) {
if (target.disabled) return;
if (target.showOn && target.showOn.length > 0 && !target.showOn.includes(deviceMode)) return;
target.visible = visible;
const dom = viewport.querySelector(`[data-id="${target.id}"]`);
if (dom) {
dom.style.opacity = visible ? target.opacity : '0';
dom.style.pointerEvents = visible ? 'auto' : 'none';
dom.classList.toggle('el-hidden-edit', !visible);
}
// Cascade DOM-only to children — do NOT modify child.visible
previewCascadeChildren(target.id, visible);
}
function previewCascadeChildren(parentId, parentVisible) {
elements.filter(e => e.parentId === parentId).forEach(child => {
if (child.disabled) return;
if (child.showOn && child.showOn.length > 0 && !child.showOn.includes(deviceMode)) return;
const childShouldShow = parentVisible && child.visible;
const childDom = viewport.querySelector(`[data-id="${child.id}"]`);
if (childDom) {
childDom.style.opacity = childShouldShow ? child.opacity : '0';
childDom.style.pointerEvents = childShouldShow ? 'auto' : 'none';
childDom.classList.toggle('el-hidden-edit', !childShouldShow);
}
previewCascadeChildren(child.id, childShouldShow);
});
}
function executePreviewActions(actions, sourceEl, mode) {
const isChain = mode === 'chain';
let cumDelay = 0;
actions.forEach((act, i) => {
if (isChain) {
cumDelay += (i > 0 ? (act.delay || 0) : 0);
}
const delay = isChain ? cumDelay : (act.delay || 0);
setTimeout(() => {
const targetId = act.targetId || sourceEl.id;
const target = elements.find(e => e.id === targetId);
if (!target && !['callFunction','addAnimation','removeAnimation','playSound','loadScene','playMusic','stopMusic','setVolume','loadURL','showHTML'].includes(act.type)) return;
const targetDom = viewport.querySelector(`[data-id="${targetId}"]`);
switch (act.type) {
case 'show':
if (target) previewSetVisible(target, true);
break;
case 'hide':
if (target) previewSetVisible(target, false);
break;
case 'toggle':
if (target) previewSetVisible(target, !target.visible);
break;
case 'setText':
if (target) {
target.content = act.value || '';
const inn = targetDom?.querySelector('.ui-el-inner');
if (inn && (target.type === 'text' || target.type === 'button')) inn.textContent = act.value || '';
}
break;
case 'setProgress':
if (targetDom) {
const fill = targetDom.querySelector('[style*="transition:width"]');
if (fill) fill.style.width = Math.max(0, Math.min(100, act.value || 0)) + '%';
}
break;
case 'playAnimation':
{
const aDom = targetDom || viewport.querySelector(`[data-id="${sourceEl.id}"]`);
const aInner = aDom ? aDom.querySelector('.ui-el-inner') : null;
const aEl = (act.targetId ? elements.find(e => e.id === act.targetId) : sourceEl) || sourceEl;
if (aInner) runUIAnimSequence(aInner, aEl, act.animStage || 'all');
}
break;
case 'modelControl':
{
const mDom = targetDom || viewport.querySelector(`[data-id="${sourceEl.id}"]`);
if (mDom) controlUIModel(mDom, act.modelCmd, act.modelValue);
}
break;
case 'frameControl':
{
const fDom = targetDom || viewport.querySelector(`[data-id="${sourceEl.id}"]`);
if (fDom) controlUIFrame(fDom, act.frameCmd, act.frameValue);
}
break;
case 'heroControl':
{
const hDom = targetDom || viewport.querySelector(`[data-id="${sourceEl.id}"]`);
if (hDom) controlUIHero(hDom, act.heroCmd, act.heroIndex);
}
break;
case 'carouselControl':
{
const cDom = targetDom || viewport.querySelector(`[data-id="${sourceEl.id}"]`);
if (cDom) controlUICarousel(cDom, act.carCmd, act.carIndex);
else showToast('carouselControl: target not found', 'warn');
}
break;
case 'videoControl':
{
const vDom = targetDom || viewport.querySelector(`[data-id="${sourceEl.id}"]`);
const vid = vDom ? vDom.querySelector('video') : null;
if (vid) {
switch (act.videoCmd || 'play') {
case 'play': vid.play().catch(() => {}); break;
case 'pause': vid.pause(); break;
case 'toggle': vid.paused ? vid.play().catch(() => {}) : vid.pause(); break;
case 'stop': vid.pause(); try { vid.currentTime = 0; } catch(e) {} break;
case 'restart': try { vid.currentTime = 0; } catch(e) {} vid.play().catch(() => {}); break;
}
} else {
showToast('videoControl: target has no video', 'warn');
}
}
break;
case 'playSound':
if (act.soundUrl) {
try {
const audio = new Audio(act.soundUrl);
audio.volume = act.volume !== undefined ? act.volume : 1;
audio.play().catch(e => showToast('Audio: ' + e.message, 'warn'));
} catch(e) { showToast('Sound error', 'error'); }
}
break;
case 'callFunction':
if (act.funcName) {
try {
if (typeof window[act.funcName] === 'function') {
let extraArgs = [];
if (act.funcArgs) {
try { extraArgs = Function(`return [${act.funcArgs}]`)(); } catch(e) {}
}
window[act.funcName](...extraArgs, sourceEl, target);
}
}
catch(err) { showToast('Func error: ' + err.message, 'error'); }
}
break;
case 'addAnimation':
{
// Apply to INNER element to avoid overriding wrapper's position transform
const aDom = targetDom || viewport.querySelector(`[data-id="${sourceEl.id}"]`);
const aInner = aDom ? aDom.querySelector('.ui-el-inner') : null;
if (aInner && act.animClass) {
// Clear any previous action animation classes
[...aInner.classList].filter(c => c.startsWith('animate__') && c !== 'animate__animated').forEach(c => aInner.classList.remove(c));
aInner.classList.remove('animate__animated');
void aInner.offsetWidth; // force reflow
aInner.classList.add('animate__animated', act.animClass);
if (act.animDuration) aInner.style.setProperty('--animate-duration', act.animDuration);
else aInner.style.removeProperty('--animate-duration');
if (act.animDelay) aInner.style.animationDelay = act.animDelay;
else aInner.style.animationDelay = '';
if (act.animRepeat) aInner.style.animationIterationCount = act.animRepeat;
else aInner.style.animationIterationCount = '';
}
}
break;
case 'removeAnimation':
{
const rDom = targetDom || viewport.querySelector(`[data-id="${sourceEl.id}"]`);
const rInner = rDom ? rDom.querySelector('.ui-el-inner') : null;
if (rInner) {
if (act.animClass) {
rInner.classList.remove(act.animClass);
const hasOtherAnim = [...rInner.classList].some(c => c.startsWith('animate__') && c !== 'animate__animated');
if (!hasOtherAnim) rInner.classList.remove('animate__animated');
} else {
[...rInner.classList].filter(c => c.startsWith('animate__')).forEach(c => rInner.classList.remove(c));
}
rInner.style.removeProperty('--animate-duration');
rInner.style.animationDelay = '';
rInner.style.animationIterationCount = '';
}
}
break;
case 'loadScene':
showToast('Load Scene: ' + (act.levelFile || '?') + ' — runs in game-level', 'info');
break;
case 'playMusic':
showToast('Play Music: ' + (act.musicSrc || '?'), 'info');
break;
case 'stopMusic':
showToast('Stop Music', 'info');
break;
case 'setVolume':
showToast('Set Volume: ' + Math.round((act.volume || 0.5) * 100) + '%', 'info');
break;
case 'loadURL':
showToast('Load URL: ' + (act.url || '?') + ' (' + (act.urlTarget || 'newTab') + ')', 'info');
break;
case 'showHTML':
showToast('Show HTML (' + (act.htmlMode || 'popup') + ')', 'info');
break;
}
}, delay);
});
}
// Delegated preview event handlers on viewport
function previewEventHandler(eventType, e) {
if (!previewMode) return;
const elDom = e.target.closest('.ui-el');
if (!elDom || !elDom._elEvents) return;
const acts = elDom._elEvents[eventType];
if (acts && acts.length > 0) {
e.stopPropagation();
const mode = elDom._elEvents[eventType + 'Mode'] || 'parallel';
executePreviewActions(acts, elDom._elData, mode);
}
}
viewport.addEventListener('click', e => previewEventHandler('click', e));
viewport.addEventListener('dblclick', e => previewEventHandler('dblclick', e));
viewport.addEventListener('mousedown', e => previewEventHandler('mousedown', e));
viewport.addEventListener('mouseup', e => previewEventHandler('mouseup', e));
viewport.addEventListener('mouseenter', e => {
if (!previewMode) return;
const elDom = e.target.closest('.ui-el');
if (elDom && elDom._elEvents && elDom._elEvents.hover && elDom._elEvents.hover.length > 0) {
const mode = elDom._elEvents.hoverMode || 'parallel';
executePreviewActions(elDom._elEvents.hover, elDom._elData, mode);
}
}, true);
function renderAllElements() {
viewport.querySelectorAll('.ui-el').forEach(d => d.remove());
// Render parents first, then children
const parents = elements.filter(e => !e.parentId);
const children = elements.filter(e => e.parentId);
parents.forEach(el => renderElement(el));
children.forEach(el => renderElement(el));
// Carousels drive other elements — re-apply their links after a full rebuild
elements.filter(e => e.type === 'carousel').forEach(el => {
const dom = viewport.querySelector(`[data-id="${el.id}"]`);
const wrapEl = dom ? dom.querySelector('.car-wrap') : null;
if (wrapEl) syncCarouselLinks(el, wrapEl);
});
}
// ══════════════════════════════════════
// SELECTION
// ══════════════════════════════════════
function selectElement(id) {
selectedId = id;
viewport.querySelectorAll('.ui-el').forEach(d => d.classList.toggle('selected', d.dataset.id === id));
const el = elements.find(e => e.id === id);
if (el) {
propsPanel.classList.add('visible');
canvasArea.classList.remove('props-hidden');
buildPropsPanel(el);
}
buildLayerList();
}
function deselectAll() {
selectedId = null;
viewport.querySelectorAll('.ui-el.selected').forEach(d => d.classList.remove('selected'));
propsPanel.classList.remove('visible');
canvasArea.classList.add('props-hidden');
buildLayerList();
}
// ══════════════════════════════════════
// PROPERTIES PANEL
// ══════════════════════════════════════
// Overridable properties per device
const OVERRIDE_PROPS = ['x','y','w','h','fontSize','fontWeight','padding','background',
'borderRadius','border','opacity','color','textAlign','boxShadow','letterSpacing','visible'];
function getVal(el, prop) {
if (el.responsive && deviceMode !== 'desktop') {
const ov = (el.overrides || {})[deviceMode] || {};
if (ov[prop] !== undefined && ov[prop] !== '') return ov[prop];
}
return el[prop];
}
function setVal(el, prop, val) {
if (el.responsive && deviceMode !== 'desktop') {
if (!el.overrides) el.overrides = {};
if (!el.overrides[deviceMode]) el.overrides[deviceMode] = {};
el.overrides[deviceMode][prop] = val;
} else {
el[prop] = val;
}
renderElement(el);
}
function clearOverride(el, prop) {
if (el.overrides && el.overrides[deviceMode]) {
delete el.overrides[deviceMode][prop];
}
}
function hasOverride(el, prop) {
if (!el.responsive || deviceMode === 'desktop') return false;
return el.overrides && el.overrides[deviceMode] && el.overrides[deviceMode][prop] !== undefined;
}
function buildPropsPanel(el) {
document.getElementById('pp-title').textContent = el.name || 'Properties';
ppContext.innerHTML = '';
ppStyle.innerHTML = '';
ppProps.innerHTML = '';
// ── Device badge + Responsive toggle ──
const devNames = { desktop:'Desktop', tablet:'Tablet', phone:'Phone', 'phone-land':'Phone Land' };
const badge = document.createElement('div');
badge.className = 'pp-device-badge' + (deviceMode === 'desktop' ? ' desktop' : '');
badge.innerHTML = `<i class="bi ${deviceMode === 'desktop' ? 'bi-display' : deviceMode === 'tablet' ? 'bi-tablet' : 'bi-phone'}"></i> ${devNames[deviceMode] || deviceMode}`;
ppContext.appendChild(badge);
const respRow = document.createElement('div');
respRow.className = 'pp-responsive-toggle';
const respCheck = document.createElement('input');
respCheck.type = 'checkbox'; respCheck.className = 'pp-check';
if (!el.overrides) el.overrides = { tablet:{}, phone:{}, 'phone-land':{} };
respCheck.checked = !!el.responsive;
respCheck.onchange = () => { el.responsive = respCheck.checked; buildPropsPanel(el); };
const respLabel = document.createElement('span');
respLabel.textContent = 'Per-device layout & style';
respRow.appendChild(respCheck); respRow.appendChild(respLabel);
if (el.responsive && deviceMode !== 'desktop') {
const resetBtn = document.createElement('button');
resetBtn.className = 'pp-btn'; resetBtn.style.cssText = 'margin-left:auto;padding:2px 8px;font-size:0.45rem;background:rgba(255,77,106,0.08);border-color:var(--danger);color:var(--danger);';
resetBtn.textContent = 'Reset device';
resetBtn.onclick = () => { el.overrides[deviceMode] = {}; renderElement(el); buildPropsPanel(el); showToast('Device overrides cleared'); };
respRow.appendChild(resetBtn);
}
ppContext.appendChild(respRow);
// ── Info section ──
const info = makeSection('Info', 'bi-info-circle');
info.appendChild(makeRow('Name', makeInput(el.name, v => { el.name = v; buildLayerList(); })));
info.appendChild(makeRow('ID', makeReadonly(el.id)));
info.appendChild(makeRow('Type', makeReadonly(el.type)));
ppProps.appendChild(info);
// ── Position section ──
const pos = makeSection('Position', 'bi-arrows-move');
pos.appendChild(makeOvRow(el,'X %', 'x', makeNumInput(getVal(el,'x'), 0, 100, 0.5, v => setVal(el,'x',v))));
pos.appendChild(makeOvRow(el,'Y %', 'y', makeNumInput(getVal(el,'y'), 0, 100, 0.5, v => setVal(el,'y',v))));
pos.appendChild(makeOvRow(el,'W %', 'w', makeNumInput(getVal(el,'w'), 0, 100, 0.5, v => setVal(el,'w',v), '0=auto')));
pos.appendChild(makeOvRow(el,'H %', 'h', makeNumInput(getVal(el,'h'), 0, 100, 0.5, v => setVal(el,'h',v), '0=auto')));
pos.appendChild(makeOvRow(el, 'Anchor', 'anchor', makeSelect(getVal(el, 'anchor') || 'center',
['center','top-left','top-center','top-right','center-left','center-right','bottom-left','bottom-center','bottom-right'],
v => { setVal(el, 'anchor', v); buildPropsPanel(el); })));
pos.appendChild(makeRow('Z-Index', makeNumInput(el.zIndex, 0, 100, 1, v => { el.zIndex = v; renderElement(el); })));
// Parent dropdown — only panels can be parents
const panelEls = elements.filter(e => (e.type === 'panel' || e.type === 'hero') && e.id !== el.id);
const parentOpts = ['(none)', ...panelEls.map(p => p.id + ' — ' + (p.name || p.id))];
const parentVals = ['', ...panelEls.map(p => p.id)];
const parentSel = document.createElement('select');
parentSel.className = 'pp-select';
parentVals.forEach((v, i) => {
const opt = document.createElement('option');
opt.value = v; opt.textContent = parentOpts[i];
if (v === (el.parentId || '')) opt.selected = true;
parentSel.appendChild(opt);
});
parentSel.onchange = () => { el.parentId = parentSel.value || null; renderAllElements(); buildLayerList(); };
pos.appendChild(makeRow('Parent', parentSel));
ppProps.appendChild(pos);
// ── Content section ──
if (el.type === 'text' || el.type === 'button') {
const cont = makeSection('Content', 'bi-text-left');
const ta = document.createElement('textarea');
ta.className = 'pp-textarea'; ta.value = el.content;
ta.oninput = () => { el.content = ta.value; renderElement(el); };
cont.appendChild(ta);
ppProps.appendChild(cont);
} else if (el.type === 'image') {
const cont = makeSection('Image', 'bi-image');
cont.appendChild(makeRow('Image', makeImageInput(el.src || '', v => { el.src = v; renderElement(el); })));
cont.appendChild(makeRow('Fit', makeSelect(el.objectFit, ['contain','cover','fill','none','background'], v => { el.objectFit = v; renderElement(el); })));
if (el.objectFit === 'background') {
cont.appendChild(makeRow('BG Size', makeInput(el.bgSize || 'auto 100%', v => { el.bgSize = v; renderElement(el); }, 'auto 100%, cover, 50%')));
cont.appendChild(makeRow('BG Pos', makeInput(el.bgPosition || 'center', v => { el.bgPosition = v; renderElement(el); }, 'center, top left')));
cont.appendChild(makeRow('BG Repeat', makeSelect(el.bgRepeat || 'no-repeat', ['no-repeat','repeat','repeat-x','repeat-y','space','round'], v => { el.bgRepeat = v; renderElement(el); })));
}
ppProps.appendChild(cont);
} else if (el.type === 'model3d') {
const md = makeSection('3D Model', 'bi-box');
md.appendChild(makeRow('GLB / GLTF', makeInput(el.src || '', v => { el.src = v; renderElement(el); }, './models/product.glb')));
const mdHint = document.createElement('div');
mdHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.35;';
mdHint.textContent = 'A path next to your HTML, or a full URL. .glb is a single file and travels best; .gltf needs its textures alongside it.';
md.appendChild(mdHint);
md.appendChild(makeRow('Auto Rotate', makeCheck(el.modelAutoRotate, v => { el.modelAutoRotate = v; renderElement(el); })));
md.appendChild(makeRow('Spin Speed', makeSlider(el.modelRotateSpeed, 0.1, 12, 0.1, v => { el.modelRotateSpeed = v; renderElement(el); })));
md.appendChild(makeRow('Interactive', makeCheck(el.modelControls, v => { el.modelControls = v; buildPropsPanel(el); renderElement(el); })));
if (el.modelControls) {
const inter = document.createElement('div');
inter.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.35;';
inter.textContent = 'Drag (or one finger) turns the model, wheel / pinch zooms, and two fingers pan when allowed.';
md.appendChild(inter);
md.appendChild(makeRow('Drag Rotate', makeCheck(el.modelDragRotate, v => { el.modelDragRotate = v; renderElement(el); })));
md.appendChild(makeRow('Allow Zoom', makeCheck(el.modelZoom, v => { el.modelZoom = v; renderElement(el); })));
md.appendChild(makeRow('Allow Pan', makeCheck(el.modelPan, v => { el.modelPan = v; renderElement(el); })));
md.appendChild(makeRow('Drag Speed', makeSlider(el.modelDragSpeed, 0.1, 4, 0.05, v => { el.modelDragSpeed = v; renderElement(el); })));
md.appendChild(makeRow('Zoom Speed', makeSlider(el.modelZoomSpeed, 0.1, 4, 0.05, v => { el.modelZoomSpeed = v; renderElement(el); })));
md.appendChild(makeRow('Smoothing', makeCheck(el.modelDamping, v => { el.modelDamping = v; renderElement(el); })));
md.appendChild(makeRow('Pause Spin', makeCheck(el.modelStopSpinOnInteract, v => { el.modelStopSpinOnInteract = v; buildPropsPanel(el); renderElement(el); })));
if (el.modelStopSpinOnInteract) {
md.appendChild(makeRow('Resume After', makeNumInput(el.modelResumeSpin, 0, 60000, 250, v => { el.modelResumeSpin = Math.round(v); renderElement(el); })));
const rh = document.createElement('div');
rh.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.35;';
rh.textContent = 'Milliseconds of stillness before auto-rotation starts again. 0 keeps it stopped once the visitor has taken over.';
md.appendChild(rh);
}
const limLbl = document.createElement('div');
limLbl.style.cssText = 'font-family:"Orbitron",sans-serif;font-size:0.4rem;letter-spacing:1px;color:var(--accent2);margin:8px 0 4px;text-transform:uppercase;';
limLbl.textContent = '● Limits (blank = free)';
md.appendChild(limLbl);
const lim = (label, key, ph) => {
const inp = document.createElement('input');
inp.type = 'number'; inp.className = 'pp-input'; inp.style.cssText = 'flex:1;min-width:0;';
inp.value = el[key] === '' || el[key] === undefined ? '' : el[key];
inp.placeholder = ph;
inp.onchange = () => { el[key] = inp.value === '' ? '' : parseFloat(inp.value); renderElement(el); };
return makeRow(label, inp);
};
md.appendChild(lim('Min Zoom', 'modelMinDistance', 'e.g. 2'));
md.appendChild(lim('Max Zoom', 'modelMaxDistance', 'e.g. 10'));
md.appendChild(lim('Min Tilt°', 'modelMinPolar', '0 = top'));
md.appendChild(lim('Max Tilt°', 'modelMaxPolar', '180 = bottom'));
}
md.appendChild(makeRow('Play Clips', makeCheck(el.modelPlayClips, v => { el.modelPlayClips = v; renderElement(el); })));
md.appendChild(makeRow('Live on canvas', makeCheck(el.modelLiveOnCanvas, v => { el.modelLiveOnCanvas = v; renderElement(el); })));
const liveHint = document.createElement('div');
liveHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:2px 0;line-height:1.35;';
liveHint.textContent = 'Turn this off if several models make the canvas sluggish — they still animate in Preview and in the player.';
md.appendChild(liveHint);
ppProps.appendChild(md);
const tr = makeSection('Model Transform', 'bi-arrows-move');
tr.appendChild(makeRow('Auto Fit', makeCheck(el.modelAutoFit, v => { el.modelAutoFit = v; renderElement(el); })));
const fitHint = document.createElement('div');
fitHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.35;';
fitHint.textContent = 'Auto Fit centres the model and normalises its size first; the values below are applied on top.';
tr.appendChild(fitHint);
tr.appendChild(makeRow('Scale', makeSlider(el.modelScale, 0.05, 5, 0.05, v => { el.modelScale = v; renderElement(el); })));
tr.appendChild(makeRow('Pos X', makeNumInput(el.modelX, -20, 20, 0.1, v => { el.modelX = v; renderElement(el); })));
tr.appendChild(makeRow('Pos Y', makeNumInput(el.modelY, -20, 20, 0.1, v => { el.modelY = v; renderElement(el); })));
tr.appendChild(makeRow('Pos Z', makeNumInput(el.modelZ, -20, 20, 0.1, v => { el.modelZ = v; renderElement(el); })));
tr.appendChild(makeRow('Rot X°', makeNumInput(el.modelRotX, -360, 360, 1, v => { el.modelRotX = v; renderElement(el); })));
tr.appendChild(makeRow('Rot Y°', makeNumInput(el.modelRotY, -360, 360, 1, v => { el.modelRotY = v; renderElement(el); })));
tr.appendChild(makeRow('Rot Z°', makeNumInput(el.modelRotZ, -360, 360, 1, v => { el.modelRotZ = v; renderElement(el); })));
ppProps.appendChild(tr);
const cam = makeSection('Camera & Light', 'bi-camera-reels');
cam.appendChild(makeRow('Distance', makeSlider(el.modelDistance, 0.5, 20, 0.1, v => { el.modelDistance = v; renderElement(el); })));
cam.appendChild(makeRow('Height', makeNumInput(el.modelCamHeight, -10, 10, 0.1, v => { el.modelCamHeight = v; renderElement(el); })));
cam.appendChild(makeRow('Look At Y', makeNumInput(el.modelTargetY, -10, 10, 0.1, v => { el.modelTargetY = v; renderElement(el); })));
cam.appendChild(makeRow('FOV', makeSlider(el.modelFov, 10, 110, 1, v => { el.modelFov = v; renderElement(el); })));
cam.appendChild(makeRow('Lighting', makeSelect(el.modelLighting, ['studio','soft','dramatic'], v => { el.modelLighting = v; renderElement(el); })));
cam.appendChild(makeRow('Intensity', makeSlider(el.modelLightIntensity, 0, 3, 0.05, v => { el.modelLightIntensity = v; renderElement(el); })));
cam.appendChild(makeRow('Sky Light', makeCheck(el.modelHemi, v => { el.modelHemi = v; renderElement(el); })));
cam.appendChild(makeRow('Shadows', makeCheck(el.modelShadows, v => { el.modelShadows = v; renderElement(el); })));
ppStyle.appendChild(cam);
const mstyle = makeSection('Model Background', 'bi-palette');
mstyle.appendChild(makeRow('Background', makeColorCombo(el.modelBg, v => { el.modelBg = v; renderElement(el); })));
const bgHint2 = document.createElement('div');
bgHint2.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:2px 0;line-height:1.35;';
bgHint2.textContent = 'Type "transparent" to let whatever sits behind the element show through.';
mstyle.appendChild(bgHint2);
mstyle.appendChild(makeRow('Radius', makeInput(el.borderRadius, v => { el.borderRadius = v; renderElement(el); }, '10px')));
ppStyle.appendChild(mstyle);
} else if (el.type === 'iframe') {
const fr = makeSection('Embedded Page', 'bi-window');
fr.appendChild(makeRow('URL', makeInput(el.src || '', v => { el.src = v; renderElement(el); }, 'https://… (YouTube / Vimeo / Maps links auto-convert)')));
if (el.src) {
const resolved = document.createElement('div');
resolved.style.cssText = 'font-size:0.4rem;color:var(--accent);padding:0 0 4px;line-height:1.35;word-break:break-all;';
resolved.textContent = 'Loads: ' + uiEmbedURL(el.src);
fr.appendChild(resolved);
}
fr.appendChild(makeRow('Title', makeInput(el.frameTitle, v => { el.frameTitle = v; renderElement(el); }, 'for screen readers')));
fr.appendChild(makeRow('Scrollbars', makeCheck(el.frameScroll, v => { el.frameScroll = v; renderElement(el); })));
fr.appendChild(makeRow('Fullscreen', makeCheck(el.frameAllowFullscreen, v => { el.frameAllowFullscreen = v; renderElement(el); })));
fr.appendChild(makeRow('Lazy Load', makeCheck(el.frameLazy, v => { el.frameLazy = v; renderElement(el); })));
fr.appendChild(makeRow('Stub on canvas', makeCheck(el.framePreviewOnly, v => { el.framePreviewOnly = v; renderElement(el); })));
const frHint = document.createElement('div');
frHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:2px 0;line-height:1.35;';
frHint.textContent = 'With the stub on, the canvas shows a placeholder and the real page only loads in Preview and in the player — much lighter while designing.';
fr.appendChild(frHint);
fr.appendChild(makeRow('Allow', makeInput(el.frameAllow, v => { el.frameAllow = v; renderElement(el); }, 'autoplay; fullscreen…')));
fr.appendChild(makeRow('Sandbox', makeCheck(el.frameSandbox, v => { el.frameSandbox = v; buildPropsPanel(el); renderElement(el); })));
if (el.frameSandbox) {
fr.appendChild(makeRow('Rules', makeInput(el.frameSandboxRules, v => { el.frameSandboxRules = v; renderElement(el); }, 'allow-scripts allow-same-origin')));
}
const secHint = document.createElement('div');
secHint.style.cssText = 'font-size:0.42rem;color:var(--accent3);padding:2px 0;line-height:1.35;';
secHint.textContent = '⚠ Many sites refuse to be embedded (X-Frame-Options / CSP). If a page stays blank, that site is blocking it — nothing you can change here.';
fr.appendChild(secHint);
ppProps.appendChild(fr);
const frStyle = makeSection('Frame Style', 'bi-border-style');
frStyle.appendChild(makeRow('Border', makeInput(el.frameBorder, v => { el.frameBorder = v; renderElement(el); }, 'none / 1px solid #fff2')));
frStyle.appendChild(makeRow('Radius', makeInput(el.borderRadius, v => { el.borderRadius = v; renderElement(el); }, '8px')));
frStyle.appendChild(makeRow('Background', makeColorCombo(el.frameBg, v => { el.frameBg = v; renderElement(el); })));
ppStyle.appendChild(frStyle);
} else if (el.type === 'hero') {
buildHeroProps(el);
} else if (el.type === 'carousel') {
buildCarouselProps(el);
} else if (el.type === 'video') {
const cont = makeSection('Video', 'bi-film');
cont.appendChild(makeRow('Src', makeVideoInput(el.src || '', v => { el.src = v; renderElement(el); })));
cont.appendChild(makeRow('Poster', makeImageInput(el.videoPoster || '', v => { el.videoPoster = v; renderElement(el); })));
cont.appendChild(makeRow('Play', makeSelect(el.playMode || 'auto', ['auto','click','manual'], v => { el.playMode = v; renderElement(el); })));
const playHint = document.createElement('div');
playHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.3;';
playHint.textContent = 'auto = play on load · click = tap video to play/pause · manual = only via actions/API';
cont.appendChild(playHint);
cont.appendChild(makeRow('Fit', makeSelect(el.videoFit || 'contain', ['contain','fit-width','fit-height','cover','stretch'], v => { el.videoFit = v; renderElement(el); })));
cont.appendChild(makeRow('Playback', makeSelect(el.loopMode || 'loop', ['once','count','loop'], v => { el.loopMode = v; buildPropsPanel(el); renderElement(el); })));
if (el.loopMode === 'count') {
cont.appendChild(makeRow('Times', makeNumInput(el.loopCount || 1, 1, 999, 1, v => { el.loopCount = Math.max(1, Math.round(v)); renderElement(el); })));
}
const mutedChk = document.createElement('input');
mutedChk.type = 'checkbox'; mutedChk.className = 'pp-check'; mutedChk.checked = el.muted !== false;
mutedChk.onchange = () => { el.muted = mutedChk.checked; renderElement(el); };
cont.appendChild(makeRow('Muted', mutedChk));
const ctrlChk = document.createElement('input');
ctrlChk.type = 'checkbox'; ctrlChk.className = 'pp-check'; ctrlChk.checked = !!el.controls;
ctrlChk.onchange = () => { el.controls = ctrlChk.checked; renderElement(el); };
cont.appendChild(makeRow('Controls', ctrlChk));
cont.appendChild(makeRow('Volume', makeSlider(el.volume !== undefined ? el.volume : 1, 0, 1, 0.05, v => { el.volume = v; renderElement(el); })));
const vHint = document.createElement('div');
vHint.style.cssText = 'font-size:0.42rem;color:var(--accent3);padding:2px 0;line-height:1.3;';
vHint.textContent = '⚠ Browsers block un-muted autoplay. Keep Muted ON for auto, or use click/manual.';
cont.appendChild(vHint);
ppProps.appendChild(cont);
// ── Transparency / chroma key ──
const ck = makeSection('Transparency', 'bi-magic');
const ckHint = document.createElement('div');
ckHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 6px;line-height:1.35;';
ckHint.textContent = 'Knock a colour out of the video so whatever is behind it shows through — green/blue screen footage, logos on a flat background, and so on.';
ck.appendChild(ckHint);
ck.appendChild(makeRow('Chroma Key', makeCheck(el.chromaKey, v => { el.chromaKey = v; buildPropsPanel(el); renderElement(el); })));
if (el.chromaKey) {
ck.appendChild(makeRow('Colour', makeColor(el.chromaColor, v => { el.chromaColor = v; renderElement(el); })));
const presets = document.createElement('div');
presets.style.cssText = 'display:flex;gap:4px;padding:2px 0 6px;';
[['#00ff00','Green'],['#0000ff','Blue'],['#000000','Black'],['#ffffff','White'],['#ff00ff','Magenta']].forEach(([c, label]) => {
const b = document.createElement('button');
b.className = 'pp-btn';
b.style.cssText = 'flex:1;padding:3px 2px;font-size:0.42rem;border-color:' + c + ';color:' + c + ';';
b.textContent = label;
b.onclick = () => { el.chromaColor = c; buildPropsPanel(el); renderElement(el); };
presets.appendChild(b);
});
ck.appendChild(presets);
ck.appendChild(makeRow('Similarity', makeSlider(el.chromaSimilarity, 0.01, 1, 0.01, v => { el.chromaSimilarity = v; renderElement(el); })));
ck.appendChild(makeRow('Smoothness', makeSlider(el.chromaSmoothness, 0, 0.5, 0.01, v => { el.chromaSmoothness = v; renderElement(el); })));
ck.appendChild(makeRow('Spill', makeSlider(el.chromaSpill, 0, 1, 0.05, v => { el.chromaSpill = v; renderElement(el); })));
const tuneHint = document.createElement('div');
tuneHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:2px 0;line-height:1.35;';
tuneHint.textContent = 'Similarity = how much of the colour range is removed · Smoothness = softness of the cut-out edge · Spill = removes colour bleeding onto the subject.';
ck.appendChild(tuneHint);
const cors = document.createElement('div');
cors.style.cssText = 'font-size:0.42rem;color:var(--accent3);padding:2px 0;line-height:1.35;';
cors.textContent = '⚠ Keying reads the video frames, so the file must be local, embedded, or served with CORS enabled — otherwise the plain video is shown.';
ck.appendChild(cors);
}
ck.appendChild(makeRow('Blend', makeSelect(el.videoBlend || 'none',
['none','screen','multiply','lighten','darken','overlay','difference','luminosity'],
v => { el.videoBlend = v; renderElement(el); })));
const blendHint = document.createElement('div');
blendHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:2px 0;line-height:1.35;';
blendHint.textContent = 'A cheaper alternative for simple cases: screen drops a black background, multiply drops a white one. Works without reading the frames.';
ck.appendChild(blendHint);
ppStyle.appendChild(ck);
} else if (el.type === 'icon') {
const cont = makeSection('Icon', 'bi-emoji-smile');
cont.appendChild(makeRow('Class', makeInput(el.iconClass || '', v => { el.iconClass = v; renderElement(el); })));
const hint = document.createElement('div');
hint.style.cssText = 'font-size:0.5rem;color:var(--text-muted);padding:4px 0;';
hint.textContent = 'e.g. bi-heart-fill, bi-star, bi-gear';
cont.appendChild(hint);
ppProps.appendChild(cont);
} else if (el.type === 'progressbar') {
const cont = makeSection('Progress', 'bi-battery-half');
cont.appendChild(makeRow('Value', makeSlider(el.barValue, 0, 100, 1, v => { el.barValue = v; renderElement(el); })));
cont.appendChild(makeRow('Bar Color', makeColor(el.barColor, v => { el.barColor = v; renderElement(el); })));
cont.appendChild(makeRow('BG Color', makeColor(el.barBgColor, v => { el.barBgColor = v; renderElement(el); })));
cont.appendChild(makeRow('Height', makeInput(el.barHeight, v => { el.barHeight = v; renderElement(el); })));
cont.appendChild(makeRow('Label', makeInput(el.labelText || '', v => { el.labelText = v; el.showLabel = !!v; renderElement(el); })));
ppProps.appendChild(cont);
}
// ── Style section ──
const style = makeSection('Style', 'bi-palette');
style.appendChild(makeRow('Font', makeSelect(el.fontFamily, [
"'Orbitron',sans-serif", "'Rajdhani',sans-serif", "'Courier New',monospace",
"Georgia,serif", "'Arial',sans-serif", "'Trebuchet MS',sans-serif", "system-ui",
], v => { el.fontFamily = v; renderElement(el); })));
// Font size + unit (px, rem, em, %, vw, vh, pt)
const FONT_UNITS = ['rem','px','em','%','vw','vh','vmin','pt'];
const sizeWrap = document.createElement('div');
sizeWrap.style.cssText = 'display:flex;gap:4px;flex:1;align-items:center;';
const curUnit = getVal(el, 'fontUnit') || 'rem';
const sizeNum = document.createElement('input');
sizeNum.type = 'number'; sizeNum.className = 'pp-input';
sizeNum.style.cssText = 'flex:1;min-width:0;';
sizeNum.step = (curUnit === 'px' || curUnit === 'pt' || curUnit === '%') ? '1' : '0.05';
sizeNum.value = getVal(el, 'fontSize');
sizeNum.onchange = () => { setVal(el, 'fontSize', parseFloat(sizeNum.value) || 0); };
const unitSel = document.createElement('select');
unitSel.className = 'pp-select';
unitSel.style.cssText = 'flex:0 0 58px;';
FONT_UNITS.forEach(u => {
const o = document.createElement('option');
o.value = u; o.textContent = u;
if (u === curUnit) o.selected = true;
unitSel.appendChild(o);
});
unitSel.onchange = () => {
const from = curUnit, to = unitSel.value;
// Convert the number so the text keeps roughly the same size
const toPx = { rem:16, em:16, px:1, pt:1.333, '%':0.16, vw:0, vh:0, vmin:0 };
let val = parseFloat(sizeNum.value) || 0;
if (toPx[from] && toPx[to]) val = Math.round((val * toPx[from] / toPx[to]) * 1000) / 1000;
setVal(el, 'fontUnit', to);
setVal(el, 'fontSize', val);
buildPropsPanel(el);
};
sizeWrap.appendChild(sizeNum); sizeWrap.appendChild(unitSel);
style.appendChild(makeOvRow(el, 'Size', 'fontSize', sizeWrap));
const sizeSliderMax = curUnit === 'px' ? 120 : curUnit === 'pt' ? 90 : curUnit === '%' ? 400 : curUnit === 'vw' || curUnit === 'vh' || curUnit === 'vmin' ? 20 : 6;
style.appendChild(makeRow('', makeSlider(getVal(el,'fontSize'), 0, sizeSliderMax, sizeSliderMax > 20 ? 1 : 0.05, v => { setVal(el,'fontSize',v); sizeNum.value = v; })));
style.appendChild(makeOvRow(el,'Weight', 'fontWeight', makeSelect(getVal(el,'fontWeight'), ['300','400','500','600','700'], v => setVal(el,'fontWeight',v))));
style.appendChild(makeOvRow(el,'Color', 'color', makeColor(getVal(el,'color'), v => setVal(el,'color',v))));
style.appendChild(makeOvRow(el,'BG', 'background', makeColorCombo(getVal(el,'background'), v => setVal(el,'background',v))));
style.appendChild(makeOvRow(el,'Radius', 'borderRadius', makeInput(getVal(el,'borderRadius'), v => setVal(el,'borderRadius',v))));
style.appendChild(makeOvRow(el,'Padding', 'padding', makeInput(getVal(el,'padding'), v => setVal(el,'padding',v))));
style.appendChild(makeOvRow(el,'Border', 'border', makeColorCombo(getVal(el,'border'), v => setVal(el,'border',v))));
style.appendChild(makeOvRow(el,'Opacity', 'opacity', makeSlider(getVal(el,'opacity'), 0, 1, 0.05, v => setVal(el,'opacity',v))));
style.appendChild(makeOvRow(el,'Shadow', 'boxShadow', makeInput(getVal(el,'boxShadow'), v => setVal(el,'boxShadow',v))));
style.appendChild(makeRow('Txt Shd', makeInput(el.textShadow, v => { el.textShadow = v; renderElement(el); })));
style.appendChild(makeOvRow(el,'Align', 'textAlign', makeSelect(getVal(el,'textAlign'), ['left','center','right','justify'], v => setVal(el,'textAlign',v))));
style.appendChild(makeOvRow(el,'Spacing', 'letterSpacing', makeInput(getVal(el,'letterSpacing'), v => setVal(el,'letterSpacing',v))));
// Custom CSS
const cssRow = document.createElement('div');
cssRow.style.cssText = 'margin-top:6px;';
const cssLbl = document.createElement('div');
cssLbl.style.cssText = "font-size:0.38rem;color:var(--accent3);font-family:'Orbitron',sans-serif;letter-spacing:1px;margin-bottom:3px;";
cssLbl.textContent = 'CUSTOM CSS';
const cssTa = document.createElement('textarea');
cssTa.className = 'pp-input';
cssTa.style.cssText = 'width:100%;min-height:40px;max-height:120px;resize:vertical;font-family:"Courier New",monospace;font-size:0.5rem;line-height:1.4;';
cssTa.value = el.customCSS || '';
cssTa.placeholder = 'e.g. transform: rotate(5deg);\nfilter: blur(2px);';
cssTa.onchange = () => { el.customCSS = cssTa.value; renderElement(el); };
cssRow.appendChild(cssLbl); cssRow.appendChild(cssTa);
style.appendChild(cssRow);
ppStyle.appendChild(style);
// ── Animation section: entrance → attention → exit ──
const animSec = makeSection('Animation', 'bi-play-circle');
const ANIM_GROUPS = {
'Entrances': ['animate__fadeIn','animate__fadeInUp','animate__fadeInDown','animate__fadeInLeft','animate__fadeInRight','animate__bounceIn','animate__bounceInUp','animate__bounceInDown','animate__bounceInLeft','animate__bounceInRight','animate__zoomIn','animate__zoomInUp','animate__zoomInDown','animate__slideInUp','animate__slideInDown','animate__slideInLeft','animate__slideInRight','animate__flipInX','animate__flipInY','animate__rotateIn','animate__jackInTheBox','animate__rollIn','animate__lightSpeedInLeft','animate__lightSpeedInRight','animate__backInUp','animate__backInDown','animate__backInLeft','animate__backInRight'],
'Attention': ['animate__bounce','animate__flash','animate__pulse','animate__rubberBand','animate__shakeX','animate__shakeY','animate__headShake','animate__swing','animate__tada','animate__wobble','animate__jello','animate__heartBeat','animate__flip'],
'Exits': ['animate__fadeOut','animate__fadeOutUp','animate__fadeOutDown','animate__fadeOutLeft','animate__fadeOutRight','animate__bounceOut','animate__bounceOutUp','animate__bounceOutDown','animate__zoomOut','animate__zoomOutUp','animate__zoomOutDown','animate__slideOutUp','animate__slideOutDown','animate__slideOutLeft','animate__slideOutRight','animate__flipOutX','animate__flipOutY','animate__rotateOut','animate__rollOut','animate__lightSpeedOutLeft','animate__lightSpeedOutRight','animate__backOutUp','animate__backOutDown','animate__backOutLeft','animate__backOutRight'],
};
function makeAnimSelect(val, preferredGroup, onChange) {
const sel = document.createElement('select');
sel.className = 'pp-select';
const none = document.createElement('option');
none.value = ''; none.textContent = '(none)';
if (!val) none.selected = true;
sel.appendChild(none);
const order = preferredGroup ? [preferredGroup].concat(Object.keys(ANIM_GROUPS).filter(g => g !== preferredGroup)) : Object.keys(ANIM_GROUPS);
order.forEach(group => {
const og = document.createElement('optgroup');
og.label = group;
ANIM_GROUPS[group].forEach(a => {
const o = document.createElement('option');
o.value = a; o.textContent = a.replace('animate__', '');
if (a === val) o.selected = true;
og.appendChild(o);
});
sel.appendChild(og);
});
sel.onchange = () => onChange(sel.value);
return sel;
}
const playStage = stage => {
const dom = viewport.querySelector(`[data-id="${el.id}"]`);
const inner = dom ? dom.querySelector('.ui-el-inner') : null;
if (inner) runUIAnimSequence(inner, el, stage);
};
// Trigger + play whole sequence
animSec.appendChild(makeRow('Trigger', makeSelect(el.animTrigger || 'load', ['load','manual'], v => { el.animTrigger = v; renderElement(el); })));
const trigHint = document.createElement('div');
trigHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.35;';
trigHint.textContent = 'load = plays when the UI opens · manual = only via a Play Animation action or GameAPI.playAnimation().';
animSec.appendChild(trigHint);
const seqBtn = document.createElement('button');
seqBtn.className = 'pp-btn';
seqBtn.style.cssText = 'width:100%;margin-bottom:6px;background:rgba(0,229,160,0.08);border-color:var(--accent);color:var(--accent);font-size:0.55rem;padding:5px;';
seqBtn.innerHTML = '<i class="bi bi-play-circle"></i> Play full sequence';
seqBtn.onclick = () => playStage('all');
animSec.appendChild(seqBtn);
const STAGES = [
{ key:'Enter', label:'1 · Entrance', icon:'bi-box-arrow-in-down', group:'Entrances',
hint:'Plays once when the element appears. Delay is counted from when the UI opens.' },
{ key:'Attn', label:'2 · Attention', icon:'bi-stars', group:'Attention',
hint:'Delay is counted from the moment the entrance finishes. Set Repeat to infinite for a permanent loop.' },
{ key:'Exit', label:'3 · Exit', icon:'bi-box-arrow-up', group:'Exits',
hint:'Delay is counted from the moment the attention stage finishes (or starts, if it loops forever).' },
];
STAGES.forEach(st => {
const box = document.createElement('div');
box.style.cssText = 'border:1px solid var(--border-color);border-radius:8px;padding:6px 8px;margin-bottom:6px;background:rgba(255,255,255,0.02);';
const head = document.createElement('div');
head.style.cssText = 'display:flex;align-items:center;gap:5px;margin-bottom:4px;';
const ttl = document.createElement('span');
ttl.style.cssText = 'font-family:"Orbitron",sans-serif;font-size:0.42rem;letter-spacing:1px;color:' + (el['anim' + st.key + 'Class'] ? 'var(--accent)' : 'var(--text-muted)') + ';text-transform:uppercase;';
ttl.innerHTML = '<i class="bi ' + st.icon + '"></i> ' + st.label;
const play = document.createElement('button');
play.className = 'car-ed-btn'; play.style.marginLeft = 'auto';
play.innerHTML = '<i class="bi bi-play-fill"></i>';
play.title = 'Test this stage on the canvas';
play.onclick = () => playStage(st.key === 'Enter' ? 'entrance' : st.key === 'Attn' ? 'attention' : 'exit');
head.appendChild(ttl); head.appendChild(play);
box.appendChild(head);
box.appendChild(makeRow('Animation', makeAnimSelect(el['anim' + st.key + 'Class'], st.group, v => {
el['anim' + st.key + 'Class'] = v; buildPropsPanel(el); renderElement(el);
})));
if (el['anim' + st.key + 'Class']) {
box.appendChild(makeRow('Delay (ms)', makeNumInput(el['anim' + st.key + 'Delay'], 0, 60000, 50, v => {
el['anim' + st.key + 'Delay'] = Math.max(0, Math.round(v)); renderElement(el);
})));
box.appendChild(makeRow('Duration', makeNumInput(el['anim' + st.key + 'Duration'], 50, 20000, 50, v => {
el['anim' + st.key + 'Duration'] = Math.max(50, Math.round(v)); renderElement(el);
})));
box.appendChild(makeRow('Repeat', makeSelect(String(el['anim' + st.key + 'Repeat'] || '1'), ['1','2','3','5','infinite'], v => {
el['anim' + st.key + 'Repeat'] = v; renderElement(el);
})));
if (st.key === 'Exit') {
box.appendChild(makeRow('Hide after', makeCheck(el.animExitHide, v => { el.animExitHide = v; renderElement(el); })));
}
const h = document.createElement('div');
h.style.cssText = 'font-size:0.4rem;color:var(--text-muted);padding:2px 0 0;line-height:1.35;';
h.textContent = st.hint;
box.appendChild(h);
}
animSec.appendChild(box);
});
const animNote = document.createElement('div');
animNote.style.cssText = 'font-size:0.42rem;color:var(--accent3);padding:2px 0;line-height:1.35;';
animNote.textContent = 'All three stages are optional — leave them on (none) for no animation. Delays and durations are in milliseconds. Sequences run in Preview and in the player, not on the canvas.';
animSec.appendChild(animNote);
if (el.animateClass && !el.animEnterClass && !el.animAttnClass && !el.animExitClass) {
const legacy = document.createElement('div');
legacy.style.cssText = 'font-size:0.42rem;color:var(--accent2);padding:4px 0 0;line-height:1.35;';
legacy.textContent = 'This element still uses the old single animation (' + el.animateClass.replace('animate__','') + '). Pick an Entrance above to switch it to the new sequence.';
animSec.appendChild(legacy);
}
ppStyle.appendChild(animSec);
// ── Interactions section (hover / active states) ──
if (['button','image','icon','panel','text','video','carousel','hero','iframe','model3d'].includes(el.type)) {
const inter = makeSection('Interactions', 'bi-cursor');
const hintI = document.createElement('div');
hintI.style.cssText = 'font-size:0.45rem;color:var(--text-muted);padding:0 0 6px;line-height:1.3;';
hintI.textContent = 'CSS overrides on hover / click. Leave blank = no change.';
inter.appendChild(hintI);
// Hover
const hLabel = document.createElement('div');
hLabel.style.cssText = 'font-family:"Orbitron",sans-serif;font-size:0.4rem;letter-spacing:1px;color:var(--accent2);margin:6px 0 4px;text-transform:uppercase;';
hLabel.textContent = '● On Hover';
inter.appendChild(hLabel);
inter.appendChild(makeRow('BG', makeColorCombo(el.hoverBackground || '', v => { el.hoverBackground = v; })));
inter.appendChild(makeRow('Color', makeColor(el.hoverColor || '#ffffff', v => { el.hoverColor = v; })));
inter.appendChild(makeRow('Scale', makeInput(el.hoverScale || '', v => { el.hoverScale = v; }, 'e.g. 1.05')));
inter.appendChild(makeRow('Shadow', makeInput(el.hoverBoxShadow || '', v => { el.hoverBoxShadow = v; }, '0 0 20px rgba(0,229,160,0.5)')));
if (el.type === 'image') inter.appendChild(makeRow('Img Src', makeImageInput(el.hoverSrc || '', v => { el.hoverSrc = v; })));
// Active / mousedown
const aLabel = document.createElement('div');
aLabel.style.cssText = 'font-family:"Orbitron",sans-serif;font-size:0.4rem;letter-spacing:1px;color:var(--danger);margin:6px 0 4px;text-transform:uppercase;';
aLabel.textContent = '● On Click / Mousedown';
inter.appendChild(aLabel);
inter.appendChild(makeRow('BG', makeColorCombo(el.activeBackground || '', v => { el.activeBackground = v; })));
inter.appendChild(makeRow('Color', makeColor(el.activeColor || '#ffffff', v => { el.activeColor = v; })));
inter.appendChild(makeRow('Scale', makeInput(el.activeScale || '', v => { el.activeScale = v; }, 'e.g. 0.95')));
inter.appendChild(makeRow('Shadow', makeInput(el.activeBoxShadow || '', v => { el.activeBoxShadow = v; })));
if (el.type === 'image') inter.appendChild(makeRow('Img Src', makeImageInput(el.activeSrc || '', v => { el.activeSrc = v; })));
ppStyle.appendChild(inter);
}
// ── Binding section ──
const bind = makeSection('Binding', 'bi-link-45deg');
bind.appendChild(makeRow('Variable', makeInput(el.binding || '', v => { el.binding = v; })));
const hint = document.createElement('div');
hint.style.cssText = 'font-size:0.5rem;color:var(--text-muted);padding:2px 0;line-height:1.4;';
hint.textContent = 'Bind to game variable: score, timer, health, level, coins, lives, mana, xp, stamina, custom...';
bind.appendChild(hint);
ppProps.appendChild(bind);
// ── Events / Actions section ──
if (['button','image','icon','panel','text','video','carousel','hero','iframe','model3d'].includes(el.type)) {
if (!el.events) el.events = { click:[], hover:[], mousedown:[], mouseup:[], dblclick:[] };
const evtSec = makeSection('Events & Actions', 'bi-lightning-charge');
const EVENT_TYPES = [
{ key:'click', label:'On Click', icon:'bi-cursor' },
{ key:'hover', label:'On Hover', icon:'bi-hand-index' },
{ key:'mousedown', label:'On Mouse Down', icon:'bi-caret-down-fill' },
{ key:'mouseup', label:'On Mouse Up', icon:'bi-caret-up-fill' },
{ key:'dblclick', label:'On Double Click', icon:'bi-cursor-fill' },
];
EVENT_TYPES.forEach(evt => {
if (!el.events[evt.key]) el.events[evt.key] = [];
const acts = el.events[evt.key];
const modeKey = evt.key + 'Mode';
if (!el.events[modeKey]) el.events[modeKey] = 'parallel'; // default parallel
// Collapsible event header
const evtHeader = document.createElement('div');
evtHeader.style.cssText = 'display:flex;align-items:center;gap:4px;padding:4px 0;cursor:pointer;border-bottom:1px solid var(--border-color);margin-top:4px;';
const badge = acts.length > 0 ? `<span style="font-size:0.45rem;background:var(--accent);color:#000;border-radius:3px;padding:0 4px;margin-left:auto;">${acts.length}</span>` : '';
evtHeader.innerHTML = `<i class="bi ${evt.icon}" style="font-size:0.6rem;color:var(--accent3);"></i><span style="font-family:'Orbitron',sans-serif;font-size:0.4rem;letter-spacing:1px;color:var(--text-secondary);">${evt.label}</span>${badge}<i class="bi bi-chevron-down" style="font-size:0.5rem;color:var(--text-muted);margin-left:${badge?'4':'auto'}px;transition:transform 0.2s;"></i>`;
const evtBody = document.createElement('div');
evtBody.style.display = acts.length > 0 ? '' : 'none';
const arrow = evtHeader.querySelector('.bi-chevron-down');
if (acts.length === 0) arrow.style.transform = 'rotate(-90deg)';
evtHeader.onclick = () => {
const open = evtBody.style.display !== 'none';
evtBody.style.display = open ? 'none' : '';
arrow.style.transform = open ? 'rotate(-90deg)' : '';
};
evtSec.appendChild(evtHeader);
// Execution mode toggle
const modeRow = document.createElement('div');
modeRow.style.cssText = 'display:flex;align-items:center;gap:6px;padding:3px 0;margin-bottom:2px;';
const modeLabel = document.createElement('span');
modeLabel.style.cssText = 'font-size:0.38rem;color:var(--text-muted);';
modeLabel.textContent = 'Execution:';
modeRow.appendChild(modeLabel);
const isChain = el.events[modeKey] === 'chain';
const modeParBtn = document.createElement('button');
modeParBtn.style.cssText = `font-size:0.38rem;padding:1px 6px;border-radius:3px;cursor:pointer;border:1px solid;transition:all 0.15s;${!isChain ? 'background:rgba(0,229,160,0.15);border-color:var(--accent);color:var(--accent);' : 'background:transparent;border-color:var(--border-color);color:var(--text-muted);'}`;
modeParBtn.textContent = '⚡ All at once';
const modeChainBtn = document.createElement('button');
modeChainBtn.style.cssText = `font-size:0.38rem;padding:1px 6px;border-radius:3px;cursor:pointer;border:1px solid;transition:all 0.15s;${isChain ? 'background:rgba(245,158,11,0.15);border-color:var(--accent3);color:var(--accent3);' : 'background:transparent;border-color:var(--border-color);color:var(--text-muted);'}`;
modeChainBtn.textContent = '🔗 Chain sequence';
function updateModeBtns() {
const c = el.events[modeKey] === 'chain';
modeParBtn.style.background = !c ? 'rgba(0,229,160,0.15)' : 'transparent';
modeParBtn.style.borderColor = !c ? 'var(--accent)' : 'var(--border-color)';
modeParBtn.style.color = !c ? 'var(--accent)' : 'var(--text-muted)';
modeChainBtn.style.background = c ? 'rgba(245,158,11,0.15)' : 'transparent';
modeChainBtn.style.borderColor = c ? 'var(--accent3)' : 'var(--border-color)';
modeChainBtn.style.color = c ? 'var(--accent3)' : 'var(--text-muted)';
}
modeParBtn.onclick = (e) => { e.stopPropagation(); el.events[modeKey] = 'parallel'; updateModeBtns(); buildChain(); };
modeChainBtn.onclick = (e) => { e.stopPropagation(); el.events[modeKey] = 'chain'; updateModeBtns(); buildChain(); };
modeRow.appendChild(modeParBtn);
modeRow.appendChild(modeChainBtn);
evtBody.appendChild(modeRow);
const chainBox = document.createElement('div');
chainBox.className = 'act-chain';
function buildChain() {
chainBox.innerHTML = '';
acts.forEach((act, i) => {
const card = document.createElement('div');
card.className = 'act-card';
const hdr = document.createElement('div');
hdr.className = 'act-card-header';
hdr.innerHTML = `<span>STEP ${i + 1}</span>`;
const rm = document.createElement('button');
rm.className = 'act-remove'; rm.innerHTML = '<i class="bi bi-x"></i>';
rm.onclick = () => { acts.splice(i, 1); buildChain(); buildPropsPanel(el); };
hdr.appendChild(rm);
card.appendChild(hdr);
// Action type
const typeRow = document.createElement('div'); typeRow.className = 'pp-row';
const typeSel = document.createElement('select'); typeSel.className = 'pp-select';
['show','hide','toggle','setText','setProgress','videoControl','carouselControl','heroControl','frameControl','modelControl','playAnimation','playSound','callFunction','addAnimation','removeAnimation','loadScene','playMusic','stopMusic','setVolume','loadURL','showHTML'].forEach(t => {
const o = document.createElement('option');
o.value = t; o.textContent = t; if (t === act.type) o.selected = true;
typeSel.appendChild(o);
});
typeSel.onchange = () => { act.type = typeSel.value; buildChain(); };
typeRow.appendChild(makeLbl('Type')); typeRow.appendChild(typeSel);
card.appendChild(typeRow);
// Target element ID
if (['show','hide','toggle','setText','setProgress','videoControl','carouselControl','heroControl','frameControl','modelControl','playAnimation','addAnimation','removeAnimation'].includes(act.type)) {
const tgtRow = document.createElement('div'); tgtRow.className = 'pp-row';
const tgtSel = document.createElement('select'); tgtSel.className = 'pp-select';
const selfOpt = document.createElement('option');
selfOpt.value = ''; selfOpt.textContent = '(self)'; if (!act.targetId) selfOpt.selected = true;
tgtSel.appendChild(selfOpt);
elements.forEach(other => {
const o = document.createElement('option');
o.value = other.id; o.textContent = other.id + ' — ' + (other.name || other.type);
if (other.id === act.targetId) o.selected = true;
tgtSel.appendChild(o);
});
tgtSel.onchange = () => { act.targetId = tgtSel.value || ''; };
tgtRow.appendChild(makeLbl('Target')); tgtRow.appendChild(tgtSel);
card.appendChild(tgtRow);
}
// Type-specific params
if (act.type === 'setText') {
const r = document.createElement('div'); r.className = 'pp-row';
const inp = document.createElement('input'); inp.className = 'pp-input';
inp.value = act.value || ''; inp.placeholder = 'New text...';
inp.onchange = () => { act.value = inp.value; };
r.appendChild(makeLbl('Text')); r.appendChild(inp); card.appendChild(r);
} else if (act.type === 'setProgress') {
const r = document.createElement('div'); r.className = 'pp-row';
const inp = document.createElement('input'); inp.className = 'pp-input'; inp.type = 'number'; inp.min = 0; inp.max = 100;
inp.value = act.value || 50;
inp.onchange = () => { act.value = parseFloat(inp.value) || 0; };
r.appendChild(makeLbl('Value %')); r.appendChild(inp); card.appendChild(r);
} else if (act.type === 'playAnimation') {
const r = document.createElement('div'); r.className = 'pp-row';
const cs = document.createElement('select'); cs.className = 'pp-select';
[['all','▶ Full sequence'],['entrance','1 · Entrance'],['attention','2 · Attention'],['exit','3 · Exit'],['stop','⏹ Stop / reset']].forEach(([v,t]) => {
const o = document.createElement('option'); o.value = v; o.textContent = t;
if (v === (act.animStage || 'all')) o.selected = true; cs.appendChild(o);
});
cs.onchange = () => { act.animStage = cs.value; };
r.appendChild(makeLbl('Stage')); r.appendChild(cs); card.appendChild(r);
const ah = document.createElement('div');
ah.style.cssText = 'font-size:0.4rem;color:var(--text-muted);padding:0 0 4px;line-height:1.3;';
ah.textContent = 'Runs the target element\'s Animation stages (set them in the Style tab).';
card.appendChild(ah);
} else if (act.type === 'modelControl') {
const r = document.createElement('div'); r.className = 'pp-row';
const cs = document.createElement('select'); cs.className = 'pp-select';
[['spin','⟳ Start spinning'],['stop','⏸ Stop spinning'],['toggleSpin','⏯ Toggle spin'],
['reset','↺ Reset rotation'],['setModel','⇢ Load another model']].forEach(([v,t]) => {
const o = document.createElement('option'); o.value = v; o.textContent = t;
if (v === (act.modelCmd || 'toggleSpin')) o.selected = true; cs.appendChild(o);
});
cs.onchange = () => { act.modelCmd = cs.value; buildPropsPanel(el); };
r.appendChild(makeLbl('Command')); r.appendChild(cs); card.appendChild(r);
if (act.modelCmd === 'setModel') {
const r2 = document.createElement('div'); r2.className = 'pp-row';
const inp = document.createElement('input');
inp.className = 'pp-input'; inp.value = act.modelValue || ''; inp.placeholder = './models/thing.glb';
inp.onchange = () => { act.modelValue = inp.value; };
r2.appendChild(makeLbl('GLB path')); r2.appendChild(inp); card.appendChild(r2);
}
} else if (act.type === 'frameControl') {
const r = document.createElement('div'); r.className = 'pp-row';
const cs = document.createElement('select'); cs.className = 'pp-select';
[['reload','↻ Reload page'],['setSrc','⇢ Load a URL'],['blank','⏹ Clear (about:blank)']].forEach(([v,t]) => {
const o = document.createElement('option'); o.value = v; o.textContent = t;
if (v === (act.frameCmd || 'reload')) o.selected = true; cs.appendChild(o);
});
cs.onchange = () => { act.frameCmd = cs.value; buildPropsPanel(el); };
r.appendChild(makeLbl('Command')); r.appendChild(cs); card.appendChild(r);
if (act.frameCmd === 'setSrc') {
const r2 = document.createElement('div'); r2.className = 'pp-row';
const inp = document.createElement('input');
inp.className = 'pp-input'; inp.value = act.frameValue || ''; inp.placeholder = 'https://…';
inp.onchange = () => { act.frameValue = inp.value; };
r2.appendChild(makeLbl('URL')); r2.appendChild(inp); card.appendChild(r2);
}
} else if (act.type === 'heroControl') {
const r = document.createElement('div'); r.className = 'pp-row';
const cs = document.createElement('select'); cs.className = 'pp-select';
[['next','› Next background'],['prev','‹ Previous'],['goTo','⇥ Go to index'],['play','▶ Start auto'],['stop','⏸ Stop auto']].forEach(([v,t]) => {
const o = document.createElement('option'); o.value = v; o.textContent = t;
if (v === (act.heroCmd || 'next')) o.selected = true; cs.appendChild(o);
});
cs.onchange = () => { act.heroCmd = cs.value; buildPropsPanel(el); };
r.appendChild(makeLbl('Command')); r.appendChild(cs); card.appendChild(r);
if ((act.heroCmd || 'next') === 'goTo') {
const r2 = document.createElement('div'); r2.className = 'pp-row';
const inp = document.createElement('input');
inp.className = 'pp-input'; inp.type = 'number'; inp.min = '0';
inp.value = act.heroIndex !== undefined ? act.heroIndex : 0;
inp.onchange = () => { act.heroIndex = parseInt(inp.value) || 0; };
r2.appendChild(makeLbl('Index')); r2.appendChild(inp); card.appendChild(r2);
}
const hh = document.createElement('div');
hh.style.cssText = 'font-size:0.4rem;color:var(--text-muted);padding:0 0 4px;line-height:1.3;';
hh.textContent = 'Target must be a Hero element (or self).';
card.appendChild(hh);
} else if (act.type === 'carouselControl') {
const r = document.createElement('div'); r.className = 'pp-row';
const cs = document.createElement('select'); cs.className = 'pp-select';
[['next','› Next'],['prev','‹ Previous'],['goTo','⇥ Go to index'],['play','▶ Start autoplay'],['stop','⏸ Stop autoplay'],['refresh','↻ Refresh']].forEach(([v,t]) => {
const o = document.createElement('option'); o.value = v; o.textContent = t;
if (v === (act.carCmd || 'next')) o.selected = true; cs.appendChild(o);
});
cs.onchange = () => { act.carCmd = cs.value; buildPropsPanel(el); };
r.appendChild(makeLbl('Command')); r.appendChild(cs); card.appendChild(r);
if ((act.carCmd || 'next') === 'goTo') {
const r2 = document.createElement('div'); r2.className = 'pp-row';
const inp = document.createElement('input');
inp.className = 'pp-input'; inp.type = 'number'; inp.min = '0';
inp.value = act.carIndex !== undefined ? act.carIndex : 0;
inp.onchange = () => { act.carIndex = parseInt(inp.value) || 0; };
r2.appendChild(makeLbl('Index')); r2.appendChild(inp); card.appendChild(r2);
}
const ch = document.createElement('div');
ch.style.cssText = 'font-size:0.4rem;color:var(--text-muted);padding:0 0 4px;line-height:1.3;';
ch.textContent = 'Target must be a Carousel element (or self).';
card.appendChild(ch);
} else if (act.type === 'videoControl') {
const r = document.createElement('div'); r.className = 'pp-row';
const cs = document.createElement('select'); cs.className = 'pp-select';
[['play','▶ Play'],['pause','⏸ Pause'],['toggle','⏯ Toggle'],['stop','⏹ Stop (reset)'],['restart','↺ Restart']].forEach(([v,t]) => {
const o = document.createElement('option'); o.value = v; o.textContent = t;
if (v === (act.videoCmd || 'play')) o.selected = true; cs.appendChild(o);
});
cs.onchange = () => { act.videoCmd = cs.value; };
r.appendChild(makeLbl('Command')); r.appendChild(cs); card.appendChild(r);
const vh = document.createElement('div');
vh.style.cssText = 'font-size:0.4rem;color:var(--text-muted);padding:0 0 4px;line-height:1.3;';
vh.textContent = 'Target must be a Video element (or self).';
card.appendChild(vh);
} else if (act.type === 'playSound') {
const r = document.createElement('div'); r.className = 'pp-row';
const inp = document.createElement('input'); inp.className = 'pp-input';
inp.value = act.soundUrl || ''; inp.placeholder = 'https://...mp3';
inp.onchange = () => { act.soundUrl = inp.value.trim(); };
r.appendChild(makeLbl('URL')); r.appendChild(inp); card.appendChild(r);
const vr = document.createElement('div'); vr.className = 'pp-row';
const vi = document.createElement('input'); vi.className = 'pp-input'; vi.type = 'number'; vi.min = 0; vi.max = 1; vi.step = 0.1;
vi.value = act.volume !== undefined ? act.volume : 1;
vi.onchange = () => { act.volume = parseFloat(vi.value); };
vr.appendChild(makeLbl('Vol')); vr.appendChild(vi); card.appendChild(vr);
} else if (act.type === 'callFunction') {
const fnR = document.createElement('div'); fnR.className = 'pp-row';
// Combo: dropdown + text input
const fnWrap = document.createElement('div');
fnWrap.style.cssText = 'display:flex;gap:3px;flex:1;';
const fnSel = document.createElement('select'); fnSel.className = 'pp-select';
fnSel.style.cssText = 'flex:1;min-width:0;';
const customOpt = document.createElement('option');
customOpt.value = '__custom__'; customOpt.textContent = '(type custom…)';
fnSel.appendChild(customOpt);
// Parse all functions from code
const codeVal = customCode || document.getElementById('code-main')?.value || '';
const funcMatches = [...codeVal.matchAll(/function\s+(\w+)\s*\(([^)]*)\)/g)];
let foundInList = false;
funcMatches.forEach(fm => {
const o = document.createElement('option');
o.value = fm[1]; o.textContent = `${fm[1]}(${fm[2]})`;
if (fm[1] === act.funcName) { o.selected = true; foundInList = true; }
fnSel.appendChild(o);
});
const fnI = document.createElement('input'); fnI.className = 'pp-input';
fnI.value = act.funcName || ''; fnI.placeholder = 'functionName';
fnI.style.cssText = "font-family:'Courier New',monospace;flex:1;min-width:0;";
if (foundInList) { fnI.style.display = 'none'; fnSel.value = act.funcName; }
else { fnSel.value = '__custom__'; }
fnSel.onchange = () => {
if (fnSel.value === '__custom__') {
fnI.style.display = ''; fnI.focus();
} else {
fnI.style.display = 'none';
act.funcName = fnSel.value; fnI.value = fnSel.value;
}
};
fnI.onchange = () => { act.funcName = fnI.value.trim(); };
fnWrap.appendChild(fnSel); fnWrap.appendChild(fnI);
fnR.appendChild(makeLbl('Func')); fnR.appendChild(fnWrap); card.appendChild(fnR);
const arR = document.createElement('div'); arR.className = 'pp-row';
const arI = document.createElement('input'); arI.className = 'pp-input';
arI.value = act.funcArgs || ''; arI.placeholder = '"hello", 42';
arI.style.fontFamily = "'Courier New',monospace";
arI.onchange = () => { act.funcArgs = arI.value.trim(); };
arR.appendChild(makeLbl('Args')); arR.appendChild(arI); card.appendChild(arR);
} else if (act.type === 'addAnimation' || act.type === 'removeAnimation') {
const aR = document.createElement('div'); aR.className = 'pp-row';
const aSel = document.createElement('select'); aSel.className = 'pp-select';
const anOpt = document.createElement('option'); anOpt.value = ''; anOpt.textContent = '(select)'; aSel.appendChild(anOpt);
const AG = {
'Entrances': ['animate__fadeIn','animate__fadeInUp','animate__fadeInDown','animate__fadeInLeft','animate__fadeInRight','animate__bounceIn','animate__bounceInUp','animate__zoomIn','animate__slideInUp','animate__slideInDown','animate__slideInLeft','animate__slideInRight','animate__flipInX','animate__flipInY','animate__jackInTheBox','animate__rollIn','animate__backInUp','animate__backInDown'],
'Exits': ['animate__fadeOut','animate__fadeOutUp','animate__fadeOutDown','animate__fadeOutLeft','animate__fadeOutRight','animate__bounceOut','animate__zoomOut','animate__slideOutUp','animate__slideOutDown','animate__slideOutLeft','animate__slideOutRight','animate__flipOutX','animate__flipOutY','animate__rollOut','animate__backOutUp','animate__backOutDown'],
'Attention': ['animate__bounce','animate__flash','animate__pulse','animate__rubberBand','animate__shakeX','animate__shakeY','animate__headShake','animate__swing','animate__tada','animate__wobble','animate__jello','animate__heartBeat','animate__flip'],
};
Object.entries(AG).forEach(([g, a]) => {
const og = document.createElement('optgroup'); og.label = g;
a.forEach(c => { const o = document.createElement('option'); o.value = c; o.textContent = c.replace('animate__',''); if (c === act.animClass) o.selected = true; og.appendChild(o); });
aSel.appendChild(og);
});
aSel.onchange = () => { act.animClass = aSel.value; };
aR.appendChild(makeLbl('Anim')); aR.appendChild(aSel); card.appendChild(aR);
if (act.type === 'addAnimation') {
const durR = document.createElement('div'); durR.className = 'pp-row';
const durI = document.createElement('input'); durI.className = 'pp-input';
durI.value = act.animDuration || ''; durI.placeholder = '0.5s, 2s';
durI.onchange = () => { act.animDuration = durI.value.trim(); };
durR.appendChild(makeLbl('Duration')); durR.appendChild(durI); card.appendChild(durR);
const delR = document.createElement('div'); delR.className = 'pp-row';
const delI = document.createElement('input'); delI.className = 'pp-input';
delI.value = act.animDelay || ''; delI.placeholder = '0s, 0.5s, 1s';
delI.onchange = () => { act.animDelay = delI.value.trim(); };
delR.appendChild(makeLbl('Anim Delay')); delR.appendChild(delI); card.appendChild(delR);
const repR = document.createElement('div'); repR.className = 'pp-row';
const repS = document.createElement('select'); repS.className = 'pp-select';
['','1','2','3','5','10','infinite'].forEach(v => {
const o = document.createElement('option'); o.value = v; o.textContent = v || '(default)';
if (v === (act.animRepeat || '')) o.selected = true; repS.appendChild(o);
});
repS.onchange = () => { act.animRepeat = repS.value; };
repR.appendChild(makeLbl('Repeat')); repR.appendChild(repS); card.appendChild(repR);
}
}
// loadScene fields
if (act.type === 'loadScene') {
const lr = document.createElement('div'); lr.className = 'pp-row';
const li = document.createElement('input'); li.className = 'pp-input';
li.value = act.levelFile || ''; li.placeholder = 'saves/level2.json';
li.onchange = () => { act.levelFile = li.value.trim(); };
lr.appendChild(makeLbl('Level File')); lr.appendChild(li); card.appendChild(lr);
const ur = document.createElement('div'); ur.className = 'pp-row';
const ui = document.createElement('input'); ui.className = 'pp-input';
ui.value = act.uiFile || ''; ui.placeholder = '(optional) saves/ui2.json';
ui.onchange = () => { act.uiFile = ui.value.trim(); };
ur.appendChild(makeLbl('UI File')); ur.appendChild(ui); card.appendChild(ur);
}
// playMusic fields
if (act.type === 'playMusic') {
const mr = document.createElement('div'); mr.className = 'pp-row';
const mi = document.createElement('input'); mi.className = 'pp-input';
mi.value = act.musicSrc || ''; mi.placeholder = './audio/music.mp3';
mi.onchange = () => { act.musicSrc = mi.value.trim(); };
mr.appendChild(makeLbl('Music File')); mr.appendChild(mi); card.appendChild(mr);
const lr = document.createElement('div'); lr.className = 'pp-row';
const ls = document.createElement('select'); ls.className = 'pp-select';
[['true','Loop'],['false','Play Once']].forEach(([v,t]) => { const o = document.createElement('option'); o.value = v; o.textContent = t; if (String(act.musicLoop !== false) === v) o.selected = true; ls.appendChild(o); });
ls.onchange = () => { act.musicLoop = ls.value === 'true'; };
lr.appendChild(makeLbl('Loop')); lr.appendChild(ls); card.appendChild(lr);
}
// setVolume fields
if (act.type === 'setVolume') {
const vr = document.createElement('div'); vr.className = 'pp-row';
const vi = document.createElement('input'); vi.className = 'pp-input'; vi.type = 'range'; vi.min = 0; vi.max = 1; vi.step = 0.05;
vi.value = act.volume !== undefined ? act.volume : 0.5;
const vl = document.createElement('span'); vl.style.cssText = 'min-width:30px;text-align:right;font-size:0.6rem;color:#a0b0c0;'; vl.textContent = Math.round((act.volume !== undefined ? act.volume : 0.5) * 100) + '%';
vi.oninput = () => { act.volume = parseFloat(vi.value); vl.textContent = Math.round(act.volume * 100) + '%'; };
vr.appendChild(makeLbl('Volume')); vr.appendChild(vi); vr.appendChild(vl); card.appendChild(vr);
}
// loadURL fields
if (act.type === 'loadURL') {
const ur = document.createElement('div'); ur.className = 'pp-row';
const ui = document.createElement('input'); ui.className = 'pp-input';
ui.value = act.url || ''; ui.placeholder = 'https://example.com';
ui.onchange = () => { act.url = ui.value.trim(); };
ur.appendChild(makeLbl('URL')); ur.appendChild(ui); card.appendChild(ur);
const tr = document.createElement('div'); tr.className = 'pp-row';
const ts = document.createElement('select'); ts.className = 'pp-select';
[['same','Same Tab'],['newTab','New Tab'],['newWindow','New Window'],['iframe','In-Game Overlay']].forEach(([v,t]) => { const o = document.createElement('option'); o.value = v; o.textContent = t; if (v === (act.urlTarget||'newTab')) o.selected = true; ts.appendChild(o); });
ts.onchange = () => { act.urlTarget = ts.value; buildChain(); };
tr.appendChild(makeLbl('Target')); tr.appendChild(ts); card.appendChild(tr);
if (act.urlTarget === 'iframe') {
const bir = document.createElement('div'); bir.className = 'pp-row';
const bii = document.createElement('input'); bii.className = 'pp-input'; bii.value = act.iframeBackIcon || ''; bii.placeholder = 'back icon image';
bii.onchange = () => { act.iframeBackIcon = bii.value.trim(); };
bir.appendChild(makeLbl('Back Icon')); bir.appendChild(bii); card.appendChild(bir);
}
}
// showHTML fields
if (act.type === 'showHTML') {
const mr = document.createElement('div'); mr.className = 'pp-row';
const ms = document.createElement('select'); ms.className = 'pp-select';
[['popup','UI Popup'],['3d','3D Billboard']].forEach(([v,t]) => { const o = document.createElement('option'); o.value = v; o.textContent = t; if (v === (act.htmlMode||'popup')) o.selected = true; ms.appendChild(o); });
ms.onchange = () => { act.htmlMode = ms.value; buildChain(); };
mr.appendChild(makeLbl('Display')); mr.appendChild(ms); card.appendChild(mr);
const cr = document.createElement('div'); cr.className = 'pp-row';
const ci = document.createElement('textarea'); ci.className = 'pp-input'; ci.style.cssText = 'min-height:50px;font-family:monospace;font-size:0.55rem;';
ci.value = act.htmlContent || ''; ci.placeholder = '<p>Hello</p>';
ci.onchange = () => { act.htmlContent = ci.value; };
cr.appendChild(makeLbl('HTML')); cr.appendChild(ci); card.appendChild(cr);
}
// Delay from trigger (parallel) or from previous step (chain)
if (i > 0) {
const dR = document.createElement('div'); dR.className = 'pp-row';
const dI = document.createElement('input'); dI.className = 'pp-input'; dI.type = 'number'; dI.min = 0; dI.step = 100;
dI.value = act.delay || 0;
dI.onchange = () => { act.delay = parseInt(dI.value) || 0; };
const dLbl = el.events[modeKey] === 'chain' ? 'Delay ms (after prev)' : 'Delay ms (from start)';
dR.appendChild(makeLbl(dLbl)); dR.appendChild(dI); card.appendChild(dR);
}
chainBox.appendChild(card);
});
}
buildChain();
const addBtn = document.createElement('button'); addBtn.className = 'pp-btn';
addBtn.style.cssText = 'width:100%;margin-top:2px;background:rgba(245,158,11,0.06);border-color:var(--accent3);color:var(--accent3);font-size:0.55rem;padding:3px 6px;';
addBtn.innerHTML = '<i class="bi bi-plus-lg"></i> Add';
addBtn.onclick = () => { acts.push({ type:'show', targetId:'', delay:0 }); buildChain(); buildPropsPanel(el); };
evtBody.appendChild(chainBox);
evtBody.appendChild(addBtn);
evtSec.appendChild(evtBody);
});
ppProps.appendChild(evtSec);
}
// ── Actions section ──
const acts = makeSection('Actions', 'bi-lightning');
const lockCheck = document.createElement('input');
lockCheck.type = 'checkbox'; lockCheck.className = 'pp-check'; lockCheck.checked = el.locked;
lockCheck.onchange = () => { el.locked = lockCheck.checked; renderElement(el); };
acts.appendChild(makeRow('Locked', lockCheck));
const visCheck = document.createElement('input');
visCheck.type = 'checkbox'; visCheck.className = 'pp-check'; visCheck.checked = el.visible;
visCheck.onchange = () => { el.visible = visCheck.checked; renderElement(el); rerenderChildren(el.id); buildLayerList(); };
acts.appendChild(makeRow('Visible', visCheck));
const disCheck = document.createElement('input');
disCheck.type = 'checkbox'; disCheck.className = 'pp-check'; disCheck.checked = !!el.disabled;
disCheck.onchange = () => { el.disabled = disCheck.checked; renderElement(el); rerenderChildren(el.id); buildLayerList(); };
acts.appendChild(makeRow('Disabled', disCheck));
const disHint = document.createElement('div');
disHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.3;';
disHint.textContent = 'Disabled = always hidden, ignores all show/hide actions.';
acts.appendChild(disHint);
// Show on viewports
const showOnLabel = document.createElement('div');
showOnLabel.style.cssText = 'font-family:"Orbitron",sans-serif;font-size:0.4rem;letter-spacing:1px;color:var(--accent2);margin:8px 0 4px;text-transform:uppercase;';
showOnLabel.textContent = '● Show On Viewports';
acts.appendChild(showOnLabel);
const showHint = document.createElement('div');
showHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.3;';
showHint.textContent = 'None checked = show on all. Check specific viewports to limit.';
acts.appendChild(showHint);
if (!el.showOn) el.showOn = [];
const vpOpts = [
{ key:'desktop', label:'Desktop', icon:'bi-display' },
{ key:'tablet', label:'Tablet', icon:'bi-tablet' },
{ key:'phone', label:'Phone', icon:'bi-phone' },
{ key:'phone-land', label:'Ph. Land', icon:'bi-phone-landscape' },
];
const vpRow = document.createElement('div');
vpRow.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;padding:2px 0 6px;';
vpOpts.forEach(vp => {
const lbl = document.createElement('label');
lbl.style.cssText = 'display:flex;align-items:center;gap:3px;font-size:0.5rem;color:var(--text-secondary);cursor:pointer;padding:2px 6px;border:1px solid var(--border-color);border-radius:4px;background:' + (el.showOn.includes(vp.key) ? 'rgba(167,139,250,0.12)' : 'transparent');
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.checked = el.showOn.includes(vp.key);
cb.style.cssText = 'margin:0;width:12px;height:12px;';
cb.onchange = () => {
if (cb.checked) { if (!el.showOn.includes(vp.key)) el.showOn.push(vp.key); }
else { el.showOn = el.showOn.filter(v => v !== vp.key); }
buildPropsPanel(el);
};
lbl.appendChild(cb);
const ico = document.createElement('i');
ico.className = 'bi ' + vp.icon;
lbl.appendChild(ico);
lbl.appendChild(document.createTextNode(' ' + vp.label));
vpRow.appendChild(lbl);
});
acts.appendChild(vpRow);
// Control mode (for panels)
if (el.type === 'panel') {
const ctrlLabel = document.createElement('div');
ctrlLabel.style.cssText = 'font-family:"Orbitron",sans-serif;font-size:0.4rem;letter-spacing:1px;color:var(--accent);margin:8px 0 4px;text-transform:uppercase;';
ctrlLabel.textContent = '● Control Mode';
acts.appendChild(ctrlLabel);
acts.appendChild(makeRow('Mode', makeSelect(el.controlMode || 'none', ['none','dpad','joystick'], v => { el.controlMode = v; buildPropsPanel(el); renderElement(el); })));
if (el.controlMode === 'dpad' || el.controlMode === 'joystick') {
acts.appendChild(makeRow('Ctrl BG', makeColorCombo(el.controlBg || '', v => { el.controlBg = v; renderElement(el); })));
acts.appendChild(makeRow(el.controlMode === 'dpad' ? 'Arrow Color' : 'Knob Color', makeColorCombo(el.controlKnob || '', v => { el.controlKnob = v; renderElement(el); })));
}
const ctrlHint = document.createElement('div');
ctrlHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.3;';
ctrlHint.textContent = 'D-Pad: renders arrow buttons. Joystick: draggable knob. Both simulate WASD to control the player.';
acts.appendChild(ctrlHint);
}
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display:flex;gap:4px;margin-top:8px;';
const dupBtn = document.createElement('button');
dupBtn.className = 'pp-btn'; dupBtn.style.cssText = 'flex:1;background:rgba(0,229,160,0.08);border-color:var(--accent);color:var(--accent);';
dupBtn.innerHTML = '<i class="bi bi-copy"></i> Duplicate';
dupBtn.onclick = () => duplicateElement(el.id);
btnRow.appendChild(dupBtn);
const delBtn = document.createElement('button');
delBtn.className = 'pp-btn'; delBtn.style.cssText = 'flex:1;background:rgba(255,77,106,0.08);border-color:var(--danger);color:var(--danger);';
delBtn.innerHTML = '<i class="bi bi-trash3"></i> Remove';
delBtn.onclick = () => removeElement(el.id);
btnRow.appendChild(delBtn);
acts.appendChild(btnRow);
// Layer buttons
const layerRow = document.createElement('div');
layerRow.style.cssText = 'display:flex;gap:4px;margin-top:4px;';
const fwdBtn = document.createElement('button');
fwdBtn.className = 'pp-btn'; fwdBtn.style.cssText = 'flex:1;background:rgba(167,139,250,0.08);border-color:var(--accent2);color:var(--accent2);';
fwdBtn.innerHTML = '<i class="bi bi-arrow-up"></i> Forward';
fwdBtn.onclick = () => { el.zIndex = Math.min(el.zIndex + 1, 100); renderElement(el); buildPropsPanel(el); };
layerRow.appendChild(fwdBtn);
const bkBtn = document.createElement('button');
bkBtn.className = 'pp-btn'; bkBtn.style.cssText = 'flex:1;background:rgba(167,139,250,0.08);border-color:var(--accent2);color:var(--accent2);';
bkBtn.innerHTML = '<i class="bi bi-arrow-down"></i> Back';
bkBtn.onclick = () => { el.zIndex = Math.max(el.zIndex - 1, 0); renderElement(el); buildPropsPanel(el); };
layerRow.appendChild(bkBtn);
acts.appendChild(layerRow);
ppProps.appendChild(acts);
applyPropsTab();
}
// ══════════════════════════════════════
// RIGHT PANEL TABS (Style / Properties)
// ══════════════════════════════════════
function switchPropsTab(tab) {
window._ppTab = tab;
document.querySelectorAll('.pp-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
document.querySelectorAll('.pp-tab-panel').forEach(p => p.classList.toggle('active', p.id === 'pp-panel-' + tab));
ppContent.scrollTop = 0;
}
function applyPropsTab() {
// Keep the user's chosen tab across selections; default to Properties
switchPropsTab(window._ppTab || 'props');
}
document.querySelectorAll('.pp-tab').forEach(btn => {
btn.addEventListener('click', () => switchPropsTab(btn.dataset.tab));
});
// ── Property helper builders ──
/** Local timestamp for filenames: YYYY-MM-DD_HHMMSS */
function fileTimestamp(d) {
d = d || new Date();
const p = n => String(n).padStart(2, '0');
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) +
'_' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
}
function makeSection(title, icon) {
const s = document.createElement('div');
s.className = 'pp-section';
s.innerHTML = `<h6><i class="bi ${icon}"></i> ${title}</h6>`;
return s;
}
// ══════════════════════════════════════
// CAROUSEL PROPERTIES (Owl Carousel 2)
// ══════════════════════════════════════
function makeCheck(val, onChange) {
const c = document.createElement('input');
c.type = 'checkbox'; c.className = 'pp-check'; c.checked = !!val;
c.onchange = () => onChange(c.checked);
return c;
}
function makeTextareaInput(val, onChange, placeholder, rows) {
const t = document.createElement('textarea');
t.className = 'pp-input';
t.style.cssText = 'flex:1;min-height:' + ((rows || 3) * 16) + 'px;resize:vertical;font-family:monospace;font-size:0.55rem;line-height:1.4;';
t.value = val || '';
if (placeholder) t.placeholder = placeholder;
t.onchange = () => onChange(t.value);
return t;
}
const NAV_SHAPES = { circle:'50%', rounded:'8px', square:'0', pill:'999px' };
const DOT_SHAPES = { circle:'50%', rounded:'3px', square:'0', bar:'999px' };
function navShapeOf(el) {
const r = el.carNavRadius || '50%';
for (const [k, v] of Object.entries(NAV_SHAPES)) if (v === r) return k;
return 'circle';
}
function dotShapeOf(el) {
const r = el.carDotRadius || '50%';
if (r === '999px' && (parseFloat(el.carDotWidth) || 7) > (parseFloat(el.carDotHeight) || 7)) return 'bar';
for (const [k, v] of Object.entries(DOT_SHAPES)) if (v === r) return k;
return 'circle';
}
/** Active-item props are stored per device; desktop is the base value. */
function carGet(el, prop) {
if (deviceMode !== 'desktop' && el.carDeviceOverrides && el.carDeviceOverrides[deviceMode]) {
const v = el.carDeviceOverrides[deviceMode][prop];
if (v !== undefined && v !== null) return v;
}
return el[prop];
}
function carSet(el, prop, val) {
if (deviceMode !== 'desktop') {
if (!el.carDeviceOverrides) el.carDeviceOverrides = { tablet:{}, phone:{}, 'phone-land':{} };
if (!el.carDeviceOverrides[deviceMode]) el.carDeviceOverrides[deviceMode] = {};
el.carDeviceOverrides[deviceMode][prop] = val;
} else {
el[prop] = val;
}
renderElement(el);
}
function carHasOv(el, prop) {
return deviceMode !== 'desktop' && el.carDeviceOverrides && el.carDeviceOverrides[deviceMode]
&& el.carDeviceOverrides[deviceMode][prop] !== undefined
&& el.carDeviceOverrides[deviceMode][prop] !== null;
}
/** Row with a purple dot + reset button when this device overrides the desktop value */
function carOvRow(el, label, prop, inputEl) {
const row = makeRow(label, inputEl);
if (deviceMode === 'desktop') return row;
const lbl = row.querySelector('.pp-label');
if (carHasOv(el, prop)) {
if (lbl) {
const dot = document.createElement('span');
dot.style.cssText = 'display:inline-block;width:5px;height:5px;border-radius:50%;background:var(--accent2);margin-left:4px;vertical-align:middle;';
dot.title = 'Overridden for ' + deviceMode;
lbl.appendChild(dot);
}
const rst = document.createElement('button');
rst.className = 'car-ed-btn';
rst.innerHTML = '<i class="bi bi-arrow-counterclockwise"></i>';
rst.title = 'Use the desktop value on ' + deviceMode;
rst.onclick = () => {
delete el.carDeviceOverrides[deviceMode][prop];
buildPropsPanel(el); renderElement(el);
};
row.appendChild(rst);
}
return row;
}
function buildHeroProps(el) {
if (!Array.isArray(el.heroSlides)) el.heroSlides = [];
const rerender = () => renderElement(el);
const rebuild = () => { renderElement(el); buildPropsPanel(el); };
// ══ PROPERTIES TAB ══
// Backgrounds
const bgSec = makeSection('Hero Backgrounds', 'bi-images');
const bgHint = document.createElement('div');
bgHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 6px;line-height:1.35;';
bgHint.textContent = 'Each background fades into the next. Give one its own title/subtitle to change the wording as it appears, or leave them blank to keep the hero text below.';
bgSec.appendChild(bgHint);
const list = document.createElement('div');
const openSet = window._heroOpen || (window._heroOpen = new Set());
el.heroSlides.forEach((sl, idx) => {
if (!sl.id) sl.id = 'hs_' + Math.random().toString(36).slice(2, 8);
const card = document.createElement('div');
card.className = 'car-ed-item' + (openSet.has(sl.id) ? ' open' : '');
const head = document.createElement('div');
head.className = 'car-ed-head';
const thumb = document.createElement(sl.image ? 'img' : 'div');
thumb.className = 'car-ed-thumb';
if (sl.image) thumb.src = sl.image; else thumb.innerHTML = '<i class="bi ' + (sl.video ? 'bi-film' : 'bi-image') + '"></i>';
const nameEl = document.createElement('span');
nameEl.className = 'car-ed-name';
nameEl.textContent = sl.title || sl.text || ('Background ' + (idx + 1));
head.appendChild(thumb); head.appendChild(nameEl);
const mkBtn = (icon, cls, title, fn) => {
const b = document.createElement('button');
b.className = 'car-ed-btn ' + (cls || ''); b.title = title;
b.innerHTML = '<i class="bi ' + icon + '"></i>';
b.onclick = e => { e.stopPropagation(); fn(); };
return b;
};
head.appendChild(mkBtn('bi-chevron-up', '', 'Move up', () => {
if (idx > 0) { const a = el.heroSlides; [a[idx-1], a[idx]] = [a[idx], a[idx-1]]; rebuild(); }
}));
head.appendChild(mkBtn('bi-chevron-down', '', 'Move down', () => {
const a = el.heroSlides;
if (idx < a.length - 1) { [a[idx+1], a[idx]] = [a[idx], a[idx+1]]; rebuild(); }
}));
head.appendChild(mkBtn('bi-trash', 'del', 'Delete', () => { el.heroSlides.splice(idx, 1); rebuild(); }));
head.onclick = () => {
if (openSet.has(sl.id)) openSet.delete(sl.id); else openSet.add(sl.id);
card.classList.toggle('open');
};
const body = document.createElement('div');
body.className = 'car-ed-body';
body.appendChild(makeRow('Image', makeImageInput(sl.image || '', v => { sl.image = v; rebuild(); })));
body.appendChild(makeRow('Video', makeVideoInput(sl.video || '', v => { sl.video = v; rebuild(); })));
body.appendChild(makeRow('Colour', makeColorCombo(sl.color || '', v => { sl.color = v; rerender(); })));
body.appendChild(makeRow('Title', makeInput(sl.title || '', v => { sl.title = v; rebuild(); }, 'leave blank to keep hero title')));
body.appendChild(makeRow('Subtitle', makeInput(sl.text || '', v => { sl.text = v; rebuild(); }, 'leave blank to keep hero subtitle')));
card.appendChild(head); card.appendChild(body);
list.appendChild(card);
});
bgSec.appendChild(list);
const addBg = document.createElement('button');
addBg.className = 'car-ed-add';
addBg.innerHTML = '<i class="bi bi-plus-lg"></i> Add Background';
addBg.onclick = () => {
const id = 'hs_' + Math.random().toString(36).slice(2, 8);
el.heroSlides.push({ id, image:'', video:'', color:'', title:'', text:'' });
openSet.add(id); rebuild();
};
bgSec.appendChild(addBg);
ppProps.appendChild(bgSec);
// Text
const txtSec = makeSection('Hero Text', 'bi-fonts');
txtSec.appendChild(makeRow('Title', makeInput(el.heroTitle, v => { el.heroTitle = v; rerender(); }, 'Main heading')));
txtSec.appendChild(makeRow('Subtitle', makeInput(el.heroSubtitle, v => { el.heroSubtitle = v; rerender(); }, 'Supporting line')));
const txtHint = document.createElement('div');
txtHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:2px 0;line-height:1.35;';
txtHint.textContent = 'Basic HTML works here (<br>, <b>, <span>). A background with its own title/subtitle overrides these while it is showing.';
txtSec.appendChild(txtHint);
ppProps.appendChild(txtSec);
// Size + behaviour
const laySec = makeSection('Hero Layout', 'bi-aspect-ratio');
const fsBtn = document.createElement('button');
fsBtn.className = 'car-ed-add';
fsBtn.style.cssText += 'margin-bottom:6px;';
fsBtn.innerHTML = '<i class="bi bi-arrows-fullscreen"></i> Make full screen';
fsBtn.onclick = () => {
el.x = 50; el.y = 50; el.w = 100; el.h = 100; el.anchor = 'center';
if (el.responsive) el.overrides = { tablet:{}, phone:{}, 'phone-land':{} };
rebuild();
};
laySec.appendChild(fsBtn);
laySec.appendChild(makeRow('Align H', makeSelect(el.heroAlignH, ['left','center','right'], v => { el.heroAlignH = v; rerender(); })));
laySec.appendChild(makeRow('Align V', makeSelect(el.heroAlignV, ['top','center','bottom'], v => { el.heroAlignV = v; rerender(); })));
laySec.appendChild(makeRow('Padding', makeInput(el.heroPadding, v => { el.heroPadding = v; rerender(); }, '6% / 40px')));
laySec.appendChild(makeRow('Text Width', makeInput(el.heroMaxWidth, v => { el.heroMaxWidth = v; rerender(); }, '70% / 640px')));
laySec.appendChild(makeRow('Gap', makeInput(el.heroGap, v => { el.heroGap = v; rerender(); }, '10px')));
ppProps.appendChild(laySec);
const behSec = makeSection('Hero Behaviour', 'bi-arrow-repeat');
behSec.appendChild(makeRow('Auto Play', makeCheck(el.heroAutoplay, v => { el.heroAutoplay = v; rebuild(); })));
if (el.heroAutoplay) {
behSec.appendChild(makeRow('Interval', makeNumInput(el.heroInterval, 800, 60000, 250, v => { el.heroInterval = Math.round(v); rerender(); })));
}
behSec.appendChild(makeRow('Transition', makeSelect(el.heroTransition, ['fade','zoom','slide-left','slide-right','slide-up'], v => { el.heroTransition = v; rerender(); })));
behSec.appendChild(makeRow('Duration', makeNumInput(el.heroTransDuration, 0, 5000, 50, v => { el.heroTransDuration = Math.round(v); rerender(); })));
behSec.appendChild(makeRow('Ken Burns', makeCheck(el.heroKenBurns, v => { el.heroKenBurns = v; rebuild(); })));
if (el.heroKenBurns) {
behSec.appendChild(makeRow('Zoom To', makeSlider(el.heroKenScale, 1, 1.6, 0.01, v => { el.heroKenScale = v; rerender(); })));
}
behSec.appendChild(makeRow('Start At', makeNumInput(el.heroStart, 0, 99, 1, v => { el.heroStart = Math.max(0, Math.round(v)); rerender(); })));
behSec.appendChild(makeRow('On Change', makeInput(el.heroChangeFunc, v => { el.heroChangeFunc = v; rerender(); }, 'onHeroSlide')));
const behHint = document.createElement('div');
behHint.style.cssText = 'font-size:0.42rem;color:var(--accent3);padding:2px 0;line-height:1.35;';
behHint.textContent = 'Backgrounds cycle in Preview and in the player — the canvas holds the first one so you can design against it.';
behSec.appendChild(behHint);
ppProps.appendChild(behSec);
const kidHint = makeSection('Child Elements', 'bi-diagram-3');
const kids = elements.filter(c => c.parentId === el.id);
const kidText = document.createElement('div');
kidText.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.35;';
kidText.textContent = kids.length
? kids.length + ' element' + (kids.length === 1 ? '' : 's') + ' sit inside this hero. Their position is a percentage of the hero, not the screen.'
: 'Drop any element on the canvas, then set its Parent to this hero (in the element\'s Info section) to place it inside — buttons, logos, badges, anything.';
kidHint.appendChild(kidText);
ppProps.appendChild(kidHint);
// ══ STYLE TAB ══
const bgStyle = makeSection('Background Style', 'bi-image');
bgStyle.appendChild(makeRow('Fit', makeSelect(el.heroBgFit, ['cover','contain','fill','none'], v => { el.heroBgFit = v; rerender(); })));
bgStyle.appendChild(makeRow('Position', makeInput(el.heroBgPosition, v => { el.heroBgPosition = v; rerender(); }, 'center / top left')));
bgStyle.appendChild(makeRow('Overlay', makeInput(el.heroOverlay, v => { el.heroOverlay = v; rerender(); }, 'linear-gradient(...) / #0008')));
bgStyle.appendChild(makeRow('Overlay α', makeSlider(el.heroOverlayOpacity, 0, 1, 0.05, v => { el.heroOverlayOpacity = v; rerender(); })));
bgStyle.appendChild(makeRow('Empty Bg', makeInput(el.heroEmptyBg, v => { el.heroEmptyBg = v; rerender(); }, 'used when a slide has no media')));
ppStyle.appendChild(bgStyle);
const FONT_UNITS = ['rem','px','em','%','vw','vh','pt'];
const sizeRow = (label, sizeKey, unitKey) => {
const wrap = document.createElement('div');
wrap.style.cssText = 'display:flex;gap:4px;flex:1;align-items:center;';
const num = document.createElement('input');
num.type = 'number'; num.className = 'pp-input'; num.style.cssText = 'flex:1;min-width:0;';
num.step = (el[unitKey] === 'px' || el[unitKey] === 'pt') ? '1' : '0.05';
num.value = el[sizeKey];
num.onchange = () => { el[sizeKey] = parseFloat(num.value) || 0; rerender(); };
const us = document.createElement('select');
us.className = 'pp-select'; us.style.cssText = 'flex:0 0 54px;';
FONT_UNITS.forEach(u => {
const o = document.createElement('option');
o.value = u; o.textContent = u;
if (u === (el[unitKey] || 'rem')) o.selected = true;
us.appendChild(o);
});
us.onchange = () => { el[unitKey] = us.value; buildPropsPanel(el); rerender(); };
wrap.appendChild(num); wrap.appendChild(us);
return makeRow(label, wrap);
};
const tSec = makeSection('Title Style', 'bi-type-h1');
tSec.appendChild(makeRow('Font', makeSelect(el.heroTitleFont, [
"'Orbitron',sans-serif", "'Rajdhani',sans-serif", "'Courier New',monospace",
"Georgia,serif", "'Arial',sans-serif", "'Trebuchet MS',sans-serif", "system-ui",
], v => { el.heroTitleFont = v; rerender(); })));
tSec.appendChild(sizeRow('Size', 'heroTitleSize', 'heroTitleUnit'));
tSec.appendChild(makeRow('Weight', makeSelect(el.heroTitleWeight, ['300','400','500','600','700','800','900'], v => { el.heroTitleWeight = v; rerender(); })));
tSec.appendChild(makeRow('Colour', makeColorCombo(el.heroTitleColor, v => { el.heroTitleColor = v; rerender(); })));
tSec.appendChild(makeRow('Line H', makeInput(el.heroTitleLineHeight, v => { el.heroTitleLineHeight = v; rerender(); }, '1.1')));
tSec.appendChild(makeRow('Spacing', makeInput(el.heroTitleSpacing, v => { el.heroTitleSpacing = v; rerender(); }, 'normal / 2px')));
tSec.appendChild(makeRow('Shadow', makeInput(el.heroTitleShadow, v => { el.heroTitleShadow = v; rerender(); }, '0 2px 18px #0008')));
ppStyle.appendChild(tSec);
const sSec = makeSection('Subtitle Style', 'bi-type');
sSec.appendChild(makeRow('Font', makeSelect(el.heroSubFont, [
"'Rajdhani',sans-serif", "'Orbitron',sans-serif", "'Courier New',monospace",
"Georgia,serif", "'Arial',sans-serif", "'Trebuchet MS',sans-serif", "system-ui",
], v => { el.heroSubFont = v; rerender(); })));
sSec.appendChild(sizeRow('Size', 'heroSubSize', 'heroSubUnit'));
sSec.appendChild(makeRow('Weight', makeSelect(el.heroSubWeight, ['300','400','500','600','700'], v => { el.heroSubWeight = v; rerender(); })));
sSec.appendChild(makeRow('Colour', makeColorCombo(el.heroSubColor, v => { el.heroSubColor = v; rerender(); })));
sSec.appendChild(makeRow('Line H', makeInput(el.heroSubLineHeight, v => { el.heroSubLineHeight = v; rerender(); }, '1.5')));
sSec.appendChild(makeRow('Shadow', makeInput(el.heroSubShadow, v => { el.heroSubShadow = v; rerender(); }, '0 1px 10px #0007')));
ppStyle.appendChild(sSec);
const navSec = makeSection('Hero Dots & Arrows', 'bi-three-dots');
navSec.appendChild(makeRow('Dots', makeCheck(el.heroDots, v => { el.heroDots = v; rebuild(); })));
if (el.heroDots) {
navSec.appendChild(makeRow('Width', makeNumInput(el.heroDotWidth, 2, 80, 1, v => { el.heroDotWidth = Math.round(v); rerender(); })));
navSec.appendChild(makeRow('Height', makeNumInput(el.heroDotHeight, 2, 80, 1, v => { el.heroDotHeight = Math.round(v); rerender(); })));
navSec.appendChild(makeRow('Radius', makeInput(el.heroDotRadius, v => { el.heroDotRadius = v; rerender(); }, '50% / 2px')));
navSec.appendChild(makeRow('Gap', makeNumInput(el.heroDotGap, 0, 60, 1, v => { el.heroDotGap = Math.round(v); rerender(); })));
navSec.appendChild(makeRow('From Bottom', makeNumInput(el.heroDotBottom, -100, 400, 1, v => { el.heroDotBottom = Math.round(v); rerender(); })));
navSec.appendChild(makeRow('Colour', makeColorCombo(el.heroDotColor, v => { el.heroDotColor = v; rerender(); })));
navSec.appendChild(makeRow('Active Col', makeColorCombo(el.heroDotActiveColor, v => { el.heroDotActiveColor = v; rerender(); })));
navSec.appendChild(makeRow('Active W', makeInput(el.heroDotActiveWidth, v => { el.heroDotActiveWidth = v; rerender(); }, 'same / 28')));
}
navSec.appendChild(makeRow('Arrows', makeCheck(el.heroArrows, v => { el.heroArrows = v; rebuild(); })));
if (el.heroArrows) {
navSec.appendChild(makeRow('Size', makeNumInput(el.heroArrowSize, 16, 120, 1, v => { el.heroArrowSize = Math.round(v); rerender(); })));
navSec.appendChild(makeRow('Radius', makeInput(el.heroArrowRadius, v => { el.heroArrowRadius = v; rerender(); }, '50% / 8px')));
navSec.appendChild(makeRow('Inset', makeNumInput(el.heroArrowInset, -100, 400, 1, v => { el.heroArrowInset = Math.round(v); rerender(); })));
navSec.appendChild(makeRow('Bg', makeColorCombo(el.heroArrowBg, v => { el.heroArrowBg = v; rerender(); })));
navSec.appendChild(makeRow('Icon Col', makeColorCombo(el.heroArrowColor, v => { el.heroArrowColor = v; rerender(); })));
navSec.appendChild(makeRow('Icon Size', makeSlider(el.heroArrowIconSize, 0.3, 3, 0.05, v => { el.heroArrowIconSize = v; rerender(); })));
navSec.appendChild(makeRow('Prev Icon', makeInput(el.heroPrevIcon, v => { el.heroPrevIcon = v; rerender(); }, 'bi-chevron-left')));
navSec.appendChild(makeRow('Next Icon', makeInput(el.heroNextIcon, v => { el.heroNextIcon = v; rerender(); }, 'bi-chevron-right')));
}
ppStyle.appendChild(navSec);
}
function buildCarouselProps(el) {
if (!Array.isArray(el.carouselItems)) el.carouselItems = [];
const rerender = () => renderElement(el);
const rebuild = () => { renderElement(el); buildPropsPanel(el); };
// ── Items ──
const itemsSec = makeSection('Carousel Items', 'bi-collection');
const hint = document.createElement('div');
hint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 6px;line-height:1.35;';
hint.textContent = 'Each item reuses the design below — fill in its image, title and text.';
itemsSec.appendChild(hint);
const list = document.createElement('div');
const openSet = window._carOpenItems || (window._carOpenItems = new Set());
el.carouselItems.forEach((item, idx) => {
if (!item.id) item.id = 'ci_' + Math.random().toString(36).slice(2, 8);
const card = document.createElement('div');
card.className = 'car-ed-item' + (openSet.has(item.id) ? ' open' : '');
const head = document.createElement('div');
head.className = 'car-ed-head';
const thumb = document.createElement(item.image ? 'img' : 'div');
thumb.className = 'car-ed-thumb';
if (item.image) thumb.src = item.image; else thumb.innerHTML = '<i class="bi bi-image"></i>';
const nameEl = document.createElement('span');
nameEl.className = 'car-ed-name';
nameEl.textContent = item.title || item.text || ('Item ' + (idx + 1));
const lay = item.layer || (item.model ? 'model' : (item.foreground ? 'image' : 'none'));
if (lay !== 'none') {
const lb = document.createElement('span');
lb.style.cssText = 'font-size:0.4rem;color:var(--accent2);background:rgba(167,139,250,0.12);border-radius:6px;padding:1px 5px;margin-left:4px;';
lb.textContent = lay === 'model' ? '3D' : 'IMG';
lb.title = lay === 'model' ? 'A 3D model sits on top of this card' : 'An overlay image sits on top of this card';
nameEl.appendChild(lb);
}
if (item.actions && item.actions.length) {
const tb = document.createElement('span');
tb.style.cssText = 'font-size:0.4rem;color:var(--accent);background:rgba(0,229,160,0.12);border-radius:6px;padding:1px 5px;';
tb.textContent = item.actions.length + ' ⚡';
tb.title = item.actions.length + ' trigger(s) on this item';
nameEl.appendChild(tb);
}
head.appendChild(thumb);
head.appendChild(nameEl);
const mkBtn = (icon, cls, title, fn) => {
const b = document.createElement('button');
b.className = 'car-ed-btn ' + (cls || '');
b.title = title;
b.innerHTML = '<i class="bi ' + icon + '"></i>';
b.onclick = e => { e.stopPropagation(); fn(); };
return b;
};
head.appendChild(mkBtn('bi-chevron-up', '', 'Move up', () => {
if (idx > 0) { const a = el.carouselItems; [a[idx-1], a[idx]] = [a[idx], a[idx-1]]; rebuild(); }
}));
head.appendChild(mkBtn('bi-chevron-down', '', 'Move down', () => {
const a = el.carouselItems;
if (idx < a.length - 1) { [a[idx+1], a[idx]] = [a[idx], a[idx+1]]; rebuild(); }
}));
head.appendChild(mkBtn('bi-files', '', 'Duplicate', () => {
const copy = JSON.parse(JSON.stringify(el.carouselItems[idx]));
copy.id = 'ci_' + Math.random().toString(36).slice(2, 8);
el.carouselItems.splice(idx + 1, 0, copy); rebuild();
}));
head.appendChild(mkBtn('bi-trash', 'del', 'Delete', () => {
el.carouselItems.splice(idx, 1); rebuild();
}));
head.onclick = () => {
if (openSet.has(item.id)) openSet.delete(item.id); else openSet.add(item.id);
card.classList.toggle('open');
};
const body = document.createElement('div');
body.className = 'car-ed-body';
body.appendChild(makeRow('Image', makeImageInput(item.image || '', v => { item.image = v; rebuild(); })));
body.appendChild(makeRow('Title', makeInput(item.title || '', v => { item.title = v; rebuild(); }, 'Item title')));
body.appendChild(makeRow('Text', makeInput(item.text || '', v => { item.text = v; rebuild(); }, 'Caption / description')));
// What goes on top of the card image
const curLayer = item.layer || (item.model ? 'model' : (item.foreground ? 'image' : 'none'));
const layerLbl = document.createElement('div');
layerLbl.style.cssText = 'font-family:"Orbitron",sans-serif;font-size:0.4rem;letter-spacing:1px;color:var(--accent);margin:8px 0 4px;text-transform:uppercase;';
layerLbl.textContent = '● On Top Of The Image';
body.appendChild(layerLbl);
body.appendChild(makeRow('Layer', makeSelect(curLayer, ['none','model','image'], v => { item.layer = v; rebuild(); })));
if (curLayer === 'model') {
body.appendChild(makeRow('GLB File', makeInput(item.model || '', v => { item.model = v; rebuild(); }, './models/thing.glb')));
const spinVal = (item.modelAutoRotate === undefined || item.modelAutoRotate === '') ? 'inherit' : (item.modelAutoRotate ? 'on' : 'off');
body.appendChild(makeRow('Auto Rotate', makeSelect(spinVal, ['inherit','on','off'], v => {
item.modelAutoRotate = v === 'inherit' ? '' : (v === 'on');
rebuild();
})));
body.appendChild(makeRow('Scale', makeNumInput(item.modelScale !== undefined ? item.modelScale : 1, 0.05, 8, 0.05, v => { item.modelScale = v; rebuild(); })));
body.appendChild(makeRow('Zoom (dist)', makeNumInput(item.modelDistance !== undefined ? item.modelDistance : (el.carModelDistance || 4), 0.5, 30, 0.1, v => { item.modelDistance = v; rebuild(); })));
body.appendChild(makeRow('Rot X°', makeNumInput(item.modelRotX || 0, -360, 360, 5, v => { item.modelRotX = v; rebuild(); })));
body.appendChild(makeRow('Rot Y°', makeNumInput(item.modelRotY || 0, -360, 360, 5, v => { item.modelRotY = v; rebuild(); })));
body.appendChild(makeRow('Rot Z°', makeNumInput(item.modelRotZ || 0, -360, 360, 5, v => { item.modelRotZ = v; rebuild(); })));
body.appendChild(makeRow('Shift X', makeNumInput(item.modelX || 0, -10, 10, 0.05, v => { item.modelX = v; rebuild(); })));
body.appendChild(makeRow('Shift Y', makeNumInput(item.modelY || 0, -10, 10, 0.05, v => { item.modelY = v; rebuild(); })));
body.appendChild(makeRow('Shift Z', makeNumInput(item.modelZ || 0, -10, 10, 0.05, v => { item.modelZ = v; rebuild(); })));
body.appendChild(makeRow('Margin', makeInput(item.modelInset !== undefined ? item.modelInset : '', v => { item.modelInset = v; rebuild(); }, 'inherit / 12')));
const zh = document.createElement('div');
zh.style.cssText = 'font-size:0.4rem;color:var(--text-muted);padding:0 0 4px;line-height:1.35;';
zh.textContent = 'Smaller Zoom = camera closer = model looks bigger. Auto Rotate: inherit follows the 3D Cards setting. Active/side poses override the rotations when set.';
body.appendChild(zh);
} else if (curLayer === 'image') {
body.appendChild(makeRow('Overlay Img', makeImageInput(item.foreground || '', v => { item.foreground = v; rebuild(); })));
body.appendChild(makeRow('Margin', makeInput(item.fgInset !== undefined ? item.fgInset : '', v => { item.fgInset = v; rebuild(); }, 'inherit / 12 / 8%')));
body.appendChild(makeRow('Padding', makeInput(item.fgPadding !== undefined ? item.fgPadding : '', v => { item.fgPadding = v; rebuild(); }, 'inherit / 16')));
body.appendChild(makeRow('Width', makeInput(item.fgWidth !== undefined ? item.fgWidth : '', v => { item.fgWidth = v; rebuild(); }, 'inherit / 70% / 180px')));
body.appendChild(makeRow('Height', makeInput(item.fgHeight !== undefined ? item.fgHeight : '', v => { item.fgHeight = v; rebuild(); }, 'inherit / auto')));
body.appendChild(makeRow('Fit', makeSelect(item.fgFit || 'inherit', ['inherit','contain','cover','stretch','none'], v => { item.fgFit = v === 'inherit' ? '' : v; rebuild(); })));
const fh = document.createElement('div');
fh.style.cssText = 'font-size:0.4rem;color:var(--text-muted);padding:0 0 4px;line-height:1.35;';
fh.textContent = 'Blank = use the shared values in the Overlay Image section of the Style tab.';
body.appendChild(fh);
}
body.appendChild(makeRow('Link', makeInput(item.link || '', v => { item.link = v; rerender(); }, 'https://... (used by Item Click)')));
// ── Extra fields (feed linked elements: headings, paragraphs, big images…) ──
if (!item.fields || typeof item.fields !== 'object') item.fields = {};
const fldLabel = document.createElement('div');
fldLabel.style.cssText = 'font-family:"Orbitron",sans-serif;font-size:0.4rem;letter-spacing:1px;color:var(--accent2);margin:8px 0 4px;text-transform:uppercase;';
fldLabel.textContent = '● Extra Fields';
body.appendChild(fldLabel);
Object.keys(item.fields).forEach(key => {
const r = document.createElement('div');
r.className = 'pp-row';
const kIn = document.createElement('input');
kIn.className = 'pp-input'; kIn.value = key; kIn.style.cssText = 'flex:0 0 70px;font-size:0.5rem;';
kIn.title = 'Field name';
kIn.onchange = () => {
const nk = kIn.value.trim();
if (!nk || nk === key) { kIn.value = key; return; }
item.fields[nk] = item.fields[key]; delete item.fields[key]; rebuild();
};
const vIn = makeInput(item.fields[key], v => { item.fields[key] = v; rerender(); }, 'value');
const del = document.createElement('button');
del.className = 'car-ed-btn del'; del.innerHTML = '<i class="bi bi-x"></i>'; del.title = 'Remove field';
del.onclick = () => { delete item.fields[key]; rebuild(); };
r.appendChild(kIn); r.appendChild(vIn); r.appendChild(del);
body.appendChild(r);
});
const addFld = document.createElement('button');
addFld.className = 'pp-btn';
addFld.style.cssText = 'width:100%;margin-top:2px;background:rgba(167,139,250,0.06);border-color:var(--accent2);color:var(--accent2);font-size:0.5rem;padding:3px 6px;';
addFld.innerHTML = '<i class="bi bi-plus-lg"></i> Add field';
addFld.onclick = () => {
let n = 1; while (item.fields['field' + n] !== undefined) n++;
item.fields['field' + n] = ''; rebuild();
};
body.appendChild(addFld);
// ── Triggers: what happens to other elements when this item is active ──
if (!Array.isArray(item.actions)) item.actions = [];
const trgLabel = document.createElement('div');
trgLabel.style.cssText = 'font-family:"Orbitron",sans-serif;font-size:0.4rem;letter-spacing:1px;color:var(--accent);margin:10px 0 4px;text-transform:uppercase;';
trgLabel.textContent = '● When This Item Is Active';
body.appendChild(trgLabel);
const ACT_OPTS = [['setText','Set text'],['setHTML','Set HTML'],['setImage','Set image src'],
['setBackground','Set background'],['setVideo','Set video src'],['setColor','Set text colour'],
['show','Show'],['hide','Hide']];
const FIELD_OPTS = [['','(type a value)'],['title','Item title'],['text','Item text'],
['image','Item image'],['link','Item link'],['index','Item number']];
item.actions.forEach((act, ai) => {
const aCard = document.createElement('div');
aCard.style.cssText = 'border:1px solid var(--border-color);border-radius:6px;padding:5px 6px;margin-bottom:4px;background:rgba(0,229,160,0.03);';
const aHead = document.createElement('div');
aHead.style.cssText = 'display:flex;align-items:center;gap:4px;margin-bottom:3px;';
const aNum = document.createElement('span');
aNum.style.cssText = 'font-size:0.4rem;letter-spacing:1px;color:var(--accent);font-family:"Orbitron",sans-serif;';
aNum.textContent = 'T' + (ai + 1);
const aDel = document.createElement('button');
aDel.className = 'car-ed-btn del'; aDel.style.marginLeft = 'auto';
aDel.innerHTML = '<i class="bi bi-x"></i>'; aDel.title = 'Remove trigger';
aDel.onclick = e => { e.stopPropagation(); item.actions.splice(ai, 1); rebuild(); };
aHead.appendChild(aNum); aHead.appendChild(aDel);
aCard.appendChild(aHead);
// target element
const tSel = document.createElement('select'); tSel.className = 'pp-select';
const tNone = document.createElement('option');
tNone.value = ''; tNone.textContent = '(choose element)';
if (!act.targetId) tNone.selected = true;
tSel.appendChild(tNone);
elements.filter(o => o.id !== el.id).forEach(o => {
const op = document.createElement('option');
op.value = o.id; op.textContent = (o.name || o.type) + ' — ' + o.id;
if (o.id === act.targetId) op.selected = true;
tSel.appendChild(op);
});
tSel.onchange = () => { act.targetId = tSel.value; rerender(); };
aCard.appendChild(makeRow('Element', tSel));
// action
const aSel = document.createElement('select'); aSel.className = 'pp-select';
ACT_OPTS.forEach(([v, t]) => {
const op = document.createElement('option');
op.value = v; op.textContent = t;
if (v === (act.action || 'setText')) op.selected = true;
aSel.appendChild(op);
});
aSel.onchange = () => { act.action = aSel.value; rebuild(); };
aCard.appendChild(makeRow('Do', aSel));
// value (not needed for show/hide)
if (act.action !== 'show' && act.action !== 'hide') {
const fSel = document.createElement('select'); fSel.className = 'pp-select';
FIELD_OPTS.concat(Object.keys(item.fields || {}).map(k => ['field:' + k, 'Field: ' + k]))
.forEach(([v, t]) => {
const op = document.createElement('option');
op.value = v; op.textContent = t;
if (v === (act.useField || '')) op.selected = true;
fSel.appendChild(op);
});
fSel.onchange = () => { act.useField = fSel.value; rebuild(); };
aCard.appendChild(makeRow('Value', fSel));
if (!act.useField) {
aCard.appendChild(makeRow('Text', makeInput(act.value || '', v => { act.value = v; rerender(); }, 'value to apply')));
}
}
body.appendChild(aCard);
});
const addTrg = document.createElement('button');
addTrg.className = 'pp-btn';
addTrg.style.cssText = 'width:100%;margin-top:2px;background:rgba(0,229,160,0.06);border-color:var(--accent);color:var(--accent);font-size:0.5rem;padding:3px 6px;';
addTrg.innerHTML = '<i class="bi bi-plus-lg"></i> Add trigger';
addTrg.onclick = () => { item.actions.push({ targetId:'', action:'setText', useField:'title', value:'' }); rebuild(); };
body.appendChild(addTrg);
card.appendChild(head);
card.appendChild(body);
list.appendChild(card);
});
itemsSec.appendChild(list);
const addBtn = document.createElement('button');
addBtn.className = 'car-ed-add';
addBtn.innerHTML = '<i class="bi bi-plus-lg"></i> Add Item';
addBtn.onclick = () => {
const id = 'ci_' + Math.random().toString(36).slice(2, 8);
el.carouselItems.push({ id, image: '', title: 'Item ' + (el.carouselItems.length + 1), text: '', link: '' });
openSet.add(id);
rebuild();
};
itemsSec.appendChild(addBtn);
ppProps.appendChild(itemsSec);
// ── What each card shows ──
const shown = makeSection('Item Content', 'bi-card-heading');
const shownHint = document.createElement('div');
shownHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 6px;line-height:1.35;';
shownHint.textContent = 'Applies to every card, including the active one.';
shown.appendChild(shownHint);
shown.appendChild(makeRow('Image', makeCheck(el.carShowImage, v => { el.carShowImage = v; rerender(); })));
shown.appendChild(makeRow('Title', makeCheck(el.carShowTitle, v => { el.carShowTitle = v; rerender(); })));
shown.appendChild(makeRow('Text', makeCheck(el.carShowText, v => { el.carShowText = v; rerender(); })));
// Extra fields → show inside the card
if (!Array.isArray(el.carShowFields)) el.carShowFields = [];
const allKeys = new Set();
el.carouselItems.forEach(it => { if (it.fields) Object.keys(it.fields).forEach(k => allKeys.add(k)); });
const fldTitle = document.createElement('div');
fldTitle.style.cssText = 'font-family:"Orbitron",sans-serif;font-size:0.4rem;letter-spacing:1px;color:var(--accent2);margin:8px 0 4px;text-transform:uppercase;';
fldTitle.textContent = '● Extra Fields In Card';
shown.appendChild(fldTitle);
if (allKeys.size === 0) {
const none = document.createElement('div');
none.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.35;';
none.textContent = 'Add fields to an item above, then tick them here to show them in every card.';
shown.appendChild(none);
} else {
const chips = document.createElement('div');
chips.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;padding:2px 0 4px;';
allKeys.forEach(k => {
const on = el.carShowFields.includes(k);
const lbl = document.createElement('label');
lbl.style.cssText = 'display:flex;align-items:center;gap:3px;font-size:0.5rem;color:var(--text-secondary);cursor:pointer;padding:2px 6px;border:1px solid var(--border-color);border-radius:4px;background:' + (on ? 'rgba(167,139,250,0.12)' : 'transparent');
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.checked = on;
cb.onchange = () => {
if (cb.checked) { if (!el.carShowFields.includes(k)) el.carShowFields.push(k); }
else el.carShowFields = el.carShowFields.filter(x => x !== k);
rebuild();
};
lbl.appendChild(cb); lbl.appendChild(document.createTextNode(k));
chips.appendChild(lbl);
});
shown.appendChild(chips);
const tplNote = document.createElement('div');
tplNote.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.35;';
tplNote.textContent = 'In a custom item HTML template you can also place them anywhere as {fieldName}.';
shown.appendChild(tplNote);
}
ppProps.appendChild(shown);
// ── Layout ──
const lay = makeSection('Carousel Layout', 'bi-columns-gap');
lay.appendChild(makeRow('Visible', makeNumInput(el.carItems, 1, 12, 1, v => { el.carItems = Math.max(1, Math.round(v)); rerender(); })));
lay.appendChild(makeRow('On Tablet', makeNumInput(el.carItemsTablet, 1, 12, 1, v => { el.carItemsTablet = Math.max(1, Math.round(v)); rerender(); })));
lay.appendChild(makeRow('On Phone', makeNumInput(el.carItemsPhone, 1, 12, 1, v => { el.carItemsPhone = Math.max(1, Math.round(v)); rerender(); })));
lay.appendChild(makeRow('On Landscape', makeNumInput(el.carItemsPhoneLand === '' ? (parseInt(el.carItemsTablet) || 2) : el.carItemsPhoneLand, 1, 12, 1,
v => { el.carItemsPhoneLand = Math.max(1, Math.round(v)); rerender(); })));
const landHint = document.createElement('div');
landHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.35;';
landHint.textContent = 'Landscape = phones held sideways (≤768px wide). Blank falls back to the tablet count.';
lay.appendChild(landHint);
lay.appendChild(makeRow('Gap (px)', makeNumInput(el.carMargin, 0, 100, 1, v => { el.carMargin = Math.round(v); rerender(); })));
lay.appendChild(makeRow('Active Gap', makeNumInput(el.carSideGap, -200, 300, 2, v => { el.carSideGap = Math.round(v); rerender(); })));
lay.appendChild(makeRow('Gap Stacks', makeCheck(el.carSideGapStacks, v => { el.carSideGapStacks = v; rerender(); })));
const gapHint = document.createElement('div');
gapHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.35;';
gapHint.textContent = 'Gap spaces every card evenly. Active Gap pushes the side cards further away from the active one — with Gap Stacks on, the second neighbour moves twice as far.';
lay.appendChild(gapHint);
lay.appendChild(makeRow('Stage Pad', makeNumInput(el.carStagePadding, 0, 200, 5, v => { el.carStagePadding = Math.round(v); rerender(); })));
lay.appendChild(makeRow('Auto Width', makeCheck(el.carAutoWidth, v => { el.carAutoWidth = v; rerender(); })));
lay.appendChild(makeRow('Start At', makeNumInput(el.carStart, 0, 99, 1, v => { el.carStart = Math.max(0, Math.round(v)); rerender(); })));
ppProps.appendChild(lay);
// ── Active item emphasis ──
const ctr = makeSection('Active Item', 'bi-bullseye');
const devBanner = document.createElement('div');
devBanner.style.cssText = 'font-size:0.42rem;padding:4px 6px;margin-bottom:6px;border-radius:5px;line-height:1.35;' +
(deviceMode === 'desktop'
? 'color:var(--text-muted);background:rgba(255,255,255,0.03);'
: 'color:var(--accent2);background:rgba(167,139,250,0.1);border:1px solid rgba(167,139,250,0.3);');
devBanner.textContent = deviceMode === 'desktop'
? 'Editing the desktop values. Switch device at the top to give tablet, phone or landscape their own active-item settings.'
: 'Editing ' + deviceMode.toUpperCase() + ' — changes here only apply on ' + deviceMode + '. Values without a dot are inherited from desktop.';
ctr.appendChild(devBanner);
ctr.appendChild(makeRow('Center Mode', makeCheck(el.carCenter, v => { el.carCenter = v; rerender(); })));
const perView = parseInt(deviceMode === 'phone' ? (el.carItemsPhone || 1)
: deviceMode === 'tablet' ? (el.carItemsTablet || el.carItems)
: deviceMode === 'phone-land' ? (el.carItemsPhoneLand || el.carItemsTablet || el.carItems)
: el.carItems) || 3;
const slotOpts = ['auto'];
for (let i = 0; i < perView; i++) slotOpts.push(String(i));
const slotVal = carGet(el, 'carActiveSlot');
ctr.appendChild(carOvRow(el, 'Active Slot', 'carActiveSlot', makeSelect(
(slotVal === '' || slotVal === undefined) ? 'auto' : String(slotVal),
slotOpts,
v => { carSet(el, 'carActiveSlot', v === 'auto' ? '' : parseInt(v)); buildPropsPanel(el); }
)));
const slotHint = document.createElement('div');
slotHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.35;';
if (slotVal === '' || slotVal === undefined) {
slotHint.textContent = 'auto = the active card sits dead centre. Pick a slot number for an off-balance layout (0 = leftmost).';
} else {
const left = parseInt(slotVal);
const right = perView - left - 1;
slotHint.textContent = 'Active card in slot ' + left + ' → ' + left + ' card' + (left === 1 ? '' : 's') +
' on the left, ' + right + ' on the right (' + perView + ' visible on ' + deviceMode + ').';
slotHint.style.color = 'var(--accent)';
}
ctr.appendChild(slotHint);
ctr.appendChild(carOvRow(el, 'Active Scale', 'carCenterScale', makeSlider(carGet(el,'carCenterScale'), 0.8, 2.5, 0.02, v => carSet(el,'carCenterScale',v))));
ctr.appendChild(carOvRow(el, 'Active W', 'carCenterWidth', makeInput(carGet(el,'carCenterWidth'), v => carSet(el,'carCenterWidth',v), 'auto / 240px / 110%')));
ctr.appendChild(carOvRow(el, 'Active H', 'carCenterHeight', makeInput(carGet(el,'carCenterHeight'), v => carSet(el,'carCenterHeight',v), 'auto / 220px')));
ctr.appendChild(carOvRow(el, 'Offset X', 'carCenterOffsetX', makeNumInput(carGet(el,'carCenterOffsetX'), -300, 300, 1, v => carSet(el,'carCenterOffsetX',Math.round(v)))));
ctr.appendChild(carOvRow(el, 'Offset Y', 'carCenterOffsetY', makeNumInput(carGet(el,'carCenterOffsetY'), -300, 300, 1, v => carSet(el,'carCenterOffsetY',Math.round(v)))));
ctr.appendChild(carOvRow(el, 'Side Scale', 'carSideScale', makeSlider(carGet(el,'carSideScale'), 0.4, 1.2, 0.02, v => carSet(el,'carSideScale',v))));
ctr.appendChild(carOvRow(el, 'Side Opacity', 'carSideOpacity', makeSlider(carGet(el,'carSideOpacity'), 0.1, 1, 0.05, v => carSet(el,'carSideOpacity',v))));
ctr.appendChild(makeRow('Click→Center', makeCheck(el.carClickToCenter, v => { el.carClickToCenter = v; rerender(); })));
ctr.appendChild(carOvRow(el, 'Space Above', 'carPadTop', makeNumInput(carGet(el,'carPadTop'), 0, 500, 2, v => carSet(el,'carPadTop',Math.round(v)))));
ctr.appendChild(carOvRow(el, 'Space Below', 'carPadBottom', makeNumInput(carGet(el,'carPadBottom'), 0, 500, 2, v => carSet(el,'carPadBottom',Math.round(v)))));
const padHint = document.createElement('div');
padHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.35;';
padHint.textContent = 'Extra room inside the strip so a scaled or offset active card is never clipped — increase Space Below if its bottom edge is being cut.';
ctr.appendChild(padHint);
ctr.appendChild(makeRow('Active Layer', makeNumInput(el.carActiveZ, 1, 999, 1, v => { el.carActiveZ = Math.round(v); rerender(); })));
const zHint = document.createElement('div');
zHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.35;';
zHint.textContent = 'Stacking order of the active card against its neighbours (the element\'s own z-index only affects the whole carousel).';
ctr.appendChild(zHint);
const ovLabel = document.createElement('div');
ovLabel.style.cssText = 'font-family:"Orbitron",sans-serif;font-size:0.4rem;letter-spacing:1px;color:var(--accent2);margin:8px 0 4px;text-transform:uppercase;';
ovLabel.textContent = '● Active Card Box';
ctr.appendChild(ovLabel);
ctr.appendChild(carOvRow(el, 'Padding', 'carCenterPadding', makeInput(carGet(el,'carCenterPadding'), v => carSet(el,'carCenterPadding',v), 'inherit / 16px')));
ctr.appendChild(carOvRow(el, 'Margin', 'carCenterMargin', makeInput(carGet(el,'carCenterMargin'), v => carSet(el,'carCenterMargin',v), 'inherit / 0 8px')));
ctr.appendChild(carOvRow(el, 'Radius', 'carCenterRadius', makeInput(carGet(el,'carCenterRadius'), v => carSet(el,'carCenterRadius',v), 'inherit / 18px')));
ctr.appendChild(makeRow('Bg', makeColorCombo(el.carCenterBg, v => { el.carCenterBg = v; rerender(); })));
ctr.appendChild(makeRow('Border', makeInput(el.carCenterBorder, v => { el.carCenterBorder = v; rerender(); }, '2px solid #00e5a0')));
ctr.appendChild(makeRow('Shadow', makeInput(el.carCenterShadow, v => { el.carCenterShadow = v; rerender(); }, '0 12px 34px #0009')));
const ovHint = document.createElement('div');
ovHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:2px 0;line-height:1.35;';
ovHint.textContent = 'Blank = use the shared Item Design values. Bg / Border / Shadow are the same on every device.';
ctr.appendChild(ovHint);
// Summary of which devices carry their own settings
const devs = ['tablet','phone','phone-land'].filter(dv => {
const o = (el.carDeviceOverrides || {})[dv] || {};
return Object.keys(o).some(k => o[k] !== undefined && o[k] !== null);
});
if (devs.length) {
const sum = document.createElement('div');
sum.style.cssText = 'font-size:0.42rem;color:var(--accent2);padding:4px 0 0;line-height:1.35;';
sum.textContent = 'Custom active-item settings saved for: ' + devs.join(', ') + '.';
ctr.appendChild(sum);
}
ppStyle.appendChild(ctr);
// ── Behaviour ──
const beh = makeSection('Carousel Behaviour', 'bi-arrow-repeat');
beh.appendChild(makeRow('Loop', makeCheck(el.carLoop, v => { el.carLoop = v; rerender(); })));
beh.appendChild(makeRow('Autoplay', makeCheck(el.carAutoplay, v => { el.carAutoplay = v; rebuild(); })));
if (el.carAutoplay) {
beh.appendChild(makeRow('Interval', makeNumInput(el.carAutoplayTimeout, 500, 20000, 250, v => { el.carAutoplayTimeout = Math.round(v); rerender(); })));
beh.appendChild(makeRow('Pause Hover', makeCheck(el.carAutoplayHoverPause, v => { el.carAutoplayHoverPause = v; rerender(); })));
}
beh.appendChild(makeRow('Speed (ms)', makeNumInput(el.carSpeed, 100, 3000, 50, v => { el.carSpeed = Math.round(v); rerender(); })));
beh.appendChild(makeRow('Drag', makeCheck(el.carMouseDrag, v => { el.carMouseDrag = v; rerender(); })));
const behHint = document.createElement('div');
behHint.style.cssText = 'font-size:0.42rem;color:var(--accent3);padding:2px 0;line-height:1.35;';
behHint.textContent = 'Autoplay and dragging only run in Preview / the player — the canvas stays still while you design.';
beh.appendChild(behHint);
ppProps.appendChild(beh);
// ── Navigation ──
const nav = makeSection('Arrows', 'bi-chevron-bar-right');
nav.appendChild(makeRow('Show', makeCheck(el.carNav, v => { el.carNav = v; rerender(); })));
nav.appendChild(makeRow('Size (px)', makeNumInput(el.carNavSize, 12, 96, 1, v => { el.carNavSize = Math.round(v); rerender(); })));
nav.appendChild(makeRow('Shape', makeSelect(navShapeOf(el), ['circle','rounded','square','pill'], v => { el.carNavRadius = NAV_SHAPES[v]; rerender(); })));
nav.appendChild(makeRow('Radius', makeInput(el.carNavRadius, v => { el.carNavRadius = v; rerender(); }, '50% / 8px')));
nav.appendChild(makeRow('Icon Size', makeSlider(el.carNavIconSize, 0.3, 2.5, 0.05, v => { el.carNavIconSize = v; rerender(); })));
nav.appendChild(makeRow('Prev Icon', makeInput(el.carNavPrevIcon, v => { el.carNavPrevIcon = v; rerender(); }, 'bi-chevron-left')));
nav.appendChild(makeRow('Next Icon', makeInput(el.carNavNextIcon, v => { el.carNavNextIcon = v; rerender(); }, 'bi-chevron-right')));
nav.appendChild(makeRow('Icon Col', makeColorCombo(el.carNavColor, v => { el.carNavColor = v; rerender(); })));
nav.appendChild(makeRow('Bg', makeColorCombo(el.carNavBg, v => { el.carNavBg = v; rerender(); })));
nav.appendChild(makeRow('Border', makeInput(el.carNavBorder, v => { el.carNavBorder = v; rerender(); }, 'none / 1px solid #fff3')));
const posLabel = document.createElement('div');
posLabel.style.cssText = 'font-family:"Orbitron",sans-serif;font-size:0.4rem;letter-spacing:1px;color:var(--accent2);margin:8px 0 4px;text-transform:uppercase;';
posLabel.textContent = '● Placement' + (deviceMode === 'desktop' ? '' : ' — ' + deviceMode.toUpperCase());
nav.appendChild(posLabel);
nav.appendChild(carOvRow(el, 'Inset (px)', 'carNavInset', makeNumInput(carGet(el,'carNavInset'), -200, 300, 1,
v => { carSet(el, 'carNavInset', Math.round(v)); if (deviceMode === 'desktop') el.carNavOffset = el.carNavInset; })));
nav.appendChild(carOvRow(el, 'Both X', 'carNavOffsetX', makeNumInput(carGet(el,'carNavOffsetX'), -600, 600, 1, v => carSet(el,'carNavOffsetX',Math.round(v)))));
nav.appendChild(carOvRow(el, 'Both Y', 'carNavOffsetY', makeNumInput(carGet(el,'carNavOffsetY'), -600, 600, 1, v => carSet(el,'carNavOffsetY',Math.round(v)))));
const lrLabel = document.createElement('div');
lrLabel.style.cssText = 'font-family:"Orbitron",sans-serif;font-size:0.4rem;letter-spacing:1px;color:var(--accent);margin:8px 0 4px;text-transform:uppercase;';
lrLabel.textContent = '◀ Left Arrow';
nav.appendChild(lrLabel);
nav.appendChild(carOvRow(el, 'Left X', 'carNavPrevX', makeNumInput(carGet(el,'carNavPrevX'), -600, 600, 1, v => carSet(el,'carNavPrevX',Math.round(v)))));
nav.appendChild(carOvRow(el, 'Left Y', 'carNavPrevY', makeNumInput(carGet(el,'carNavPrevY'), -600, 600, 1, v => carSet(el,'carNavPrevY',Math.round(v)))));
const rrLabel = document.createElement('div');
rrLabel.style.cssText = 'font-family:"Orbitron",sans-serif;font-size:0.4rem;letter-spacing:1px;color:var(--accent);margin:8px 0 4px;text-transform:uppercase;';
rrLabel.textContent = 'Right Arrow ▶';
nav.appendChild(rrLabel);
nav.appendChild(carOvRow(el, 'Right X', 'carNavNextX', makeNumInput(carGet(el,'carNavNextX'), -600, 600, 1, v => carSet(el,'carNavNextX',Math.round(v)))));
nav.appendChild(carOvRow(el, 'Right Y', 'carNavNextY', makeNumInput(carGet(el,'carNavNextY'), -600, 600, 1, v => carSet(el,'carNavNextY',Math.round(v)))));
const navHint = document.createElement('div');
navHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:2px 0;line-height:1.35;';
navHint.textContent = 'Icons use Bootstrap Icon names (bi-arrow-left, bi-caret-right-fill…). Inset pulls both arrows toward the middle (negative pushes them out), Both X/Y shifts the pair, and Left/Right X/Y move each arrow on its own. Placement is saved per device.';
nav.appendChild(navHint);
ppStyle.appendChild(nav);
const dots = makeSection('Dots', 'bi-three-dots');
dots.appendChild(makeRow('Show', makeCheck(el.carDots, v => { el.carDots = v; rerender(); })));
dots.appendChild(makeRow('Position', makeSelect(el.carDotPosition || 'below', ['below','above','overlay'], v => { el.carDotPosition = v; buildPropsPanel(el); renderElement(el); })));
if (el.carDotPosition === 'overlay') {
dots.appendChild(carOvRow(el, 'From Bottom', 'carDotBottom', makeNumInput(carGet(el,'carDotBottom'), -200, 400, 1, v => carSet(el,'carDotBottom',Math.round(v)))));
}
const dotPosHint = document.createElement('div');
dotPosHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.35;';
dotPosHint.textContent = 'overlay puts the dots on top of the cards — useful when the carousel fills the whole screen and dots would fall off the bottom.';
dots.appendChild(dotPosHint);
dots.appendChild(makeRow('Shape', makeSelect(dotShapeOf(el), ['circle','rounded','square','bar'], v => {
el.carDotRadius = DOT_SHAPES[v];
if (v === 'bar') { el.carDotWidth = 18; el.carDotHeight = 5; }
rebuild();
})));
dots.appendChild(makeRow('Width', makeNumInput(el.carDotWidth, 2, 60, 1, v => { el.carDotWidth = Math.round(v); rerender(); })));
dots.appendChild(makeRow('Height', makeNumInput(el.carDotHeight, 2, 60, 1, v => { el.carDotHeight = Math.round(v); rerender(); })));
dots.appendChild(makeRow('Radius', makeInput(el.carDotRadius, v => { el.carDotRadius = v; rerender(); }, '50% / 3px')));
dots.appendChild(makeRow('Gap', makeNumInput(el.carDotGap, 0, 40, 1, v => { el.carDotGap = Math.round(v); rerender(); })));
const dotPosLabel = document.createElement('div');
dotPosLabel.style.cssText = 'font-family:"Orbitron",sans-serif;font-size:0.4rem;letter-spacing:1px;color:var(--accent2);margin:8px 0 4px;text-transform:uppercase;';
dotPosLabel.textContent = '● Placement' + (deviceMode === 'desktop' ? '' : ' — ' + deviceMode.toUpperCase());
dots.appendChild(dotPosLabel);
dots.appendChild(carOvRow(el, 'Top Space', 'carDotMarginTop', makeNumInput(carGet(el,'carDotMarginTop'), -200, 400, 1, v => carSet(el,'carDotMarginTop',Math.round(v)))));
dots.appendChild(carOvRow(el, 'Move X', 'carDotOffsetX', makeNumInput(carGet(el,'carDotOffsetX'), -600, 600, 1, v => carSet(el,'carDotOffsetX',Math.round(v)))));
dots.appendChild(carOvRow(el, 'Move Y', 'carDotOffsetY', makeNumInput(carGet(el,'carDotOffsetY'), -600, 600, 1, v => carSet(el,'carDotOffsetY',Math.round(v)))));
dots.appendChild(makeRow('Colour', makeColorCombo(el.carDotColor, v => { el.carDotColor = v; rerender(); })));
dots.appendChild(makeRow('Active Col', makeColorCombo(el.carDotActiveColor, v => { el.carDotActiveColor = v; rerender(); })));
dots.appendChild(makeRow('Active W', makeInput(el.carDotActiveWidth, v => { el.carDotActiveWidth = v; rerender(); }, 'same / 20')));
dots.appendChild(makeRow('Active H', makeInput(el.carDotActiveHeight, v => { el.carDotActiveHeight = v; rerender(); }, 'same / 8')));
const dotHint = document.createElement('div');
dotHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:2px 0;line-height:1.35;';
dotHint.textContent = 'Top Space adds room above the row; Move X/Y nudges it anywhere from there. Placement is saved per device — switch device at the top to give phones their own. Give the active dot a wider width for the classic "pill" indicator.';
dots.appendChild(dotHint);
ppStyle.appendChild(dots);
// ── Item design (replicated across all items) ──
const des = makeSection('Item Design', 'bi-palette');
des.appendChild(makeRow('Item Width', makeInput(el.carItemWidth, v => { el.carItemWidth = v; rerender(); }, 'auto / 180px / 90%')));
des.appendChild(makeRow('Item Height', makeInput(el.carItemHeight, v => { el.carItemHeight = v; rerender(); }, 'auto / 200px')));
const sizeHint = document.createElement('div');
sizeHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.35;';
sizeHint.textContent = 'Leave blank to let "Visible" items decide the width. The active item can override both in the Active Item section.';
des.appendChild(sizeHint);
des.appendChild(makeRow('Card Bg', makeColorCombo(el.carItemBg, v => { el.carItemBg = v; rerender(); })));
des.appendChild(makeRow('Card Border', makeInput(el.carItemBorder, v => { el.carItemBorder = v; rerender(); }, '1px solid #fff2')));
des.appendChild(makeRow('Radius', makeInput(el.carItemRadius, v => { el.carItemRadius = v; rerender(); }, '12px')));
des.appendChild(makeRow('Padding', makeInput(el.carItemPadding, v => { el.carItemPadding = v; rerender(); }, '10px')));
des.appendChild(makeRow('Margin', makeInput(el.carItemMargin, v => { el.carItemMargin = v; rerender(); }, '0 / 4px 6px')));
des.appendChild(makeRow('Shadow', makeInput(el.carItemShadow, v => { el.carItemShadow = v; rerender(); }, '0 6px 20px #0006')));
des.appendChild(makeRow('Align', makeSelect(el.carItemAlign, ['center','left','right'], v => { el.carItemAlign = v; rerender(); })));
des.appendChild(makeRow('Image Mode', makeSelect(el.carImgMode || 'inline', ['inline','cover'], v => { el.carImgMode = v; buildPropsPanel(el); rerender(); })));
const imgModeHint = document.createElement('div');
imgModeHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.35;';
imgModeHint.textContent = el.carImgMode === 'cover'
? 'cover = the image fills the whole card and the title/text sit on top of it.'
: 'inline = the image sits above the text at its own height. Switch to cover to fill the card.';
des.appendChild(imgModeHint);
if (el.carImgMode === 'cover') {
des.appendChild(makeRow('Text At', makeSelect(el.carCoverAlign || 'bottom', ['top','center','bottom'], v => { el.carCoverAlign = v; rerender(); })));
des.appendChild(makeRow('Img Position', makeInput(el.carImgPosition, v => { el.carImgPosition = v; rerender(); }, 'center / top / 50% 20%')));
} else {
des.appendChild(makeRow('Img Height', makeInput(el.carImgHeight, v => { el.carImgHeight = v; rerender(); }, '110px / auto')));
}
des.appendChild(makeRow('Img Fit', makeSelect(el.carImgFit, ['cover','contain','fill','none'], v => { el.carImgFit = v; rerender(); })));
des.appendChild(makeRow('Img Radius', makeInput(el.carImgRadius, v => { el.carImgRadius = v; rerender(); }, '8px')));
const CAR_UNITS = ['rem','px','em','%','vw','vh','pt'];
const sizeRow = (label, sizeKey, unitKey) => {
const wrap = document.createElement('div');
wrap.style.cssText = 'display:flex;gap:4px;flex:1;align-items:center;';
const num = document.createElement('input');
num.type = 'number'; num.className = 'pp-input'; num.style.cssText = 'flex:1;min-width:0;';
num.step = (el[unitKey] === 'px' || el[unitKey] === 'pt') ? '1' : '0.05';
num.value = el[sizeKey];
num.onchange = () => { el[sizeKey] = parseFloat(num.value) || 0; rerender(); };
const us = document.createElement('select');
us.className = 'pp-select'; us.style.cssText = 'flex:0 0 54px;';
CAR_UNITS.forEach(u => {
const o = document.createElement('option');
o.value = u; o.textContent = u;
if (u === (el[unitKey] || 'rem')) o.selected = true;
us.appendChild(o);
});
us.onchange = () => { el[unitKey] = us.value; buildPropsPanel(el); rerender(); };
wrap.appendChild(num); wrap.appendChild(us);
return makeRow(label, wrap);
};
des.appendChild(sizeRow('Title Size', 'carTitleSize', 'carTitleUnit'));
des.appendChild(makeRow('Title Col', makeColorCombo(el.carTitleColor, v => { el.carTitleColor = v; rerender(); })));
des.appendChild(makeRow('Title Wt', makeSelect(el.carTitleWeight, ['400','500','600','700'], v => { el.carTitleWeight = v; rerender(); })));
des.appendChild(sizeRow('Text Size', 'carTextSize', 'carTextUnit'));
des.appendChild(makeRow('Text Col', makeColorCombo(el.carTextColor, v => { el.carTextColor = v; rerender(); })));
des.appendChild(sizeRow('Field Size', 'carFieldSize', 'carFieldUnit'));
des.appendChild(makeRow('Field Col', makeColorCombo(el.carFieldColor, v => { el.carFieldColor = v; rerender(); })));
ppStyle.appendChild(des);
// ── Overlay images on the cards ──
if ((el.carouselItems || []).some(it => it.foreground && (it.layer || 'image') !== 'model')) {
const fg = makeSection('Overlay Image', 'bi-images');
const fgHint = document.createElement('div');
fgHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 6px;line-height:1.35;';
fgHint.textContent = 'Applies to every item whose layer is an image. Each item can still override the margin.';
fg.appendChild(fgHint);
fg.appendChild(makeRow('Margin', makeInput(el.carFgInset, v => { el.carFgInset = v; rebuild(); }, '12 / 8%')));
fg.appendChild(makeRow('Padding', makeInput(el.carFgPadding, v => { el.carFgPadding = v; rebuild(); }, '0 / 16 / 10px 24px')));
const mpHint = document.createElement('div');
mpHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.35;';
mpHint.textContent = 'Margin insets the overlay area from the card edges; Padding adds space inside that area (CSS shorthand works: "10px 24px").';
fg.appendChild(mpHint);
fg.appendChild(makeRow('Width', makeInput(el.carFgWidth, v => { el.carFgWidth = v; rebuild(); }, '100% / 70% / 180px')));
fg.appendChild(makeRow('Height', makeInput(el.carFgHeight, v => { el.carFgHeight = v; rebuild(); }, '100% / auto / 120px')));
fg.appendChild(makeRow('Fit', makeSelect(el.carFgFit === 'fill' ? 'stretch' : el.carFgFit, ['contain','cover','stretch','none'], v => { el.carFgFit = v; rebuild(); })));
const fitHint = document.createElement('div');
fitHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.35;';
fitHint.textContent = 'contain = fits inside, keeps proportions · cover = fills and crops · stretch = distorts to fill · none = original size.';
fg.appendChild(fitHint);
fg.appendChild(makeRow('Align H', makeSelect(el.carFgAlignH, ['left','center','right'], v => { el.carFgAlignH = v; rebuild(); })));
fg.appendChild(makeRow('Align V', makeSelect(el.carFgAlignV, ['top','center','bottom'], v => { el.carFgAlignV = v; rebuild(); })));
fg.appendChild(makeRow('Position', makeInput(el.carFgPosition, v => { el.carFgPosition = v; rebuild(); }, 'center / bottom')));
fg.appendChild(makeRow('Radius', makeInput(el.carFgRadius, v => { el.carFgRadius = v; rebuild(); }, '0 / 10px')));
fg.appendChild(makeRow('Shadow', makeInput(el.carFgShadow, v => { el.carFgShadow = v; rebuild(); }, '0 8px 16px #0006')));
ppStyle.appendChild(fg);
}
// ── 3D model cards ──
if ((el.carouselItems || []).some(it => it.model && (it.layer || 'model') !== 'image')) {
const cm = makeSection('3D Cards', 'bi-box');
const cmHint = document.createElement('div');
cmHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 6px;line-height:1.35;';
cmHint.textContent = 'Applies to every item that has a 3D model instead of an image.';
cm.appendChild(cmHint);
cm.appendChild(makeRow('Canvas W', makeInput(el.carModelWidth, v => { el.carModelWidth = v; rebuild(); }, '100% / 240px')));
cm.appendChild(makeRow('Canvas H', makeInput(el.carModelHeight, v => { el.carModelHeight = v; rebuild(); }, 'defaults to the image height')));
cm.appendChild(makeRow('Inset', makeNumInput(el.carModelInset, -80, 120, 1, v => { el.carModelInset = Math.round(v); rebuild(); })));
const sizeHint3d = document.createElement('div');
sizeHint3d.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.35;';
sizeHint3d.textContent = 'The canvas sits on top of the item image and matches its box, so adding a model never resizes the card. Inset shrinks (or with a negative value, expands) the canvas inside that box.';
cm.appendChild(sizeHint3d);
cm.appendChild(makeRow('Background', makeColorCombo(el.carModelBg, v => { el.carModelBg = v; rebuild(); })));
cm.appendChild(makeRow('Auto Rotate', makeCheck(el.carModelAutoRotate, v => { el.carModelAutoRotate = v; rebuild(); })));
cm.appendChild(makeRow('Spin Speed', makeSlider(el.carModelRotateSpeed, 0.1, 12, 0.1, v => { el.carModelRotateSpeed = v; rebuild(); })));
cm.appendChild(makeRow('Interactive', makeCheck(el.carModelControls, v => { el.carModelControls = v; buildPropsPanel(el); rebuild(); })));
if (el.carModelControls) {
const ih = document.createElement('div');
ih.style.cssText = 'font-size:0.42rem;color:var(--accent3);padding:0 0 4px;line-height:1.35;';
ih.textContent = 'Dragging a card now turns its model instead of sliding the carousel — use the arrows, dots or a drag outside the model area to change slides.';
cm.appendChild(ih);
cm.appendChild(makeRow('Drag Rotate', makeCheck(el.carModelDragRotate, v => { el.carModelDragRotate = v; rebuild(); })));
cm.appendChild(makeRow('Allow Zoom', makeCheck(el.carModelZoom, v => { el.carModelZoom = v; rebuild(); })));
cm.appendChild(makeRow('Allow Pan', makeCheck(el.carModelPan, v => { el.carModelPan = v; rebuild(); })));
cm.appendChild(makeRow('Drag Speed', makeSlider(el.carModelDragSpeed, 0.1, 4, 0.05, v => { el.carModelDragSpeed = v; rebuild(); })));
cm.appendChild(makeRow('Zoom Speed', makeSlider(el.carModelZoomSpeed, 0.1, 4, 0.05, v => { el.carModelZoomSpeed = v; rebuild(); })));
cm.appendChild(makeRow('Smoothing', makeCheck(el.carModelDamping, v => { el.carModelDamping = v; rebuild(); })));
cm.appendChild(makeRow('Pause Spin', makeCheck(el.carModelStopSpinOnInteract, v => { el.carModelStopSpinOnInteract = v; buildPropsPanel(el); rebuild(); })));
if (el.carModelStopSpinOnInteract) {
cm.appendChild(makeRow('Resume After', makeNumInput(el.carModelResumeSpin, 0, 60000, 250, v => { el.carModelResumeSpin = Math.round(v); rebuild(); })));
}
const climLbl = document.createElement('div');
climLbl.style.cssText = 'font-family:"Orbitron",sans-serif;font-size:0.4rem;letter-spacing:1px;color:var(--accent2);margin:8px 0 4px;text-transform:uppercase;';
climLbl.textContent = '● Limits (blank = free)';
cm.appendChild(climLbl);
const clim = (label, key, ph) => {
const inp = document.createElement('input');
inp.type = 'number'; inp.className = 'pp-input'; inp.style.cssText = 'flex:1;min-width:0;';
inp.value = el[key] === '' || el[key] === undefined ? '' : el[key];
inp.placeholder = ph;
inp.onchange = () => { el[key] = inp.value === '' ? '' : parseFloat(inp.value); rebuild(); };
return makeRow(label, inp);
};
cm.appendChild(clim('Min Zoom', 'carModelMinDistance', 'e.g. 2'));
cm.appendChild(clim('Max Zoom', 'carModelMaxDistance', 'e.g. 10'));
cm.appendChild(clim('Min Tilt°', 'carModelMinPolar', '0 = top'));
cm.appendChild(clim('Max Tilt°', 'carModelMaxPolar', '180 = bottom'));
}
cm.appendChild(makeRow('Distance', makeSlider(el.carModelDistance, 0.5, 20, 0.1, v => { el.carModelDistance = v; rebuild(); })));
cm.appendChild(makeRow('FOV', makeSlider(el.carModelFov, 10, 110, 1, v => { el.carModelFov = v; rebuild(); })));
cm.appendChild(makeRow('Scale', makeSlider(el.carModelScale, 0.05, 5, 0.05, v => { el.carModelScale = v; rebuild(); })));
cm.appendChild(makeRow('Lighting', makeSelect(el.carModelLighting, ['studio','soft','dramatic'], v => { el.carModelLighting = v; rebuild(); })));
cm.appendChild(makeRow('Intensity', makeSlider(el.carModelLightIntensity, 0, 3, 0.05, v => { el.carModelLightIntensity = v; rebuild(); })));
cm.appendChild(makeRow('Auto Rotate', makeCheck(el.carModelAutoRotate, v => { el.carModelAutoRotate = v; rebuild(); })));
cm.appendChild(makeRow('Spin Active Only', makeCheck(el.carModelSpinActiveOnly, v => { el.carModelSpinActiveOnly = v; rebuild(); })));
const spinHint = document.createElement('div');
spinHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 4px;line-height:1.35;';
spinHint.textContent = 'This is the default; each item can override it with its own Auto Rotate (inherit / on / off). "Spin Active Only" additionally limits spinning to the centred card.';
cm.appendChild(spinHint);
// Base pose for every card
const baseLbl = document.createElement('div');
baseLbl.style.cssText = 'font-family:"Orbitron",sans-serif;font-size:0.4rem;letter-spacing:1px;color:var(--accent2);margin:8px 0 4px;text-transform:uppercase;';
baseLbl.textContent = '● Base Pose (all cards)';
cm.appendChild(baseLbl);
cm.appendChild(makeRow('Rot X°', makeNumInput(el.carModelRotX, -360, 360, 5, v => { el.carModelRotX = Math.round(v); rebuild(); })));
cm.appendChild(makeRow('Rot Y°', makeNumInput(el.carModelRotY, -360, 360, 5, v => { el.carModelRotY = Math.round(v); rebuild(); })));
cm.appendChild(makeRow('Rot Z°', makeNumInput(el.carModelRotZ, -360, 360, 5, v => { el.carModelRotZ = Math.round(v); rebuild(); })));
cm.appendChild(makeRow('Shift X', makeNumInput(el.carModelX, -10, 10, 0.05, v => { el.carModelX = v; rebuild(); })));
cm.appendChild(makeRow('Shift Y', makeNumInput(el.carModelY, -10, 10, 0.05, v => { el.carModelY = v; rebuild(); })));
cm.appendChild(makeRow('Shift Z', makeNumInput(el.carModelZ, -10, 10, 0.05, v => { el.carModelZ = v; rebuild(); })));
cm.appendChild(makeRow('Cam Height', makeNumInput(el.carModelCamHeight, -10, 10, 0.05, v => { el.carModelCamHeight = v; rebuild(); })));
const poseRow = (label, key, min, max, step, ph) => {
const inp = document.createElement('input');
inp.type = 'number'; inp.className = 'pp-input'; inp.style.cssText = 'flex:1;min-width:0;';
inp.min = min; inp.max = max; inp.step = step;
inp.value = el[key] === '' || el[key] === undefined ? '' : el[key];
inp.placeholder = ph;
inp.onchange = () => { el[key] = inp.value === '' ? '' : parseFloat(inp.value); rerender(); };
return makeRow(label, inp);
};
const actLbl = document.createElement('div');
actLbl.style.cssText = 'font-family:"Orbitron",sans-serif;font-size:0.4rem;letter-spacing:1px;color:var(--accent);margin:8px 0 4px;text-transform:uppercase;';
actLbl.textContent = '● Active Card Pose';
cm.appendChild(actLbl);
cm.appendChild(poseRow('Rot X°', 'carModelActiveRotX', -360, 360, 5, 'inherit'));
cm.appendChild(poseRow('Rot Y°', 'carModelActiveRotY', -360, 360, 5, 'inherit'));
cm.appendChild(poseRow('Rot Z°', 'carModelActiveRotZ', -360, 360, 5, 'inherit'));
cm.appendChild(poseRow('Scale', 'carModelActiveScale', 0.05, 8, 0.05, 'inherit'));
cm.appendChild(poseRow('Zoom (dist)', 'carModelActiveDistance', 0.5, 30, 0.1, 'inherit'));
const sideLbl = document.createElement('div');
sideLbl.style.cssText = 'font-family:"Orbitron",sans-serif;font-size:0.4rem;letter-spacing:1px;color:var(--accent3);margin:8px 0 4px;text-transform:uppercase;';
sideLbl.textContent = '● Side Cards Pose';
cm.appendChild(sideLbl);
cm.appendChild(poseRow('Rot X°', 'carModelSideRotX', -360, 360, 5, 'inherit'));
cm.appendChild(poseRow('Rot Y°', 'carModelSideRotY', -360, 360, 5, 'inherit'));
cm.appendChild(poseRow('Rot Z°', 'carModelSideRotZ', -360, 360, 5, 'inherit'));
cm.appendChild(poseRow('Scale', 'carModelSideScale', 0.05, 8, 0.05, 'inherit'));
cm.appendChild(poseRow('Zoom (dist)', 'carModelSideDistance', 0.5, 30, 0.1, 'inherit'));
const poseHint = document.createElement('div');
poseHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:2px 0;line-height:1.35;';
poseHint.textContent = 'Leave blank to inherit. Set them to turn the model as a card becomes active — e.g. side cards at -30° and the active one at 0°. Applied every time the slide changes.';
cm.appendChild(poseHint);
ppStyle.appendChild(cm);
}
// ── Linked elements: react to the active item changing ──
if (!Array.isArray(el.carLinks)) el.carLinks = [];
const lnk = makeSection('On Active Item Change', 'bi-broadcast-pin');
const lnkHint = document.createElement('div');
lnkHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 6px;line-height:1.35;';
lnkHint.textContent = 'Same mapping for every slide — e.g. a heading always takes the active slide\'s title. For per-slide behaviour (different text, or showing/hiding components), use the triggers inside each item above.';
lnk.appendChild(lnkHint);
// Field options: built-ins + every custom field key used by any item
const fieldOpts = [['title','Title'],['text','Text'],['image','Image'],['link','Link'],['index','Index (1-based)']];
const customKeys = new Set();
el.carouselItems.forEach(it => { if (it.fields) Object.keys(it.fields).forEach(k => customKeys.add(k)); });
customKeys.forEach(k => fieldOpts.push(['field:' + k, 'Field: ' + k]));
const actionOpts = [['setText','Set text'],['setHTML','Set HTML'],['setImage','Set image src'],
['setBackground','Set background image'],['setVideo','Set video src'],['show','Show'],['hide','Hide']];
el.carLinks.forEach((link, li) => {
const card = document.createElement('div');
card.style.cssText = 'border:1px solid var(--border-color);border-radius:8px;padding:6px 8px;margin-bottom:6px;background:rgba(255,255,255,0.02);';
const head = document.createElement('div');
head.style.cssText = 'display:flex;align-items:center;gap:6px;margin-bottom:4px;';
const num = document.createElement('span');
num.style.cssText = 'font-family:"Orbitron",sans-serif;font-size:0.4rem;letter-spacing:1px;color:var(--accent2);';
num.textContent = 'LINK ' + (li + 1);
const del = document.createElement('button');
del.className = 'car-ed-btn del'; del.style.marginLeft = 'auto';
del.innerHTML = '<i class="bi bi-trash"></i>'; del.title = 'Remove link';
del.onclick = () => { el.carLinks.splice(li, 1); rebuild(); };
head.appendChild(num); head.appendChild(del);
card.appendChild(head);
// Target element
const tRow = document.createElement('div'); tRow.className = 'pp-row';
const tSel = document.createElement('select'); tSel.className = 'pp-select';
const none = document.createElement('option');
none.value = ''; none.textContent = '(choose element)';
if (!link.targetId) none.selected = true;
tSel.appendChild(none);
elements.filter(o => o.id !== el.id).forEach(o => {
const opt = document.createElement('option');
opt.value = o.id; opt.textContent = (o.name || o.type) + ' — ' + o.id;
if (o.id === link.targetId) opt.selected = true;
tSel.appendChild(opt);
});
tSel.onchange = () => { link.targetId = tSel.value; rerender(); };
tRow.appendChild(makeLbl('Element')); tRow.appendChild(tSel); card.appendChild(tRow);
// Source field
const fRow = document.createElement('div'); fRow.className = 'pp-row';
const fSel = document.createElement('select'); fSel.className = 'pp-select';
fieldOpts.forEach(([v, t]) => {
const o = document.createElement('option');
o.value = v; o.textContent = t;
if (v === (link.field || 'title')) o.selected = true;
fSel.appendChild(o);
});
fSel.onchange = () => { link.field = fSel.value; rerender(); };
fRow.appendChild(makeLbl('Use')); fRow.appendChild(fSel); card.appendChild(fRow);
// Action
const aRow = document.createElement('div'); aRow.className = 'pp-row';
const aSel = document.createElement('select'); aSel.className = 'pp-select';
actionOpts.forEach(([v, t]) => {
const o = document.createElement('option');
o.value = v; o.textContent = t;
if (v === (link.action || 'setText')) o.selected = true;
aSel.appendChild(o);
});
aSel.onchange = () => { link.action = aSel.value; rerender(); };
aRow.appendChild(makeLbl('Action')); aRow.appendChild(aSel); card.appendChild(aRow);
lnk.appendChild(card);
});
const addLink = document.createElement('button');
addLink.className = 'car-ed-add';
addLink.innerHTML = '<i class="bi bi-plus-lg"></i> Add Linked Element';
addLink.onclick = () => { el.carLinks.push({ targetId:'', field:'title', action:'setText' }); rebuild(); };
lnk.appendChild(addLink);
lnk.appendChild(makeRow('Reset Show/Hide', makeCheck(el.carResetVisibility, v => { el.carResetVisibility = v; rerender(); })));
const resetHint = document.createElement('div');
resetHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:0 0 6px;line-height:1.35;';
resetHint.textContent = 'On: any element shown/hidden by an item trigger returns to its designed state before the next slide applies its own triggers — so a component can appear on one slide only.';
lnk.appendChild(resetHint);
lnk.appendChild(makeRow('On Change', makeInput(el.carChangeFunc, v => { el.carChangeFunc = v; rerender(); }, 'onSlideChange')));
const fnHint = document.createElement('div');
fnHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:2px 0;line-height:1.35;';
fnHint.textContent = 'Optional function called as fn(item, index, element) whenever the active slide changes.';
lnk.appendChild(fnHint);
ppProps.appendChild(lnk);
// ── Item click + custom template ──
const adv = makeSection('Item Click & Template', 'bi-code-slash');
adv.appendChild(makeRow('On Click', makeSelect(el.carItemClick, ['none','link','function'], v => { el.carItemClick = v; rebuild(); })));
if (el.carItemClick === 'link') {
adv.appendChild(makeRow('Target', makeSelect(el.carLinkTarget, ['newTab','same','iframe'], v => { el.carLinkTarget = v; rerender(); })));
} else if (el.carItemClick === 'function') {
adv.appendChild(makeRow('Function', makeInput(el.carItemFunc, v => { el.carItemFunc = v; rerender(); }, 'onCarouselItem')));
const fh = document.createElement('div');
fh.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:2px 0;line-height:1.35;';
fh.textContent = 'Called as fn(item, index, element) — define it in Settings → Custom Game Functions.';
adv.appendChild(fh);
}
const tplRow = document.createElement('div');
tplRow.className = 'pp-row';
tplRow.style.cssText = 'flex-direction:column;align-items:stretch;gap:4px;';
const tplLbl = document.createElement('span');
tplLbl.className = 'pp-label'; tplLbl.textContent = 'Custom item HTML';
tplRow.appendChild(tplLbl);
tplRow.appendChild(makeTextareaInput(el.carItemTemplate, v => { el.carItemTemplate = v; rerender(); },
'<img src="{image}"><h4>{title}</h4><p>{text}</p>', 4));
adv.appendChild(tplRow);
const tplHint = document.createElement('div');
tplHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:2px 0;line-height:1.35;';
tplHint.textContent = 'Leave empty for the built-in layout. Placeholders: {image} {title} {text} {link}';
adv.appendChild(tplHint);
ppProps.appendChild(adv);
}
function makeRow(label, inputEl) {
const r = document.createElement('div');
r.className = 'pp-row';
const l = document.createElement('span');
l.className = 'pp-label'; l.textContent = label;
r.appendChild(l);
r.appendChild(inputEl);
return r;
}
function makeInput(val, onChange, placeholder) {
const inp = document.createElement('input');
inp.className = 'pp-input'; inp.value = val || '';
if (placeholder) inp.placeholder = placeholder;
inp.onchange = () => onChange(inp.value);
return inp;
}
function makeReadonly(val) {
const inp = document.createElement('input');
inp.className = 'pp-input'; inp.value = val; inp.readOnly = true;
inp.style.opacity = '0.6';
return inp;
}
function makeNumInput(val, min, max, step, onChange, placeholder) {
const inp = document.createElement('input');
inp.className = 'pp-input'; inp.type = 'number';
inp.value = val; inp.min = min; inp.max = max; inp.step = step;
if (placeholder) inp.placeholder = placeholder;
inp.oninput = () => onChange(parseFloat(inp.value) || 0);
return inp;
}
function makeSlider(val, min, max, step, onChange) {
const wrap = document.createElement('div');
wrap.style.cssText = 'display:flex;align-items:center;gap:4px;flex:1;';
const sl = document.createElement('input');
sl.className = 'pp-slider'; sl.type = 'range';
sl.min = min; sl.max = max; sl.step = step; sl.value = val;
const vSpan = document.createElement('span');
vSpan.className = 'pp-val'; vSpan.textContent = parseFloat(val).toFixed(step < 1 ? 2 : 0);
sl.oninput = () => { vSpan.textContent = parseFloat(sl.value).toFixed(step < 1 ? 2 : 0); onChange(parseFloat(sl.value)); };
wrap.appendChild(sl); wrap.appendChild(vSpan);
return wrap;
}
function makeSelect(val, options, onChange) {
const sel = document.createElement('select');
sel.className = 'pp-select';
options.forEach(o => {
const opt = document.createElement('option');
opt.value = o; opt.textContent = o;
if (o === val) opt.selected = true;
sel.appendChild(opt);
});
sel.onchange = () => onChange(sel.value);
return sel;
}
function makeColor(val, onChange) {
const inp = document.createElement('input');
inp.type = 'color'; inp.className = 'pp-color'; inp.value = val || '#ffffff';
inp.oninput = () => onChange(inp.value);
return inp;
}
function makeLbl(text) {
const l = document.createElement('span');
l.className = 'pp-label'; l.textContent = text;
return l;
}
function makeColorCombo(val, onChange) {
const wrap = document.createElement('div');
wrap.className = 'color-combo';
// Extract hex color from value for the picker
const hexMatch = (val || '').match(/#[0-9a-fA-F]{3,8}/);
const colorInp = document.createElement('input');
colorInp.type = 'color'; colorInp.className = 'pp-color';
colorInp.value = hexMatch ? hexMatch[0] : '#000000';
const textInp = document.createElement('input');
textInp.className = 'pp-input'; textInp.value = val || '';
colorInp.oninput = () => { textInp.value = colorInp.value; onChange(colorInp.value); };
textInp.onchange = () => { onChange(textInp.value); try { colorInp.value = textInp.value; } catch(e) {} };
wrap.appendChild(colorInp); wrap.appendChild(textInp);
return wrap;
}
function makeImageInput(val, onChange, compact) {
const wrap = document.createElement('div');
wrap.style.cssText = 'display:flex;gap:3px;flex:1;align-items:center;';
const textInp = document.createElement('input');
textInp.className = 'pp-input'; textInp.value = (val || '').startsWith('data:') ? '(base64 image)' : (val || '');
textInp.placeholder = 'URL or upload...';
textInp.style.cssText = 'flex:1;min-width:0;';
textInp.onchange = () => { onChange(textInp.value); };
// Preview thumbnail
const thumb = document.createElement('div');
thumb.style.cssText = 'width:22px;height:22px;border-radius:3px;border:1px solid var(--border-color);background-size:cover;background-position:center;flex-shrink:0;cursor:pointer;';
if (val) thumb.style.backgroundImage = `url(${val})`;
thumb.title = 'Click to preview';
// Upload button
const upBtn = document.createElement('label');
upBtn.style.cssText = 'display:flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:3px;background:rgba(167,139,250,0.1);border:1px solid var(--accent2);color:var(--accent2);cursor:pointer;flex-shrink:0;font-size:0.55rem;';
upBtn.innerHTML = '<i class="bi bi-upload"></i>';
upBtn.title = 'Upload image';
const fileInp = document.createElement('input');
fileInp.type = 'file'; fileInp.accept = 'image/*'; fileInp.style.display = 'none';
fileInp.onchange = () => {
const file = fileInp.files[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) { showToast('Image too large (max 5MB)', 'error'); return; }
const reader = new FileReader();
reader.onload = ev => {
const b64 = ev.target.result;
textInp.value = '(base64 image)';
thumb.style.backgroundImage = `url(${b64})`;
onChange(b64);
showToast(`Image loaded (${(file.size/1024).toFixed(0)}KB)`);
};
reader.readAsDataURL(file);
fileInp.value = '';
};
upBtn.appendChild(fileInp);
// Clear button
const clrBtn = document.createElement('div');
clrBtn.style.cssText = 'width:22px;height:22px;border-radius:3px;background:rgba(255,77,106,0.08);border:1px solid var(--danger);color:var(--danger);cursor:pointer;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:0.45rem;';
clrBtn.innerHTML = '<i class="bi bi-x"></i>';
clrBtn.title = 'Clear image';
clrBtn.onclick = () => { textInp.value = ''; thumb.style.backgroundImage = ''; onChange(''); };
wrap.appendChild(thumb); wrap.appendChild(textInp); wrap.appendChild(upBtn); wrap.appendChild(clrBtn);
return wrap;
}
function makeVideoInput(val, onChange) {
const wrap = document.createElement('div');
wrap.style.cssText = 'display:flex;gap:3px;flex:1;align-items:center;';
const textInp = document.createElement('input');
textInp.className = 'pp-input';
textInp.value = (val || '').startsWith('data:') ? '(embedded video)' : (val || '');
textInp.placeholder = './videos/intro.mp4 or URL';
textInp.style.cssText = 'flex:1;min-width:0;';
textInp.onchange = () => { if (textInp.value !== '(embedded video)') onChange(textInp.value.trim()); };
// Upload button (embeds as base64 — heavy, prefer paths)
const upBtn = document.createElement('label');
upBtn.style.cssText = 'display:flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:3px;background:rgba(167,139,250,0.1);border:1px solid var(--accent2);color:var(--accent2);cursor:pointer;flex-shrink:0;font-size:0.55rem;';
upBtn.innerHTML = '<i class="bi bi-upload"></i>';
upBtn.title = 'Embed video file (base64, max 25MB — inflates JSON, prefer a file path)';
const fileInp = document.createElement('input');
fileInp.type = 'file'; fileInp.accept = 'video/mp4,video/webm,video/ogg'; fileInp.style.display = 'none';
fileInp.onchange = () => {
const file = fileInp.files[0];
if (!file) return;
if (file.size > 25 * 1024 * 1024) { showToast('Video too large (max 25MB embed) — use a file path instead', 'error'); return; }
const reader = new FileReader();
reader.onload = ev => {
textInp.value = '(embedded video)';
onChange(ev.target.result);
showToast(`Video embedded (${(file.size/1024/1024).toFixed(1)}MB) — file paths keep JSON smaller`);
};
reader.readAsDataURL(file);
fileInp.value = '';
};
upBtn.appendChild(fileInp);
// Clear button
const clrBtn = document.createElement('div');
clrBtn.style.cssText = 'width:22px;height:22px;border-radius:3px;background:rgba(255,77,106,0.08);border:1px solid var(--danger);color:var(--danger);cursor:pointer;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:0.45rem;';
clrBtn.innerHTML = '<i class="bi bi-x"></i>';
clrBtn.title = 'Clear video';
clrBtn.onclick = () => { textInp.value = ''; onChange(''); };
wrap.appendChild(textInp); wrap.appendChild(upBtn); wrap.appendChild(clrBtn);
return wrap;
}
// Override-aware row: shows purple dot if this property has a device override
function makeOvRow(el, label, prop, inputEl) {
const r = makeRow(label, inputEl);
if (hasOverride(el, prop)) r.classList.add('has-override');
return r;
}
// ══════════════════════════════════════
// LAYER LIST
// ══════════════════════════════════════
function buildLayerList() {
const container = document.getElementById('layer-items');
container.innerHTML = '';
const typeIcons = { text:'bi-fonts', button:'bi-hand-index', image:'bi-image', icon:'bi-emoji-smile', panel:'bi-square', progressbar:'bi-battery-half', video:'bi-film', carousel:'bi-collection-play', hero:'bi-image-alt', iframe:'bi-window', model3d:'bi-box' };
const collapsed = window._layerCollapsed || (window._layerCollapsed = new Set());
function renderLayerItem(el, depth) {
const children = elements.filter(e => e.parentId === el.id);
const hasChildren = children.length > 0;
const isCollapsed = collapsed.has(el.id);
const div = document.createElement('div');
div.className = 'layer-item' + (el.id === selectedId ? ' selected' : '') + (depth > 0 ? ' child' : '');
if (depth > 0) div.style.paddingLeft = (12 + depth * 16) + 'px';
div.draggable = true;
div.dataset.layerId = el.id;
const collapseIcon = hasChildren
? `<i class="bi ${isCollapsed ? 'bi-caret-right-fill' : 'bi-caret-down-fill'} li-collapse" style="font-size:0.5rem;cursor:pointer;color:var(--text-muted);min-width:10px;"></i>`
: '<span style="min-width:10px;"></span>';
div.innerHTML = `
${collapseIcon}
<i class="bi ${typeIcons[el.type] || 'bi-square'} li-icon"></i>
<span class="li-name">${el.name || el.id}</span>
${el.locked ? '<i class="bi bi-lock-fill" style="font-size:0.45rem;color:var(--accent3);"></i>' : ''}
${el.disabled ? '<i class="bi bi-slash-circle" style="font-size:0.45rem;color:var(--text-muted);"></i>' : ''}
<button class="li-vis" title="${el.visible ? 'Hide' : 'Show'}" style="color:${el.visible ? 'var(--text-primary)' : 'var(--danger)'}"><i class="bi ${el.visible ? 'bi-eye-fill' : 'bi-eye-slash-fill'}"></i></button>
`;
// Collapse toggle
const colBtn = div.querySelector('.li-collapse');
if (colBtn) {
colBtn.onclick = (ev) => {
ev.stopPropagation();
if (isCollapsed) collapsed.delete(el.id); else collapsed.add(el.id);
buildLayerList();
};
}
div.querySelector('.li-name').onclick = () => selectElement(el.id);
div.querySelector('.li-vis').onclick = (ev) => { ev.stopPropagation(); el.visible = !el.visible; renderElement(el); rerenderChildren(el.id); buildLayerList(); };
// Drag & drop reorder
div.addEventListener('dragstart', ev => {
ev.dataTransfer.setData('text/plain', el.id);
ev.dataTransfer.effectAllowed = 'move';
div.style.opacity = '0.4';
});
div.addEventListener('dragend', () => { div.style.opacity = '1'; });
div.addEventListener('dragover', ev => {
ev.preventDefault(); ev.dataTransfer.dropEffect = 'move';
div.style.borderTop = '2px solid var(--accent)';
});
div.addEventListener('dragleave', () => { div.style.borderTop = ''; });
div.addEventListener('drop', ev => {
ev.preventDefault(); div.style.borderTop = '';
const srcId = ev.dataTransfer.getData('text/plain');
if (srcId === el.id) return;
const srcIdx = elements.findIndex(e => e.id === srcId);
const tgtIdx = elements.findIndex(e => e.id === el.id);
if (srcIdx < 0 || tgtIdx < 0) return;
const [moved] = elements.splice(srcIdx, 1);
elements.splice(tgtIdx, 0, moved);
// Update z-indices to match order
elements.forEach((e, i) => { e.zIndex = 10 + i; });
renderAllElements();
buildLayerList();
});
container.appendChild(div);
// Render children if not collapsed
if (hasChildren && !isCollapsed) {
children.forEach(c => renderLayerItem(c, depth + 1));
}
}
const topLevel = [...elements].filter(e => !e.parentId).reverse();
topLevel.forEach(el => renderLayerItem(el, 0));
// Hierarchy tab badge + empty state
const countEl = document.getElementById('hierarchy-count');
if (countEl) countEl.textContent = elements.length;
const listEl = document.getElementById('layer-list');
if (listEl) listEl.classList.toggle('empty', elements.length === 0);
}
// ══════════════════════════════════════
// LEFT PANEL TABS (Tools / Hierarchy)
// ══════════════════════════════════════
function switchPaletteTab(tab) {
document.querySelectorAll('.pal-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
document.querySelectorAll('.pal-tab-panel').forEach(p => p.classList.toggle('active', p.id === 'pal-panel-' + tab));
}
document.querySelectorAll('.pal-tab').forEach(btn => {
btn.addEventListener('click', () => switchPaletteTab(btn.dataset.tab));
});
// ══════════════════════════════════════
// CANVAS INTERACTION
// ══════════════════════════════════════
let dragStartX, dragStartY, dragElStartX, dragElStartY, resizeDir, resizeStartW, resizeStartH;
function onElementPointerDown(e, elId) {
if (previewMode) return;
e.stopPropagation();
const el = elements.find(e => e.id === elId);
if (!el) return;
// Locked elements: can't select, drag, or resize
if (el.locked) {
// If this is a child of a panel, try to select the parent instead
if (el.parentId) {
const parent = elements.find(p => p.id === el.parentId);
if (parent && !parent.locked) { selectElement(parent.id); return; }
}
return;
}
// If clicking a child element and the parent panel is currently selected,
// check if the click target is actually the parent's inner area (not the child's content)
const clickedDom = e.target.closest('.ui-el');
if (clickedDom) {
const clickedId = clickedDom.dataset.id;
if (clickedId !== elId) {
// Click was on a nested element; the event came from an ancestor handler
return;
}
}
// If Alt+Click on a child, select its parent panel instead
if (e.altKey && el.parentId) {
const parent = elements.find(p => p.id === el.parentId);
if (parent) { selectElement(parent.id); return; }
}
selectElement(elId);
// Check if resize handle
const handle = e.target.closest('.resize-handle');
if (handle && !el.locked) {
isResizing = true;
resizeDir = handle.dataset.handle;
const rect = viewport.getBoundingClientRect();
dragStartX = e.clientX;
dragStartY = e.clientY;
resizeStartW = getVal(el,'w');
resizeStartH = getVal(el,'h');
dragElStartX = getVal(el,'x');
dragElStartY = getVal(el,'y');
document.addEventListener('pointermove', onResizeMove);
document.addEventListener('pointerup', onResizeUp);
return;
}
if (el.locked) return;
// Start drag
isDragging = true;
const rect = viewport.getBoundingClientRect();
dragStartX = e.clientX;
dragStartY = e.clientY;
dragElStartX = getVal(el,'x');
dragElStartY = getVal(el,'y');
document.addEventListener('pointermove', onDragMove);
document.addEventListener('pointerup', onDragUp);
}
function onDragMove(e) {
if (!isDragging || !selectedId) return;
const el = elements.find(e => e.id === selectedId);
if (!el) return;
const rect = viewport.getBoundingClientRect();
const dx = ((e.clientX - dragStartX) / rect.width) * 100;
const dy = ((e.clientY - dragStartY) / rect.height) * 100;
let nx = dragElStartX + dx;
let ny = dragElStartY + dy;
if (snapEnabled) { nx = Math.round(nx / snapSize) * snapSize; ny = Math.round(ny / snapSize) * snapSize; }
nx = Math.max(0, Math.min(100, nx));
ny = Math.max(0, Math.min(100, ny));
setVal(el, 'x', nx);
setVal(el, 'y', ny);
}
function onDragUp() {
isDragging = false;
document.removeEventListener('pointermove', onDragMove);
document.removeEventListener('pointerup', onDragUp);
if (selectedId) {
const el = elements.find(e => e.id === selectedId);
if (el) buildPropsPanel(el);
}
}
function onResizeMove(e) {
if (!isResizing || !selectedId) return;
const el = elements.find(e => e.id === selectedId);
if (!el) return;
const rect = viewport.getBoundingClientRect();
const dx = ((e.clientX - dragStartX) / rect.width) * 100;
const dy = ((e.clientY - dragStartY) / rect.height) * 100;
let nw = getVal(el,'w'), nh = getVal(el,'h'), nx = getVal(el,'x'), ny = getVal(el,'y');
if (resizeDir.includes('r')) nw = Math.max(1, resizeStartW + dx);
if (resizeDir.includes('l')) { nw = Math.max(1, resizeStartW - dx); nx = dragElStartX + dx; }
if (resizeDir.includes('b')) nh = Math.max(1, resizeStartH + dy);
if (resizeDir.includes('t')) { nh = Math.max(1, resizeStartH - dy); ny = dragElStartY + dy; }
if (snapEnabled) {
nw = Math.round(nw / snapSize) * snapSize;
nh = Math.round(nh / snapSize) * snapSize;
}
setVal(el,'w',nw); setVal(el,'h',nh);
if (resizeDir.includes('l')) setVal(el,'x',nx);
if (resizeDir.includes('t')) setVal(el,'y',ny);
}
function onResizeUp() {
isResizing = false;
document.removeEventListener('pointermove', onResizeMove);
document.removeEventListener('pointerup', onResizeUp);
if (selectedId) {
const el = elements.find(e => e.id === selectedId);
if (el) buildPropsPanel(el);
}
}
// ── Canvas drop from palette ──
viewport.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
viewport.addEventListener('drop', e => {
e.preventDefault();
if (!dragData || dragData.source !== 'palette') return;
const template = findTemplate(dragData.itemId);
if (!template) return;
const rect = viewport.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
createElement(template, x, y);
showToast(`Added ${template.name}`);
dragData = null;
});
// ── Canvas click to deselect ──
viewport.addEventListener('pointerdown', e => {
if (e.target === viewport) deselectAll();
});
// ══════════════════════════════════════
// VIEWPORT SIZING
// ══════════════════════════════════════
function resizeViewport() {
const size = DEVICE_SIZES[deviceMode];
const area = canvasArea.getBoundingClientRect();
const maxW = area.width - 40;
const maxH = area.height - 40;
const scale = Math.min(maxW / size.w, maxH / size.h, 1.5);
// The canvas is always the design resolution and is *scaled* to fit, so a 110px
// image is always 110 design pixels. Sizing the canvas itself (the old behaviour)
// silently changed every px/rem proportion whenever the window changed, which is
// why exports never matched the design.
viewport.style.width = size.w + 'px';
viewport.style.height = size.h + 'px';
viewport.style.transform = 'scale(' + scale + ')';
viewport.style.transformOrigin = 'center center';
window.__uiDesignScale = scale;
if (bgImage) viewport.style.backgroundImage = `url(${bgImage})`;
else viewport.style.backgroundImage = 'none';
viewport.style.backgroundSize = 'cover';
viewport.style.backgroundPosition = 'center';
}
// ── Device tabs ──
document.querySelectorAll('.device-tab').forEach(btn => {
btn.onclick = () => {
document.querySelectorAll('.device-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
deviceMode = btn.dataset.device;
resizeViewport();
renderAllElements();
// Reapply preview styles if in preview mode
if (previewMode) {
viewport.querySelectorAll('.ui-el').forEach(d => {
d.style.outline = 'none';
const evts = d._elEvents || {};
const hasActions = Object.values(evts).some(a => a && a.length > 0);
d.style.cursor = hasActions ? 'pointer' : 'default';
});
}
// Rebuild props panel if element is selected
if (selectedId) {
const el = elements.find(e => e.id === selectedId);
if (el) buildPropsPanel(el);
}
};
});
window.addEventListener('resize', resizeViewport);
// ══════════════════════════════════════
// TOOLBAR ACTIONS
// ══════════════════════════════════════
// Grid toggle
document.getElementById('btn-grid').onclick = function() {
viewport.classList.toggle('show-grid');
this.classList.toggle('active');
};
document.getElementById('btn-grid').classList.add('active');
// Guides toggle
document.getElementById('btn-guides').onclick = function() {
viewport.classList.toggle('show-guides');
this.classList.toggle('active');
};
document.getElementById('btn-guides').classList.add('active');
// Snap toggle
document.getElementById('btn-snap').onclick = function() {
snapEnabled = !snapEnabled;
this.classList.toggle('active');
showToast(snapEnabled ? 'Snap ON' : 'Snap OFF');
};
document.getElementById('btn-snap').classList.add('active');
// Background image
document.getElementById('btn-bg-image').onclick = () => {
// Create a mini popup for BG image
let overlay = document.getElementById('bg-img-overlay');
if (overlay) { overlay.remove(); return; }
overlay = document.createElement('div');
overlay.id = 'bg-img-overlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:300;display:flex;align-items:center;justify-content:center;';
const box = document.createElement('div');
box.style.cssText = 'background:var(--bg-panel);border:1px solid var(--border-color);border-radius:10px;padding:16px;width:360px;max-width:90vw;';
box.innerHTML = `<h6 style="font-family:'Orbitron',sans-serif;font-size:0.5rem;color:var(--accent);margin:0 0 10px;letter-spacing:1px;">BACKGROUND IMAGE</h6>`;
const urlInp = document.createElement('input');
urlInp.className = 'pp-input'; urlInp.placeholder = 'Paste image URL...';
urlInp.value = bgImage.startsWith('data:') ? '' : (bgImage || '');
urlInp.style.cssText = 'width:100%;margin-bottom:8px;';
box.appendChild(urlInp);
// Preview
const preview = document.createElement('div');
preview.style.cssText = 'width:100%;height:80px;border-radius:6px;border:1px solid var(--border-color);background-size:cover;background-position:center;margin-bottom:8px;';
if (bgImage) preview.style.backgroundImage = `url(${bgImage})`;
box.appendChild(preview);
// Buttons row
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display:flex;gap:6px;';
// Upload button
const upLabel = document.createElement('label');
upLabel.className = 'pp-btn';
upLabel.style.cssText = 'flex:1;display:flex;align-items:center;justify-content:center;gap:4px;background:rgba(167,139,250,0.08);border-color:var(--accent2);color:var(--accent2);cursor:pointer;';
upLabel.innerHTML = '<i class="bi bi-upload"></i> Upload';
const fileInp = document.createElement('input');
fileInp.type = 'file'; fileInp.accept = 'image/*'; fileInp.style.display = 'none';
fileInp.onchange = () => {
const file = fileInp.files[0]; if (!file) return;
if (file.size > 5*1024*1024) { showToast('Max 5MB', 'error'); return; }
const reader = new FileReader();
reader.onload = ev => {
bgImage = ev.target.result;
preview.style.backgroundImage = `url(${bgImage})`;
urlInp.value = '(base64 image)';
resizeViewport();
showToast(`BG image loaded (${(file.size/1024).toFixed(0)}KB)`);
};
reader.readAsDataURL(file);
};
upLabel.appendChild(fileInp);
// Apply URL button
const applyBtn = document.createElement('button');
applyBtn.className = 'pp-btn';
applyBtn.style.cssText = 'flex:1;background:rgba(0,229,160,0.08);border-color:var(--accent);color:var(--accent);';
applyBtn.innerHTML = '<i class="bi bi-check-lg"></i> Apply URL';
applyBtn.onclick = () => { bgImage = urlInp.value === '(base64 image)' ? bgImage : urlInp.value; resizeViewport(); preview.style.backgroundImage = bgImage ? `url(${bgImage})` : ''; };
// Clear
const clrBtn = document.createElement('button');
clrBtn.className = 'pp-btn';
clrBtn.style.cssText = 'background:rgba(255,77,106,0.08);border-color:var(--danger);color:var(--danger);';
clrBtn.innerHTML = '<i class="bi bi-x"></i>';
clrBtn.onclick = () => { bgImage = ''; urlInp.value = ''; preview.style.backgroundImage = ''; resizeViewport(); };
// Close
const closeBtn = document.createElement('button');
closeBtn.className = 'pp-btn';
closeBtn.style.cssText = 'background:rgba(255,255,255,0.04);border-color:var(--border-color);color:var(--text-secondary);';
closeBtn.innerHTML = '<i class="bi bi-x-lg"></i>';
closeBtn.onclick = () => overlay.remove();
btnRow.appendChild(upLabel); btnRow.appendChild(applyBtn); btnRow.appendChild(clrBtn); btnRow.appendChild(closeBtn);
box.appendChild(btnRow);
overlay.appendChild(box);
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
document.body.appendChild(overlay);
};
// Preview mode
document.getElementById('btn-preview').onclick = function() {
previewMode = !previewMode;
this.classList.toggle('active');
document.getElementById('palette-items').style.display = previewMode ? 'none' : '';
// In preview, the Tools tab is unusable — jump to Hierarchy and back
const toolsTab = document.querySelector('.pal-tab[data-tab="tools"]');
if (toolsTab) {
toolsTab.style.opacity = previewMode ? '0.35' : '';
toolsTab.style.pointerEvents = previewMode ? 'none' : '';
}
switchPaletteTab(previewMode ? 'hierarchy' : 'tools');
if (previewMode) {
deselectAll();
// Full re-render with clean DOM first
renderAllElements();
// THEN inject custom code so UI elements exist for top-level statements
customCode = document.getElementById('set-custom-code')?.value || customCode;
if (customCode) {
let scriptEl = document.getElementById('preview-custom-code');
if (scriptEl) scriptEl.remove();
scriptEl = document.createElement('script');
scriptEl.id = 'preview-custom-code';
scriptEl.textContent = customCode + '\nif (typeof onReady === "function") onReady();';
document.body.appendChild(scriptEl);
}
} else {
// Remove injected script
const s = document.getElementById('preview-custom-code');
if (s) s.remove();
renderAllElements();
}
viewport.querySelectorAll('.ui-el').forEach(d => {
d.style.outline = previewMode ? 'none' : '';
// Show pointer on elements with actions in preview
const evts = d._elEvents || {};
const hasActions = Object.values(evts).some(a => a && a.length > 0);
d.style.cursor = previewMode ? (hasActions ? 'pointer' : 'default') : '';
});
showToast(previewMode ? 'Preview Mode — click to test actions' : 'Edit Mode');
};
// UI Name
document.getElementById('ui-name').onchange = function() { uiName = this.value; };
// Props close
document.getElementById('pp-close').onclick = deselectAll;
// ══════════════════════════════════════
// SAVE / LOAD
// ══════════════════════════════════════
document.getElementById('btn-save').onclick = () => {
customCode = document.getElementById('set-custom-code').value || customCode;
if (blocklyWorkspace && blocklyCurrentFunc) saveCurrentBlocklyFunc();
const data = {
formatVersion: 2,
type: 'game-ui',
name: uiName,
bgImage,
customCode,
blocklyFunctions: blocklyFuncData,
customBlocks: customBlocks,
elements: elements.map(el => ({ ...el })),
// Design resolution the layout was built against — runtimes scale to match
scaleMode: projectScaleMode,
designSizes: DEVICE_SIZES,
responsive: {
breakpoints: { mobile: 768, tablet: 1024 }
}
};
// Show save dialog
let ov = document.getElementById('save-dialog-overlay');
if (ov) ov.remove();
ov = document.createElement('div');
ov.id = 'save-dialog-overlay';
ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:500;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(3px);';
const box = document.createElement('div');
box.style.cssText = 'background:var(--bg-panel);border:1px solid var(--border-color);border-radius:12px;padding:0;width:480px;max-width:95vw;overflow:hidden;';
// Header
const hdr = document.createElement('div');
hdr.style.cssText = 'padding:14px 18px;border-bottom:1px solid var(--border-color);display:flex;align-items:center;gap:8px;';
hdr.innerHTML = `<i class="bi bi-download" style="color:var(--accent);font-size:1rem;"></i><span style="font-family:'Orbitron',sans-serif;font-size:0.52rem;color:var(--accent);letter-spacing:1px;flex:1;">SAVE PROJECT</span>`;
const closeBtn = document.createElement('div');
closeBtn.style.cssText = 'width:24px;height:24px;border-radius:4px;background:rgba(255,77,106,0.08);border:1px solid var(--danger);color:var(--danger);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:0.45rem;';
closeBtn.innerHTML = '<i class="bi bi-x-lg"></i>';
closeBtn.onclick = () => ov.remove();
hdr.appendChild(closeBtn);
box.appendChild(hdr);
const body = document.createElement('div');
body.style.cssText = 'padding:18px;display:flex;flex-direction:column;gap:10px;';
// Option 1: Download JSON
const opt1 = document.createElement('div');
opt1.style.cssText = 'padding:14px 16px;background:rgba(0,229,160,0.04);border:1px solid rgba(0,229,160,0.2);border-radius:8px;cursor:pointer;transition:all 0.15s;';
opt1.innerHTML = `<div style="display:flex;align-items:center;gap:10px;">
<i class="bi bi-filetype-json" style="font-size:1.4rem;color:var(--accent);"></i>
<div style="flex:1;">
<div style="font-size:0.52rem;font-weight:600;color:var(--text-primary);margin-bottom:2px;">Download Project JSON</div>
<div style="font-size:0.42rem;color:var(--text-muted);">Save as .ui.json — re-open in UI Builder to continue editing</div>
</div>
<i class="bi bi-chevron-right" style="color:var(--text-muted);"></i>
</div>`;
opt1.onmouseenter = () => opt1.style.borderColor = 'var(--accent)';
opt1.onmouseleave = () => opt1.style.borderColor = 'rgba(0,229,160,0.2)';
opt1.onclick = () => {
const filename = uiName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase() || 'game_ui';
const stamp = fileTimestamp();
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }));
a.download = `${filename}_${stamp}.ui.json`;
a.click();
URL.revokeObjectURL(a.href);
ov.remove();
showToast(`Saved "${uiName}" as ${filename}_${stamp}.ui.json`);
};
body.appendChild(opt1);
// Option 2: Export HTML
const opt2 = document.createElement('div');
opt2.style.cssText = 'padding:14px 16px;background:rgba(167,139,250,0.04);border:1px solid rgba(167,139,250,0.2);border-radius:8px;cursor:pointer;transition:all 0.15s;';
opt2.innerHTML = `<div style="display:flex;align-items:center;gap:10px;">
<i class="bi bi-filetype-html" style="font-size:1.4rem;color:var(--accent2);"></i>
<div style="flex:1;">
<div style="font-size:0.52rem;font-weight:600;color:var(--text-primary);margin-bottom:2px;">Export Standalone HTML</div>
<div style="font-size:0.42rem;color:var(--text-muted);">Complete page with all UI, CSS, JS — ready to use anywhere</div>
</div>
<i class="bi bi-chevron-right" style="color:var(--text-muted);"></i>
</div>`;
opt2.onmouseenter = () => opt2.style.borderColor = 'var(--accent2)';
opt2.onmouseleave = () => opt2.style.borderColor = 'rgba(167,139,250,0.2)';
opt2.onclick = () => {
ov.remove();
showExportHTMLDialog(data);
};
body.appendChild(opt2);
box.appendChild(body);
ov.appendChild(box);
ov.addEventListener('click', e => { if (e.target === ov) ov.remove(); });
document.body.appendChild(ov);
};
// ── Export HTML Dialog ──
function showExportHTMLDialog(data) {
let ov = document.createElement('div');
ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:500;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(3px);';
const box = document.createElement('div');
box.style.cssText = 'background:var(--bg-panel);border:1px solid var(--border-color);border-radius:12px;padding:0;width:500px;max-width:95vw;overflow:hidden;';
const hdr = document.createElement('div');
hdr.style.cssText = 'padding:14px 18px;border-bottom:1px solid var(--border-color);display:flex;align-items:center;gap:8px;';
hdr.innerHTML = `<i class="bi bi-filetype-html" style="color:var(--accent2);font-size:1rem;"></i><span style="font-family:'Orbitron',sans-serif;font-size:0.52rem;color:var(--accent2);letter-spacing:1px;flex:1;">EXPORT HTML</span>`;
const closeBtn = document.createElement('div');
closeBtn.style.cssText = 'width:24px;height:24px;border-radius:4px;background:rgba(255,77,106,0.08);border:1px solid var(--danger);color:var(--danger);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:0.45rem;';
closeBtn.innerHTML = '<i class="bi bi-x-lg"></i>';
closeBtn.onclick = () => ov.remove();
hdr.appendChild(closeBtn);
box.appendChild(hdr);
const body = document.createElement('div');
body.style.cssText = 'padding:18px;';
function lbl(t) { const s = document.createElement('div'); s.style.cssText = 'font-size:0.42rem;color:var(--text-muted);margin-bottom:4px;margin-top:10px;'; s.textContent = t; return s; }
// Page Title
body.appendChild(lbl('Page Title'));
const titleInp = document.createElement('input'); titleInp.className = 'pp-input';
titleInp.value = data.name || 'My UI'; titleInp.style.width = '100%';
body.appendChild(titleInp);
// Background
body.appendChild(lbl('Page Background'));
const bgInp = document.createElement('input'); bgInp.className = 'pp-input';
bgInp.value = '#000000'; bgInp.placeholder = '#000000 or gradient CSS'; bgInp.style.width = '100%';
body.appendChild(bgInp);
// Canvas
body.appendChild(lbl('3D Canvas'));
const canvasChk = document.createElement('div');
canvasChk.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:4px;';
const chk = document.createElement('input'); chk.type = 'checkbox'; chk.id = 'exp-canvas-chk'; chk.checked = false;
const chkLbl = document.createElement('label'); chkLbl.htmlFor = 'exp-canvas-chk'; chkLbl.style.cssText = 'font-size:0.45rem;color:var(--text-secondary);cursor:pointer;'; chkLbl.textContent = 'Include canvas container for 3D engine (Three.js / Babylon.js)';
canvasChk.appendChild(chk); canvasChk.appendChild(chkLbl);
body.appendChild(canvasChk);
// Scaling mode — how the design maps onto the visitor's screen
body.appendChild(lbl('Screen Fitting'));
const scaleSel = document.createElement('select');
scaleSel.className = 'pp-select'; scaleSel.style.width = '100%';
[['fit','Keep design proportions (scale to fit) — matches the builder'],
['fill','Fill the screen (scale to cover, edges may crop)'],
['stretch','Stretch to window (old behaviour — px sizes do not scale)']].forEach(([v,t]) => {
const o = document.createElement('option');
o.value = v; o.textContent = t;
if (v === projectScaleMode) o.selected = true;
scaleSel.appendChild(o);
});
scaleSel.onchange = () => { projectScaleMode = scaleSel.value; };
body.appendChild(scaleSel);
const scaleNote = document.createElement('div');
scaleNote.style.cssText = 'font-size:0.42rem;color:var(--text-muted);padding:4px 0 0;line-height:1.35;';
scaleNote.textContent = 'The canvas designs at ' + DEVICE_SIZES.desktop.w + '×' + DEVICE_SIZES.desktop.h +
' (desktop). "Scale to fit" reproduces it exactly on any screen; the other modes let font and image sizes drift.';
body.appendChild(scaleNote);
// Game level JSON path
body.appendChild(lbl('Game Level JSON (optional — loads 3D scene at runtime)'));
const glInp = document.createElement('input'); glInp.className = 'pp-input';
glInp.value = ''; glInp.placeholder = './levels/level1.json'; glInp.style.width = '100%';
body.appendChild(glInp);
// Base64 embed option
body.appendChild(lbl('Asset Embedding'));
const b64Chk = document.createElement('div');
b64Chk.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:4px;';
const chk2 = document.createElement('input'); chk2.type = 'checkbox'; chk2.id = 'exp-b64-chk'; chk2.checked = false;
const chk2Lbl = document.createElement('label'); chk2Lbl.htmlFor = 'exp-b64-chk'; chk2Lbl.style.cssText = 'font-size:0.45rem;color:var(--text-secondary);cursor:pointer;'; chk2Lbl.textContent = 'Embed all images as base64 (fully self-contained, larger file size)';
b64Chk.appendChild(chk2); b64Chk.appendChild(chk2Lbl);
body.appendChild(b64Chk);
const vidChk = document.createElement('div');
vidChk.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:4px;';
const chk3 = document.createElement('input'); chk3.type = 'checkbox'; chk3.id = 'exp-vid-chk';
const chromaEls = (data.elements || []).filter(e => e.type === 'video' && e.chromaKey && e.src && !String(e.src).startsWith('data:'));
chk3.checked = chromaEls.length > 0;
const chk3Lbl = document.createElement('label'); chk3Lbl.htmlFor = 'exp-vid-chk';
chk3Lbl.style.cssText = 'font-size:0.45rem;color:var(--text-secondary);cursor:pointer;';
chk3Lbl.textContent = 'Embed chroma-keyed videos as base64 (required for transparency)';
vidChk.appendChild(chk3); vidChk.appendChild(chk3Lbl);
body.appendChild(vidChk);
// Collect every 3D model the project references (elements + carousel cards)
const modelRefs = [];
(data.elements || []).forEach(e => {
if (e.type === 'model3d' && e.src && !String(e.src).startsWith('data:')) modelRefs.push({ get: () => e.src, set: v => { e.src = v; } });
(e.carouselItems || []).forEach(it => {
if (it.model && !String(it.model).startsWith('data:')) modelRefs.push({ get: () => it.model, set: v => { it.model = v; } });
});
(e.heroSlides || []).forEach(sl => {
if (sl.model && !String(sl.model).startsWith('data:')) modelRefs.push({ get: () => sl.model, set: v => { sl.model = v; } });
});
});
const glbChk = document.createElement('div');
glbChk.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:4px;';
const chk4 = document.createElement('input'); chk4.type = 'checkbox'; chk4.id = 'exp-glb-chk';
chk4.checked = modelRefs.length > 0;
const chk4Lbl = document.createElement('label'); chk4Lbl.htmlFor = 'exp-glb-chk';
chk4Lbl.style.cssText = 'font-size:0.45rem;color:var(--text-secondary);cursor:pointer;';
chk4Lbl.textContent = 'Embed 3D models (.glb) as base64 — needed to open the file directly';
glbChk.appendChild(chk4); glbChk.appendChild(chk4Lbl);
body.appendChild(glbChk);
if (modelRefs.length) {
const gw = document.createElement('div');
gw.style.cssText = 'font-size:0.4rem;color:var(--accent3);line-height:1.35;padding:2px 0 0;';
gw.textContent = '⚠ ' + modelRefs.length + ' 3D model' + (modelRefs.length === 1 ? '' : 's') +
' referenced. A browser refuses to fetch .glb files from a file:// page, so an export opened straight from disk ' +
'shows "could not load". Embedding them (or serving the folder over http) fixes it.';
body.appendChild(gw);
}
if (chromaEls.length) {
const warn = document.createElement('div');
warn.style.cssText = 'font-size:0.4rem;color:var(--accent3);line-height:1.35;padding:2px 0 0;';
warn.textContent = '⚠ ' + chromaEls.length + ' chroma-keyed video' + (chromaEls.length === 1 ? '' : 's') +
' found. Keying reads the video frames, which a browser refuses to do for a file:// or cross-origin source — ' +
'so an exported page opened straight from disk shows the raw video. Embedding it keeps the transparency working everywhere.';
body.appendChild(warn);
}
// Status text
const statusEl = document.createElement('div');
statusEl.style.cssText = 'font-size:0.4rem;color:var(--text-muted);min-height:14px;margin-top:4px;';
body.appendChild(statusEl);
// Export button
const expBtn = document.createElement('button'); expBtn.className = 'pp-btn';
expBtn.style.cssText = 'width:100%;margin-top:12px;padding:10px;background:rgba(167,139,250,0.08);border-color:var(--accent2);color:var(--accent2);font-size:0.5rem;';
expBtn.innerHTML = '<i class="bi bi-download"></i> Export HTML';
expBtn.onclick = async () => {
const embedB64 = chk2.checked;
const embedVideos = chk3.checked;
let exportData = JSON.parse(JSON.stringify(data));
if (chk4.checked && modelRefs.length) {
expBtn.disabled = true;
// exportData is a deep copy, so re-collect against it
const refs = [];
(exportData.elements || []).forEach(e => {
if (e.type === 'model3d' && e.src && !String(e.src).startsWith('data:')) refs.push({ get: () => e.src, set: v => { e.src = v; } });
(e.carouselItems || []).forEach(it => {
if (it.model && !String(it.model).startsWith('data:')) refs.push({ get: () => it.model, set: v => { it.model = v; } });
});
(e.heroSlides || []).forEach(sl => {
if (sl.model && !String(sl.model).startsWith('data:')) refs.push({ get: () => sl.model, set: v => { sl.model = v; } });
});
});
const cache = {};
for (const ref of refs) {
const url = ref.get();
try {
if (cache[url]) { ref.set(cache[url]); continue; }
statusEl.textContent = 'Embedding model: ' + url + '…';
const resp = await fetch(url);
if (!resp.ok) throw new Error(resp.statusText);
const blob = await resp.blob();
const dataUrl = await new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result);
r.onerror = rej;
r.readAsDataURL(blob);
});
cache[url] = dataUrl;
ref.set(dataUrl);
statusEl.textContent = 'Embedded ' + (blob.size / 1048576).toFixed(1) + 'MB model';
} catch(err) {
statusEl.textContent = 'Could not embed ' + url + ' (' + err.message + ')';
statusEl.style.color = '#ff6b6b';
}
}
expBtn.disabled = false;
}
if (embedVideos) {
expBtn.disabled = true;
const vids = (exportData.elements || []).filter(e => e.type === 'video' && e.chromaKey && e.src && !String(e.src).startsWith('data:'));
for (const ve of vids) {
try {
statusEl.textContent = 'Embedding video: ' + ve.src + '…';
const resp = await fetch(ve.src);
if (!resp.ok) throw new Error(resp.statusText);
const blob = await resp.blob();
ve.src = await new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result);
r.onerror = rej;
r.readAsDataURL(blob);
});
statusEl.textContent = 'Embedded ' + (blob.size / 1048576).toFixed(1) + 'MB video';
} catch(err) {
statusEl.textContent = 'Could not embed ' + ve.src + ' (' + err.message + ') — chroma key may not work from file://';
statusEl.style.color = '#ff6b6b';
}
}
expBtn.disabled = false;
}
if (embedB64) {
expBtn.disabled = true;
expBtn.innerHTML = '<i class="bi bi-hourglass-split"></i> Converting images...';
statusEl.textContent = 'Converting images to base64...';
try {
exportData = await embedAllImagesAsBase64(exportData, (msg) => { statusEl.textContent = msg; });
statusEl.textContent = 'Conversion complete!';
} catch(e) {
console.warn('Base64 embed error:', e);
statusEl.textContent = 'Some images could not be converted — exported with original URLs';
}
expBtn.disabled = false;
expBtn.innerHTML = '<i class="bi bi-download"></i> Export HTML';
}
exportData.scaleMode = projectScaleMode;
exportData.designSizes = DEVICE_SIZES;
const html = generateStandaloneHTML(exportData, {
title: titleInp.value,
background: bgInp.value,
includeCanvas: chk.checked,
gameLevelJson: glInp.value.trim()
});
const filename = (data.name || 'export').replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
a.download = `${filename}_${fileTimestamp()}.html`;
a.click();
URL.revokeObjectURL(a.href);
ov.remove();
showToast(`Exported "${titleInp.value}" as HTML`);
};
body.appendChild(expBtn);
box.appendChild(body);
ov.appendChild(box);
ov.addEventListener('click', e => { if (e.target === ov) ov.remove(); });
document.body.appendChild(ov);
}
// ══════════════════════════════════════
// BASE64 IMAGE EMBEDDER
// ══════════════════════════════════════
async function embedAllImagesAsBase64(data, onStatus) {
const cache = {}; // url → dataURI
async function toBase64(url) {
if (!url || url.startsWith('data:')) return url; // already base64
if (cache[url]) return cache[url];
try {
const resp = await fetch(url);
if (!resp.ok) throw new Error(resp.status);
const blob = await resp.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => { cache[url] = reader.result; resolve(reader.result); };
reader.onerror = reject;
reader.readAsDataURL(blob);
});
} catch(e) {
console.warn('Could not convert to base64:', url, e);
return url; // return original on failure
}
}
// Collect all image URLs
const urls = new Set();
if (data.bgImage) urls.add(data.bgImage);
(data.elements || []).forEach(el => {
if (el.src && el.type !== 'video') urls.add(el.src); // video files are skipped (too heavy) — keep as paths
if (el.hoverSrc) urls.add(el.hoverSrc);
if (el.activeSrc) urls.add(el.activeSrc);
if (el.videoPoster) urls.add(el.videoPoster);
(el.carouselItems || []).forEach(it => { if (it.image) urls.add(it.image); });
(el.heroSlides || []).forEach(sl => { if (sl.image) urls.add(sl.image); });
});
// Filter out already-base64 ones
const toConvert = [...urls].filter(u => u && !u.startsWith('data:'));
if (toConvert.length === 0) return data;
// Convert all in parallel
let done = 0;
await Promise.all(toConvert.map(async (url) => {
await toBase64(url);
done++;
if (onStatus) onStatus(`Converting images... ${done}/${toConvert.length}`);
}));
// Replace URLs in data
if (data.bgImage && cache[data.bgImage]) data.bgImage = cache[data.bgImage];
(data.elements || []).forEach(el => {
if (el.src && cache[el.src]) el.src = cache[el.src];
if (el.hoverSrc && cache[el.hoverSrc]) el.hoverSrc = cache[el.hoverSrc];
if (el.activeSrc && cache[el.activeSrc]) el.activeSrc = cache[el.activeSrc];
if (el.videoPoster && cache[el.videoPoster]) el.videoPoster = cache[el.videoPoster];
(el.carouselItems || []).forEach(it => { if (it.image && cache[it.image]) it.image = cache[it.image]; });
(el.heroSlides || []).forEach(sl => { if (sl.image && cache[sl.image]) sl.image = cache[sl.image]; });
});
return data;
}
// ══════════════════════════════════════
// STANDALONE HTML GENERATOR
// ══════════════════════════════════════
function generateStandaloneHTML(data, opts) {
const esc = s => s.replace(/</g,'\\x3c').replace(/>/g,'\\x3e');
const elems = data.elements || [];
const customJS = data.customCode || '';
const hasCarousel = elems.some(e => e.type === 'carousel');
// Collect all unique font families
const fonts = new Set(['Rajdhani','Orbitron']);
elems.forEach(el => {
if (el.fontFamily) {
const match = el.fontFamily.match(/['"]([^'"]+)['"]/);
if (match) fonts.add(match[1]);
}
});
const fontImport = [...fonts].map(f => `family=${f.replace(/ /g,'+')}`).join('&');
// Generate override CSS
function genOverrideCss(ov, elId, fontUnit) {
let css = '';
if (!ov || typeof ov !== 'object') return css;
if (ov.visible === false) css += ` #gui-${elId} { display:none !important; }\n`;
if (ov.x !== undefined) css += ` #gui-${elId} { left:${ov.x}% !important; }\n`;
if (ov.y !== undefined) css += ` #gui-${elId} { top:${ov.y}% !important; }\n`;
if (ov.w !== undefined) css += ` #gui-${elId} { width:${ov.w > 0 ? ov.w + '%' : 'auto'} !important; }\n`;
if (ov.h !== undefined) css += ` #gui-${elId} { height:${ov.h > 0 ? ov.h + '%' : 'auto'} !important; }\n`;
if (ov.anchor) css += ` #gui-${elId} { transform:${uiAnchorTransform(ov.anchor)} !important; }\n`;
['fontSize','fontWeight','padding','background','borderRadius','border','color','textAlign','boxShadow','letterSpacing'].forEach(prop => {
if (ov[prop] !== undefined) {
const val = prop === 'fontSize' ? ov[prop] + (ov.fontUnit || fontUnit || 'rem') : ov[prop];
css += ` #gui-${elId} .gui-inner { ${prop.replace(/[A-Z]/g,c=>'-'+c.toLowerCase())}:${val} !important; }\n`;
}
});
if (ov.opacity !== undefined) css += ` #gui-${elId} { opacity:${ov.opacity} !important; }\n`;
return css;
}
const deviceQueries = {
tablet: '@media (max-width:1024px) and (min-width:481px)',
phone: '@media (max-width:480px) and (orientation:portrait)',
'phone-land': '@media (max-width:768px) and (orientation:landscape)',
};
const showOnQueries = {
desktop: '@media (min-width:1025px)',
tablet: '@media (max-width:1024px) and (min-width:481px)',
phone: '@media (max-width:480px) and (orientation:portrait)',
'phone-land': '@media (max-width:768px) and (orientation:landscape)',
};
let responsiveCss = '';
elems.forEach(el => {
if (el.overrides) {
for (const [dev, query] of Object.entries(deviceQueries)) {
const css = genOverrideCss(el.overrides[dev], el.id, el.fontUnit);
if (css) responsiveCss += `${query} {\n${css}}\n`;
}
}
if (el.mobileHidden) responsiveCss += `@media (max-width:768px) { #gui-${el.id} { display:none !important; } }\n`;
if (el.showOn && el.showOn.length > 0) {
for (const [dev, query] of Object.entries(showOnQueries)) {
if (!el.showOn.includes(dev)) {
responsiveCss += `${query} { #gui-${el.id} { display:none !important; } }\n`;
}
}
}
});
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>${opts.title || 'My UI'}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?${fontImport}&display=swap" rel="stylesheet">
${hasCarousel ? `<link href="https://cdnjs.cloudflare.com/ajax/libs/OwlCarousel2/2.3.4/assets/owl.carousel.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/OwlCarousel2/2.3.4/assets/owl.theme.default.min.css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"><` + `/script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/OwlCarousel2/2.3.4/owl.carousel.min.js"><` + `/script>` : ''}
<style>
/* ══════════════════════════════════════
CONFIG — Edit these to customize
══════════════════════════════════════ */
:root {
--page-bg: ${opts.background || '#000000'};
--game-level-json: '${opts.gameLevelJson || ''}'; /* Path to game level JSON (for reference) */
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
overflow:hidden;
background: var(--page-bg);
font-family:'Rajdhani',sans-serif;
color:#e0e8f0;
width:100vw; height:100vh;
position:relative;
}
${opts.includeCanvas ? `
/* ── 3D Canvas Container ── */
#canvas-container {
position:fixed; inset:0;
z-index:1;
}
#canvas-container canvas {
width:100%; height:100%; display:block;
}
` : ''}
/* ── UI Overlay ── */
#game-ui-overlay {
position:fixed; top:0; left:0; width:100%; height:100%;
pointer-events:none; z-index:100; overflow:hidden;
}
.gui-el {
position:absolute; pointer-events:auto;
transition: opacity 0.2s;
}
.gui-el.gui-hidden { display:none; }
.gui-progressbar-track {
width:100%; overflow:hidden; position:relative;
}
.gui-progressbar-fill {
height:100%; transition:width 0.3s ease;
}
.gui-progressbar-label {
position:absolute; left:6px; top:50%; transform:translateY(-50%);
font-family:'Rajdhani',sans-serif; font-size:0.55rem; font-weight:700;
color:rgba(255,255,255,0.8); text-shadow:0 1px 2px rgba(0,0,0,0.5); z-index:1;
}
@media (max-width:768px) {
.gui-el.gui-mobile-hidden { display:none !important; }
}
${responsiveCss}
</style>
</head>
<body>
${opts.includeCanvas ? `
<!-- ═══ 3D Canvas ═══ -->
<div id="canvas-container">
<!-- Your 3D engine renders here (Three.js / Babylon.js / PlayCanvas) -->
</div>
` : ''}
<!-- ═══ UI Overlay ═══ -->
<div id="game-ui-overlay"></div>
<script>
// ══════════════════════════════════════
// CONFIG
// ══════════════════════════════════════
const CONFIG = {
gameLevelJson: '${opts.gameLevelJson || ''}', // Set path to load a 3D game level
includeCanvas: ${opts.includeCanvas ? 'true' : 'false'},
};
// ══════════════════════════════════════
// UI DATA (embedded from UI Builder)
// ══════════════════════════════════════
const UI_DATA = ${JSON.stringify(data, null, 2)};
// ══════════════════════════════════════
// GAME API BRIDGE (for UI ↔ Game communication)
// ══════════════════════════════════════
const gameUIState = {};
const gameUIElements = {};
let gameUIData = null;
window._gameGlobals = window._gameGlobals || {};
const GameAPI = {
globals: window._gameGlobals,
setGlobal(name, value){ window._gameGlobals[name] = value; },
getGlobal(name){ return window._gameGlobals[name]; },
setUIValue(key, val) {
gameUIState[key] = val;
updateGameUI();
},
getUIValue(key) { return gameUIState[key]; },
setUIVisible(elId, visible) {
const entry = gameUIElements[elId];
if (!entry) return;
if (visible) { entry.dom.classList.remove('gui-hidden'); entry.data.visible = true; }
else { entry.dom.classList.add('gui-hidden'); entry.data.visible = false; }
},
setUIContent(elId, text) {
const entry = gameUIElements[elId];
if (!entry) return;
entry.data.content = text;
const inner = entry.dom.querySelector('.gui-inner');
if (inner) inner.textContent = resolveBindingText(text, entry.data.binding);
},
setUIStyle(elId, prop, val) {
const entry = gameUIElements[elId];
if (!entry) return;
const inner = entry.dom.querySelector('.gui-inner');
if (inner) inner.style[prop] = val;
},
// ── Video control ──
getUIVideo(elId) { const e = gameUIElements[elId]; return e ? e.dom.querySelector('video') : null; },
playVideo(elId) { const v = this.getUIVideo(elId); if (v) v.play().catch(function(){}); },
pauseVideo(elId) { const v = this.getUIVideo(elId); if (v) v.pause(); },
toggleVideo(elId) { const v = this.getUIVideo(elId); if (v) { v.paused ? v.play().catch(function(){}) : v.pause(); } },
stopVideo(elId) { const v = this.getUIVideo(elId); if (v) { v.pause(); try { v.currentTime = 0; } catch(e) {} } },
restartVideo(elId) { const v = this.getUIVideo(elId); if (v) { try { v.currentTime = 0; } catch(e) {} v.play().catch(function(){}); } },
setVideoSrc(elId, src) { const v = this.getUIVideo(elId); if (v) { v.src = src; v.load(); } },
// ── Carousel control ──
carouselNext(elId) { const e = gameUIElements[elId]; if (e) controlUICarousel(e.dom, 'next'); },
carouselPrev(elId) { const e = gameUIElements[elId]; if (e) controlUICarousel(e.dom, 'prev'); },
carouselGoTo(elId, i) { const e = gameUIElements[elId]; if (e) controlUICarousel(e.dom, 'goTo', i); },
carouselPlay(elId) { const e = gameUIElements[elId]; if (e) controlUICarousel(e.dom, 'play'); },
carouselStop(elId) { const e = gameUIElements[elId]; if (e) controlUICarousel(e.dom, 'stop'); },
carouselRefresh(elId) { const e = gameUIElements[elId]; if (e) controlUICarousel(e.dom, 'refresh'); },
// ── Hero section ──
heroNext(elId) { const e = gameUIElements[elId]; if (e) controlUIHero(e.dom, 'next'); },
heroPrev(elId) { const e = gameUIElements[elId]; if (e) controlUIHero(e.dom, 'prev'); },
heroGoTo(elId, i) { const e = gameUIElements[elId]; if (e) controlUIHero(e.dom, 'goTo', i); },
heroPlay(elId) { const e = gameUIElements[elId]; if (e) controlUIHero(e.dom, 'play'); },
heroStop(elId) { const e = gameUIElements[elId]; if (e) controlUIHero(e.dom, 'stop'); },
// ── Embedded pages ──
reloadFrame(elId) { const e = gameUIElements[elId]; if (e) controlUIFrame(e.dom, 'reload'); },
setFrameSrc(elId, url) { const e = gameUIElements[elId]; if (e) controlUIFrame(e.dom, 'setSrc', url); },
getFrame(elId) { const e = gameUIElements[elId]; return e ? e.dom.querySelector('iframe') : null; },
// ── 3D models ──
spinModel(elId, on) { const e = gameUIElements[elId]; if (e) controlUIModel(e.dom, on === false ? 'stop' : 'spin'); },
resetModel(elId) { const e = gameUIElements[elId]; if (e) controlUIModel(e.dom, 'reset'); },
setModelSrc(elId, url) { const e = gameUIElements[elId]; if (e) controlUIModel(e.dom, 'setModel', url); },
getModelApi(elId) {
const e = gameUIElements[elId];
const host = e ? e.dom.querySelector('.ui-model') : null;
return host ? host._modelApi : null;
},
setHeroSlides(elId, slides) {
const e = gameUIElements[elId];
if (!e) return;
e.data.heroSlides = slides || [];
const inner = e.dom.querySelector('.gui-inner');
if (inner) renderUIHero(inner, e.data, true);
},
// ── Animation sequence ──
playAnimation(elId, stage) {
const e = gameUIElements[elId];
if (!e) return;
const inner = e.dom.querySelector('.gui-inner');
if (inner) runUIAnimSequence(inner, e.data, stage || 'all');
},
stopAnimation(elId) { this.playAnimation(elId, 'stop'); },
setCarouselItems(elId, items) {
const e = gameUIElements[elId];
if (!e) return;
e.data.carouselItems = items || [];
const inner = e.dom.querySelector('.gui-inner');
if (inner) renderUICarousel(inner, e.data, true);
},
playAudio(url) {
try { new Audio(url).play().catch(e => console.warn(e)); } catch(e) {}
},
showToast(msg, dur) {
const t = document.createElement('div');
t.style.cssText = 'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#fff;padding:8px 20px;border-radius:8px;font-size:0.8rem;z-index:9999;pointer-events:none;transition:opacity 0.3s;';
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 300); }, dur || 2500);
},
// Keyboard simulation for mobile controls
pressKey(k) { if (window.playerKeys) window.playerKeys[k] = true; },
releaseKey(k) { if (window.playerKeys) window.playerKeys[k] = false; },
simulateKey(k, dur) { GameAPI.pressKey(k); setTimeout(() => GameAPI.releaseKey(k), dur || 100); },
releaseAllKeys() { if (window.playerKeys) Object.keys(window.playerKeys).forEach(k => window.playerKeys[k] = false); },
};
window.GameAPI = GameAPI;
window.playerKeys = {
_globals: {},
setGlobal(name, value) { this._globals[name] = value; },
getGlobal(name) { return this._globals[name]; }, w:false, a:false, s:false, d:false, shift:false };
// ══════════════════════════════════════
// CUSTOM CODE (from UI Builder Code tab)
// ══════════════════════════════════════
${customJS}
// ══════════════════════════════════════
// UI RENDERER
// ══════════════════════════════════════
function resolveBindingText(content, binding) {
if (!content) return '';
return content.replace(/\\{(\\w+)\\}/g, (match, key) => {
return gameUIState[key] !== undefined ? gameUIState[key] : match;
});
}
/* ── Multi-stage element animation (entrance → attention → exit) ── */
function uiAnimHasSequence(el) {
return !!(el.animEnterClass || el.animAttnClass || el.animExitClass || el.animateClass);
}
function clearUIAnim(inner) {
if (!inner) return;
if (inner._animTimers) inner._animTimers.forEach(function(t) { window.clearTimeout(t); });
inner._animTimers = [];
Array.prototype.slice.call(inner.classList).forEach(function(c) {
if (c.indexOf('animate__') === 0) inner.classList.remove(c);
});
inner.style.removeProperty('--animate-duration');
inner.style.removeProperty('animation-delay');
inner.style.removeProperty('animation-duration');
inner.style.removeProperty('animation-iteration-count');
inner.style.removeProperty('opacity');
inner.style.removeProperty('pointer-events');
}
/** Plays one animate.css class. \`done\` is not called for infinite repeats. */
function playUIAnimStage(inner, cls, durationMs, repeat, done) {
if (!inner) return;
if (!cls) { if (done) done(); return; }
Array.prototype.slice.call(inner.classList).forEach(function(c) {
if (c.indexOf('animate__') === 0) inner.classList.remove(c);
});
void inner.offsetWidth; // reflow so the animation can restart
var dur = parseInt(durationMs);
if (!dur || dur < 0) dur = 800;
var infinite = (String(repeat) === 'infinite');
var rep = infinite ? 'infinite' : (parseInt(repeat) || 1);
inner.style.setProperty('--animate-duration', (dur / 1000) + 's');
inner.style.animationDuration = (dur / 1000) + 's';
inner.style.animationIterationCount = rep;
inner.classList.add('animate__animated', cls.indexOf('animate__') === 0 ? cls : 'animate__' + cls);
if (infinite || !done) return;
var t = window.setTimeout(done, dur * rep + 30);
(inner._animTimers = inner._animTimers || []).push(t);
}
/**
* Runs the element's animation sequence.
* stage: 'all' (default) | 'entrance' | 'attention' | 'exit' | 'stop'
*/
function runUIAnimSequence(inner, el, stage) {
if (!inner || !el) return;
stage = stage || 'all';
clearUIAnim(inner);
if (stage === 'stop') return;
inner._animTimers = inner._animTimers || [];
// Legacy single-animation projects behave like an entrance
var enterCls = el.animEnterClass || (!el.animAttnClass && !el.animExitClass ? el.animateClass : '') || '';
var enterDur = el.animEnterDuration !== undefined && el.animEnterDuration !== ''
? el.animEnterDuration
: (parseFloat(el.animateDuration) ? parseFloat(el.animateDuration) * 1000 : 800);
var enterDelay = el.animEnterDelay !== undefined && el.animEnterDelay !== ''
? el.animEnterDelay
: (parseFloat(el.animateDelay) ? parseFloat(el.animateDelay) * 1000 : 0);
var enterRep = el.animEnterRepeat || (el.animateRepeat && !el.animEnterClass ? el.animateRepeat : 1);
function wait(ms, fn) {
var t = window.setTimeout(fn, Math.max(0, parseInt(ms) || 0));
inner._animTimers.push(t);
return t;
}
function doExit() {
if (!el.animExitClass) return;
wait(el.animExitDelay, function() {
playUIAnimStage(inner, el.animExitClass, el.animExitDuration, el.animExitRepeat || 1, function() {
if (el.animExitHide !== false) {
inner.style.opacity = '0';
inner.style.pointerEvents = 'none';
}
});
});
}
function doAttention(next) {
if (!el.animAttnClass) { if (next) next(); return; }
wait(el.animAttnDelay, function() {
var infinite = String(el.animAttnRepeat) === 'infinite';
playUIAnimStage(inner, el.animAttnClass, el.animAttnDuration, el.animAttnRepeat || 1, infinite ? null : next);
if (infinite && next) next(); // exit is timed from when the loop starts
});
}
// While an entrance is waiting out its delay, keep the element invisible —
// otherwise it pops in first and only then animates.
function armEntrance(then) {
var d = Math.max(0, parseInt(enterDelay) || 0);
if (enterCls && d > 0) inner.style.opacity = '0';
wait(d, function() {
inner.style.removeProperty('opacity');
then();
});
}
if (stage === 'entrance') { armEntrance(function() { playUIAnimStage(inner, enterCls, enterDur, enterRep, null); }); return; }
if (stage === 'attention') { doAttention(null); return; }
if (stage === 'exit') { doExit(); return; }
// Full sequence
if (enterCls) {
armEntrance(function() {
playUIAnimStage(inner, enterCls, enterDur, enterRep, function() { doAttention(doExit); });
});
} else {
doAttention(doExit);
}
}
/* ── Carousel runtime (Owl Carousel 2) ── */
function escAttr(s) { return String(s == null ? '' : s).replace(/"/g, '"'); }
/** Overlay image: an absolutely placed box (margin + padding) holding a sized image */
function carFgHTML(el, item, boxStyle, isCover) {
var pad = item.fgPadding !== undefined && item.fgPadding !== '' ? item.fgPadding : el.carFgPadding;
var w = item.fgWidth !== undefined && item.fgWidth !== '' ? item.fgWidth : el.carFgWidth;
var hgt = item.fgHeight !== undefined && item.fgHeight !== '' ? item.fgHeight : el.carFgHeight;
var fit = item.fgFit || el.carFgFit || 'contain';
if (fit === 'stretch') fit = 'fill';
var imgStyle = 'width:' + cssLen(w, '100%') + ';height:' + cssLen(hgt, '100%') +
';object-fit:' + fit + ';';
return '<div class="car-fg-box' + (isCover ? ' car-fg-cover' : '') + '" style="' + boxStyle +
'padding:' + cssLen(pad, '0px') + ';">' +
'<img class="car-fg" src="' + escAttr(item.foreground) + '" style="' + imgStyle + '" draggable="false"></div>';
}
function carouselItemInnerHTML(el, item) {
if (el.carItemTemplate) {
var out = el.carItemTemplate
.replace(/\\{image\\}/g, item.image || '')
.replace(/\\{title\\}/g, item.title || '')
.replace(/\\{text\\}/g, item.text || '')
.replace(/\\{link\\}/g, item.link || '');
// Any custom field can be used as {fieldName}
if (item.fields) {
Object.keys(item.fields).forEach(function(k) {
out = out.split('{' + k + '}').join(item.fields[k] == null ? '' : item.fields[k]);
});
}
return out;
}
var h = '';
var cover = el.carImgMode === 'cover';
// What sits on top of the card image: a 3D model, another image, or nothing
var layer = item.layer || (item.model ? 'model' : (item.foreground ? 'image' : 'none'));
var hasModel = layer === 'model' && item.model;
var hasFg = layer === 'image' && item.foreground;
var fgInset = item.fgInset !== undefined && item.fgInset !== '' ? item.fgInset : el.carFgInset;
var fgStyle = 'inset:' + cssLen(fgInset, '0px') + ';';
if (cover) {
// The image fills the whole card and everything else stacks on top of it
if (el.carShowImage !== false && item.image) {
h += '<img class="car-bgimg" src="' + escAttr(item.image) + '" alt="' + escAttr(item.title) + '" draggable="false">';
}
if (hasModel) h += '<div class="car-model car-model-cover" data-model="' + escAttr(item.model) + '"></div>';
else if (hasFg) h += carFgHTML(el, item, fgStyle, true);
} else if (hasModel || hasFg) {
// Background image keeps its normal size; the model / overlay image is layered over it,
// so a card with either is exactly the same size as one without.
h += '<div class="car-media">';
if (el.carShowImage !== false && item.image) {
h += '<img class="car-img" src="' + escAttr(item.image) + '" alt="' + escAttr(item.title) + '" draggable="false">';
}
if (hasModel) h += '<div class="car-model" data-model="' + escAttr(item.model) + '"></div>';
else h += carFgHTML(el, item, fgStyle, false);
h += '</div>';
} else if (el.carShowImage !== false && item.image) {
h += '<img class="car-img" src="' + escAttr(item.image) + '" alt="' + escAttr(item.title) + '" draggable="false">';
}
if (el.carShowTitle !== false && item.title) h += '<div class="car-title">' + item.title + '</div>';
if (el.carShowText !== false && item.text) h += '<div class="car-text">' + item.text + '</div>';
// Extra fields marked "show in card"
(el.carShowFields || []).forEach(function(key) {
var v = item.fields ? item.fields[key] : undefined;
if (v !== undefined && v !== '') h += '<div class="car-field car-field-' + escAttr(key).replace(/[^\\w-]/g, '') + '">' + v + '</div>';
});
return h;
}
/** Active-item settings can differ per device; these are the keys that support it */
var CAR_DEVICE_PROPS = ['carActiveSlot','carCenterScale','carSideScale','carSideOpacity',
'carCenterOffsetX','carCenterOffsetY','carCenterWidth','carCenterHeight',
'carCenterPadding','carCenterMargin','carCenterRadius',
// breathing room around the strip, per-device
'carPadTop','carPadBottom',
// arrow + dot placement is per-device too
'carNavInset','carNavOffsetX','carNavOffsetY',
'carNavPrevX','carNavPrevY','carNavNextX','carNavNextY',
'carDotOffsetX','carDotOffsetY','carDotMarginTop','carDotBottom'];
/** el merged with its overrides for \`device\` ('tablet' | 'phone' | 'phone-land') */
function carMergedFor(el, device) {
var ov = (device && el.carDeviceOverrides) ? el.carDeviceOverrides[device] : null;
if (!ov) return el;
var m = {}, k;
for (k in el) m[k] = el[k];
CAR_DEVICE_PROPS.forEach(function(prop) {
if (ov[prop] !== undefined && ov[prop] !== null && ov[prop] !== '') m[prop] = ov[prop];
else if (prop === 'carActiveSlot' && ov[prop] === '') m[prop] = '';
});
return m;
}
function carHasDeviceOverride(el, device) {
var ov = (el.carDeviceOverrides || {})[device];
if (!ov) return false;
return CAR_DEVICE_PROPS.some(function(p) { return ov[p] !== undefined && ov[p] !== null; });
}
/** Explicit active-slot mode: the active card sits in a chosen visible slot
* instead of being geometrically centred (e.g. 1 card left, 2 cards right). */
function carActiveSlotOf(el, perView) {
var v = el.carActiveSlot;
if (v === '' || v === undefined || v === null || v === 'auto') return null;
var n = parseInt(v);
if (isNaN(n)) return null;
return Math.max(0, Math.min((parseInt(perView) || 3) - 1, n));
}
function carUsesSlotMode(el) {
return carActiveSlotOf(el, el.carItems) !== null;
}
/** Media queries matching the builder's device modes */
var CAR_DEVICE_QUERIES = {
tablet: ['@media (max-width:1024px) and (min-width:481px)'],
phone: ['@media (max-width:480px) and (orientation:portrait)'],
'phone-land': ['@media (orientation:landscape) and (max-height:500px)', '@media (max-width:768px) and (orientation:landscape)']
};
/** The active-item + stage-shift rules for one configuration (base or a device override) */
function carActiveRules(el, cfg, itemCount) {
var s = '.car-' + el.id;
var cScale = cfg.carCenterScale !== undefined ? cfg.carCenterScale : 1;
var sScale = cfg.carSideScale !== undefined ? cfg.carSideScale : 1;
var sOpac = cfg.carSideOpacity !== undefined ? cfg.carSideOpacity : 1;
var cx = parseFloat(cfg.carCenterOffsetX) || 0;
var cy = parseFloat(cfg.carCenterOffsetY) || 0;
var grow = Math.max(0, (cScale - 1) * 60);
var padTop = Math.round(grow + Math.max(0, -cy) + (parseFloat(cfg.carPadTop) || 0));
var padBottom = Math.round(grow + Math.max(0, cy) + (parseFloat(cfg.carPadBottom) || 0));
var centerSizeCss = '';
if (cfg.carCenterWidth) centerSizeCss += 'width:' + cssLen(cfg.carCenterWidth) + '; margin-left:auto; margin-right:auto;';
if (cfg.carCenterHeight) centerSizeCss += 'height:' + cssLen(cfg.carCenterHeight) + ';';
var centerSel = s + ' .owl-item.center .car-item, ' + s + ' .owl-item.car-active .car-item, ' + s + ' .car-fallback .car-item.center';
var out = [
s + ' .owl-stage-outer { padding:' + padTop + 'px 0 ' + padBottom + 'px; }',
s + ' .car-item { transform:translateX(var(--car-shift, 0px)) scale(' + sScale + '); opacity:' + sOpac + '; }',
centerSel + ' { transform:translate(' + cx + 'px, ' + cy + 'px) scale(' + cScale + '); opacity:1; ' + centerSizeCss +
(cfg.carCenterPadding ? ' padding:' + cfg.carCenterPadding + ';' : '') +
(cfg.carCenterMargin ? ' margin:' + cfg.carCenterMargin + ';' : '') +
(cfg.carCenterRadius ? ' border-radius:' + cfg.carCenterRadius + ';' : '') +
(cfg.carCenterBg ? ' background:' + cfg.carCenterBg + ';' : '') +
(cfg.carCenterBorder ? ' border:' + cfg.carCenterBorder + ';' : '') +
(cfg.carCenterShadow ? ' box-shadow:' + cfg.carCenterShadow + ';' : '') + ' }'
];
// Off-balance layout: push the whole strip right by whole slots
var c = Math.max(1, parseInt(itemCount) || 3);
var slot = carActiveSlotOf(cfg, c);
if (slot !== null) out.push(s + ' .owl-stage { margin-left:' + ((slot / c) * 100).toFixed(4) + '%; }');
else out.push(s + ' .owl-stage { margin-left:0; }');
return out.join('\\n');
}
/** Arrow + dot placement for one configuration (base or a device override) */
function carPositionRules(el, cfg) {
var s = '.car-' + el.id;
var num = function(v) { return parseFloat(v) || 0; };
var inset = num(cfg.carNavInset !== undefined ? cfg.carNavInset : cfg.carNavOffset);
var prevT = 'translate(' + num(cfg.carNavPrevX) + 'px, ' + num(cfg.carNavPrevY) + 'px)';
var nextT = 'translate(' + num(cfg.carNavNextX) + 'px, ' + num(cfg.carNavNextY) + 'px)';
var dotT = 'translate(' + num(cfg.carDotOffsetX) + 'px, ' + num(cfg.carDotOffsetY) + 'px)';
var topSpace = (cfg.carDotMarginTop === '' || cfg.carDotMarginTop === undefined) ? 6 : num(cfg.carDotMarginTop);
var out = [
s + ' .owl-nav { position:absolute; top:calc(50% + ' + num(cfg.carNavOffsetY) + 'px); left:' + num(cfg.carNavOffsetX) +
'px; width:100%; margin:0 !important; transform:translateY(-50%); display:flex; justify-content:space-between; pointer-events:none; box-sizing:border-box; }',
s + ' .owl-nav button.owl-prev { margin:0 ' + inset + 'px !important; transform:' + prevT + '; }',
s + ' .owl-nav button.owl-next { margin:0 ' + inset + 'px !important; transform:' + nextT + '; }',
// keep the nudge when hovering, or the button would jump back to its default spot
s + ' .owl-nav button.owl-prev:hover { transform:' + prevT + ' scale(1.12); }',
s + ' .owl-nav button.owl-next:hover { transform:' + nextT + ' scale(1.12); }'
];
if (cfg.carDotPosition === 'overlay') {
out.push(s + ' { position:relative; }');
out.push(s + ' .owl-dots { position:absolute; left:0; right:0; bottom:' +
((cfg.carDotBottom === '' || cfg.carDotBottom === undefined) ? 8 : num(cfg.carDotBottom)) +
'px; z-index:3; margin:0 !important; transform:' + dotT + '; }');
} else {
if (cfg.carDotPosition === 'above') {
out.push(s + ' .owl-dots { order:-1; }');
out.push(s + ' .car-wrap { display:flex; flex-direction:column; }');
}
// !important because owl.theme.default.css has a more specific rule
// (\`.owl-theme .owl-nav.disabled + .owl-dots { margin-top:10px }\`) that would
// otherwise pin the spacing and make "Top space" do nothing.
out.push(s + ' .owl-dots { margin-top:' + topSpace + 'px !important; position:static; transform:' + dotT + '; }');
}
return out.join('\\n');
}
/** "160" → "160px"; leaves "160px", "50%", "auto", "" alone */
function cssLen(v, fallback) {
if (v === undefined || v === null || v === '') return fallback;
var str = String(v).trim();
return /^-?\\d*\\.?\\d+$/.test(str) ? str + 'px' : str;
}
function buildCarouselCSS(el) {
var s = '.car-' + el.id;
// The builder previews one device at a time and can't rely on media queries,
// so it asks for that device's values to be baked into the base rules.
var previewDevice = (typeof window !== 'undefined') ? window.__uiCarouselDevice : null;
var base = previewDevice ? carMergedFor(el, previewDevice) : el;
var per = parseInt(el.carItems) || 3;
var gap = parseInt(el.carMargin) || 0;
// Item box sizing
var sizeCss = '';
if (el.carItemWidth) sizeCss += 'width:' + cssLen(el.carItemWidth) + '; margin-left:auto; margin-right:auto;';
if (el.carItemHeight) sizeCss += 'height:' + cssLen(el.carItemHeight) + ';';
// Active-item rules: base first, then a block per device that overrides them
var basePer = previewDevice
? (previewDevice === 'phone' ? (parseInt(el.carItemsPhone) || 1)
: previewDevice === 'tablet' ? (parseInt(el.carItemsTablet) || per)
: (parseInt(el.carItemsPhoneLand) || parseInt(el.carItemsTablet) || per))
: per;
var activeCss = [carActiveRules(el, base, basePer), carPositionRules(el, base)];
if (!previewDevice) {
var counts = {
tablet: parseInt(el.carItemsTablet) || per,
phone: parseInt(el.carItemsPhone) || 1,
'phone-land': parseInt(el.carItemsPhoneLand) || parseInt(el.carItemsTablet) || per
};
Object.keys(CAR_DEVICE_QUERIES).forEach(function(dev) {
var cfg = carMergedFor(el, dev);
if (cfg === el && counts[dev] === per) return; // nothing device-specific
// (cfg !== el means this device carries at least one override)
var rules = carActiveRules(el, cfg, counts[dev]) + '\\n' + carPositionRules(el, cfg);
CAR_DEVICE_QUERIES[dev].forEach(function(q) { activeCss.push(q + ' {\\n' + rules + '\\n}'); });
});
}
var slotCss = activeCss.join('\\n');
return [
s + ' { width:100%; }',
// Owl ships \`.owl-carousel { display:none }\` and only reveals it once it has
// initialised — if anything goes wrong the whole carousel vanishes. Keep it shown.
s + ' .owl-carousel { display:block; }',
s + ' .owl-carousel .owl-item { float:left; }',
s + ' .owl-stage { display:flex; align-items:center; }',
// z-index only applies to positioned elements — without this the *next* card in
// DOM order paints over the enlarged active one.
s + ' .owl-item { display:flex; align-items:center; position:relative; z-index:1; }',
s + ' .owl-item.center, ' + s + ' .owl-item.car-active { z-index:' + (parseInt(el.carActiveZ) || 10) + '; }',
s + ' .car-item { position:relative; }',
s + ' .car-fallback .car-item.center { z-index:' + (parseInt(el.carActiveZ) || 10) + '; }',
s + ' .car-item { background:' + (el.carItemBg || 'transparent') + '; border:' + (el.carItemBorder || 'none') +
'; border-radius:' + (el.carItemRadius || '0') + '; padding:' + (el.carItemPadding || '0') +
'; margin:' + (el.carItemMargin || '0') +
'; box-shadow:' + (el.carItemShadow || 'none') + '; text-align:' + (el.carItemAlign || 'center') +
'; overflow:hidden; box-sizing:border-box; ' + sizeCss +
' transition:transform 0.35s ease, opacity 0.35s ease; }',
s + ' .car-media { position:relative; width:' + cssLen(el.carModelWidth, '100%') + '; height:' +
cssLen(el.carModelHeight, cssLen(el.carImgHeight, '140px')) + '; margin:0 auto; }',
// The canvas covers the media box, so adding a model never resizes the card
s + ' .car-media .car-model { position:absolute; inset:' + (parseFloat(el.carModelInset) || 0) + 'px; }',
s + ' .car-media .car-fg-box { position:absolute; }',
s + ' .car-media .car-img { position:absolute; inset:0; width:100%; height:100%; }',
s + ' .car-model { border-radius:' + (el.carImgRadius || '0') + '; overflow:hidden; z-index:1; }',
// Full-bleed image mode: the picture covers the card, content sits above it
s + ' .car-bgimg { position:absolute; inset:0; width:100%; height:100%; object-fit:' +
(el.carImgFit || 'cover') + '; object-position:' + (el.carImgPosition || 'center') +
'; border-radius:inherit; z-index:0; display:block; }',
s + ' .car-model-cover { position:absolute; inset:' + (parseFloat(el.carModelInset) || 0) + 'px; z-index:1; }',
// Overlay image: sits above the card image, its inset acts as a margin from the edges
// Overlay image: the box handles margin/padding/alignment, the image its own size + fit
s + ' .car-fg-box { position:absolute; inset:' + cssLen(el.carFgInset, '0px') + '; padding:' + cssLen(el.carFgPadding, '0px') +
'; box-sizing:border-box; display:flex; z-index:1; pointer-events:none;' +
' align-items:' + (el.carFgAlignV === 'top' ? 'flex-start' : el.carFgAlignV === 'bottom' ? 'flex-end' : 'center') +
'; justify-content:' + (el.carFgAlignH === 'left' ? 'flex-start' : el.carFgAlignH === 'right' ? 'flex-end' : 'center') + '; }',
s + ' .car-fg { max-width:100%; max-height:100%; object-position:' + (el.carFgPosition || 'center') +
'; border-radius:' + (el.carFgRadius || '0') + '; display:block; ' +
(el.carFgShadow ? 'filter:drop-shadow(' + el.carFgShadow + ');' : '') + ' }',
s + ' .car-item > .car-title, ' + s + ' .car-item > .car-text, ' + s + ' .car-item > .car-field { position:relative; z-index:2; }',
(el.carImgMode === 'cover' ? s + ' .car-item { padding:' + (el.carItemPadding || '0') + '; display:flex; flex-direction:column; justify-content:' +
(el.carCoverAlign === 'top' ? 'flex-start' : el.carCoverAlign === 'center' ? 'center' : 'flex-end') + '; }' : ''),
s + ' .car-img { width:100%; height:' + cssLen(el.carImgHeight, 'auto') + '; object-fit:' + (el.carImgFit || 'cover') +
'; border-radius:' + (el.carImgRadius || '0') + '; display:block; }',
s + ' .car-title { font-size:' + (el.carTitleSize !== undefined ? el.carTitleSize : 0.85) + (el.carTitleUnit || 'rem') + '; color:' + (el.carTitleColor || '#fff') +
'; font-weight:' + (el.carTitleWeight || '700') + '; margin-top:6px; line-height:1.3; }',
s + ' .car-text { font-size:' + (el.carTextSize !== undefined ? el.carTextSize : 0.65) + (el.carTextUnit || 'rem') + '; color:' + (el.carTextColor || '#a0b0c0') +
'; margin-top:3px; line-height:1.4; }',
s + ' .car-field { font-size:' + (el.carFieldSize !== undefined ? el.carFieldSize : 0.6) + (el.carFieldUnit || 'rem') + '; color:' + (el.carFieldColor || '#8a93a8') +
'; margin-top:2px; line-height:1.4; }',
s + ' .owl-nav button.owl-prev, ' + s + ' .owl-nav button.owl-next { pointer-events:auto; color:' + (el.carNavColor || '#fff') +
' !important; background:' + (el.carNavBg || 'rgba(0,0,0,0.4)') + ' !important; width:' + (parseFloat(el.carNavSize) || 28) +
'px; height:' + (parseFloat(el.carNavSize) || 28) + 'px; border-radius:' + (el.carNavRadius || '50%') +
'; border:' + (el.carNavBorder || 'none') + ' !important; display:flex; align-items:center; justify-content:center; font-size:' +
(el.carNavIconSize !== undefined ? el.carNavIconSize : 0.8) + 'rem !important; transition:transform 0.15s, background 0.15s; }',
// Owl adds \`disabled\` to the nav/dots wrapper whenever every slide already fits on
// screen, and its core CSS hides \`.disabled\` outright — which silently removes the
// arrows and dots as soon as "Visible" reaches the number of slides. With loop on
// they still work, so keep them shown when the user asked for them.
(el.carNav !== false ? s + ' .owl-nav.disabled { display:flex !important; }' : ''),
(el.carDots !== false ? s + ' .owl-dots.disabled { display:flex !important; }' : ''),
s + ' .owl-dots { display:flex; justify-content:center; align-items:center; }',
s + ' .owl-dots .owl-dot span { background:' + (el.carDotColor || 'rgba(255,255,255,0.25)') + ' !important; width:' +
(parseFloat(el.carDotWidth) || 7) + 'px; height:' + (parseFloat(el.carDotHeight) || 7) + 'px; border-radius:' +
(el.carDotRadius || '50%') + ' !important; margin:0 ' + ((parseFloat(el.carDotGap) || 6) / 2) + 'px !important; transition:all 0.2s; }',
s + ' .owl-dots .owl-dot.active span { background:' + (el.carDotActiveColor || '#00e5a0') + ' !important;' +
(el.carDotActiveWidth ? ' width:' + parseFloat(el.carDotActiveWidth) + 'px !important;' : '') +
(el.carDotActiveHeight ? ' height:' + parseFloat(el.carDotActiveHeight) + 'px !important;' : '') + ' }',
s + ' .car-fallback { display:flex; gap:' + gap + 'px; align-items:center; justify-content:center; width:100%; overflow:hidden; }',
s + ' .car-fallback .car-item { flex:0 0 calc((100% - ' + (gap * (per - 1)) + 'px) / ' + per + '); }',
(el.carItemClick && el.carItemClick !== 'none') ? s + ' .car-item { cursor:pointer; }' : '',
slotCss
].join('\\n');
}
function applyCarouselCSS(el) {
var st = document.getElementById('car-style-' + el.id);
if (!st) { st = document.createElement('style'); st.id = 'car-style-' + el.id; document.head.appendChild(st); }
st.textContent = buildCarouselCSS(el);
}
function owlAvailable() {
return typeof window.jQuery !== 'undefined' && window.jQuery.fn && typeof window.jQuery.fn.owlCarousel === 'function';
}
/** Visible item count for the current viewport — mirrors the builder's device modes.
* window.__uiCarouselForceItems lets the builder pin a count for its device preview. */
function carItemsForViewport(el) {
// The builder previews devices by resizing its canvas, not the window, so it installs
// a resolver that answers from the selected device instead of window.innerWidth.
if (typeof window !== 'undefined' && typeof window.__uiCarouselItemsResolver === 'function') {
try {
var r = parseInt(window.__uiCarouselItemsResolver(el));
if (r > 0) return r;
} catch(e) {}
}
var forced = (typeof window !== 'undefined') ? window.__uiCarouselForceItems : null;
if (forced) return Math.max(1, parseInt(forced) || 1);
var desktop = Math.max(1, parseInt(el.carItems) || 3);
var tablet = Math.max(1, parseInt(el.carItemsTablet) || desktop);
var phone = Math.max(1, parseInt(el.carItemsPhone) || 1);
var land = el.carItemsPhoneLand ? Math.max(1, parseInt(el.carItemsPhoneLand)) : tablet;
var w = (typeof window !== 'undefined' && window.innerWidth) ? window.innerWidth : 1280;
var h = (typeof window !== 'undefined' && window.innerHeight) ? window.innerHeight : 800;
var landscape = w > h;
// Phones held sideways are wide but short (844×390, 932×430…), so height matters
// more than width for spotting them.
if (landscape && (w <= 768 || (h <= 500 && w <= 1200))) return land;
if (w <= 480) return phone;
if (w <= 1024) return tablet;
return desktop;
}
function owlOptionsFor(el, live) {
var per = carItemsForViewport(el);
return {
items: per,
margin: parseInt(el.carMargin) || 0,
loop: el.carLoop !== false,
center: carUsesSlotMode(el) ? false : !!el.carCenter,
autoWidth: !!el.carAutoWidth,
stagePadding: parseInt(el.carStagePadding) || 0,
nav: el.carNav !== false,
dots: el.carDots !== false,
dotsEach: 1, // one dot per item (Owl otherwise draws one dot per "page",
// which gives a single dot when every item is already visible)
navText: ['<i class="bi ' + (el.carNavPrevIcon || 'bi-chevron-left') + '"></i>', '<i class="bi ' + (el.carNavNextIcon || 'bi-chevron-right') + '"></i>'],
autoplay: !!(live && el.carAutoplay),
autoplayTimeout: parseInt(el.carAutoplayTimeout) || 4000,
autoplayHoverPause: el.carAutoplayHoverPause !== false,
smartSpeed: parseInt(el.carSpeed) || 450,
mouseDrag: live ? el.carMouseDrag !== false : false,
touchDrag: live ? el.carMouseDrag !== false : false,
pullDrag: false,
rewind: el.carLoop === false,
startPosition: parseInt(el.carStart) || 0,
// One bucket only: the count is resolved by carItemsForViewport(), which understands
// portrait/landscape. Owl's own breakpoints can't express orientation.
responsive: { 0: { items: per } }
};
}
/* ── Linked elements: update other UI elements when the active item changes ── */
function carouselTargetDom(id) {
if (!id) return null;
try { if (typeof gameUIElements !== 'undefined' && gameUIElements && gameUIElements[id]) return gameUIElements[id].dom; } catch(e) {}
try { if (typeof viewport !== 'undefined' && viewport) return viewport.querySelector('[data-id="' + id + '"]'); } catch(e) {}
return document.getElementById('gui-' + id);
}
function carouselFieldValue(item, field, index) {
if (!item) return '';
if (field === 'index') return String(index + 1);
if (field && field.indexOf('field:') === 0) {
var key = field.slice(6);
return (item.fields && item.fields[key] !== undefined) ? item.fields[key] : '';
}
return item[field || 'title'] !== undefined ? item[field || 'title'] : '';
}
/** Tags the current .owl-item with \`car-active\` (Owl only adds \`center\` in centre mode) */
function markCarouselActive(el, wrap, $t) {
if (!wrap) return;
var nodes = wrap.querySelectorAll('.owl-item');
if (!nodes.length) return;
var target = wrap.querySelector('.owl-item.center');
if (!target) {
var pos = -1;
try {
var inst = $t && $t.data ? $t.data('owl.carousel') : null;
if (inst) pos = inst.current();
} catch(e) {}
if (pos >= 0 && nodes[pos]) target = nodes[pos];
}
Array.prototype.forEach.call(nodes, function(n) { n.classList.remove('car-active'); });
if (target) target.classList.add('car-active');
applyCarouselSpread(el, wrap, nodes, target);
}
/** Pushes the side cards away from the active one and poses their 3D models */
function applyCarouselSpread(el, wrap, nodes, activeNode) {
nodes = nodes || wrap.querySelectorAll('.owl-item');
if (!nodes.length) return;
var activeIdx = -1;
Array.prototype.forEach.call(nodes, function(n, i) { if (n === activeNode) activeIdx = i; });
var gap = parseFloat(el.carSideGap) || 0;
var stack = el.carSideGapStacks !== false; // 2nd neighbour moves twice as far
Array.prototype.forEach.call(nodes, function(n, i) {
var card = n.querySelector('.car-item');
if (!card) return;
var shift = 0;
if (activeIdx >= 0 && i !== activeIdx && gap) {
var dist = i - activeIdx;
var steps = stack ? Math.abs(dist) : 1;
shift = (dist < 0 ? -1 : 1) * gap * steps;
}
card.style.setProperty('--car-shift', shift + 'px');
// Per-card 3D pose: the active card can sit differently from the side ones
var host = n.querySelector('.car-model .ui-model');
if (host && host._modelApi && host._modelApi.setPose) {
var isActive = (i === activeIdx);
var item = null;
var ci = card.dataset ? parseInt(card.dataset.index) : NaN;
if (!isNaN(ci)) item = (el.carouselItems || [])[ci];
var pose = {
rotX: pickNum(isActive ? el.carModelActiveRotX : el.carModelSideRotX, item && item.modelRotX, el.carModelRotX, 0),
rotY: pickNum(isActive ? el.carModelActiveRotY : el.carModelSideRotY, item && item.modelRotY, el.carModelRotY, 0),
rotZ: pickNum(isActive ? el.carModelActiveRotZ : el.carModelSideRotZ, item && item.modelRotZ, el.carModelRotZ, 0),
scale: pickNum(isActive ? el.carModelActiveScale : el.carModelSideScale, item && item.modelScale, el.carModelScale, 1),
distance: pickNum(isActive ? el.carModelActiveDistance : el.carModelSideDistance, item && item.modelDistance, el.carModelDistance, 4)
};
host._modelApi.setPose(pose);
var spins = carItemSpins(el, item, false);
host._modelApi.setSpin(el.carModelSpinActiveOnly ? (isActive && spins) : spins);
}
});
}
/** Whether an item's model should spin — per item, falling back to the carousel */
function carItemSpins(el, item, atCreation) {
var v = item ? item.modelAutoRotate : undefined;
var on = (v === undefined || v === null || v === '') ? (el.carModelAutoRotate !== false) : (v === true || v === 'true');
// "spin active only" is applied per card once we know which one is active
if (atCreation && el.carModelSpinActiveOnly) return false;
return on;
}
/** First value that is actually set */
function pickNum() {
for (var i = 0; i < arguments.length; i++) {
var v = arguments[i];
if (v !== undefined && v !== null && v !== '' && !isNaN(parseFloat(v))) return parseFloat(v);
}
return 0;
}
function carouselActiveIndex(wrap) {
if (!wrap) return 0;
var node = wrap.querySelector('.owl-item.center .car-item')
|| wrap.querySelector('.owl-item.car-active .car-item')
|| wrap.querySelector('.owl-item.active .car-item')
|| wrap.querySelector('.car-item.center')
|| wrap.querySelector('.car-item');
if (!node) return 0;
var i = parseInt(node.dataset.index);
return isNaN(i) ? 0 : i;
}
/** Base (designed) visibility of another element, so per-item show/hide can be reset */
function carouselTargetBaseVisible(id) {
try { if (typeof gameUIElements !== 'undefined' && gameUIElements && gameUIElements[id]) return gameUIElements[id].data.visible !== false; } catch(e) {}
try {
if (typeof elements !== 'undefined' && elements && elements.find) {
var m = elements.find(function(o) { return o.id === id; });
if (m) return m.visible !== false;
}
} catch(e) {}
return true;
}
function carouselSetTargetVisible(dom, show) {
if (!dom) return;
if (dom.classList.contains('gui-el')) {
dom.classList.toggle('gui-hidden', !show);
if (show) dom.style.removeProperty('display');
} else {
dom.style.display = show ? '' : 'none';
dom.classList.toggle('el-hidden', !show);
}
}
/** Applies one action (shared by carousel links and per-item triggers) */
function applyCarouselAction(dom, action, value) {
if (!dom) return;
var inner = dom.querySelector('.gui-inner') || dom.querySelector('.ui-el-inner') || dom.querySelector('.el-inner') || dom.firstElementChild || dom;
switch (action || 'setText') {
case 'setText': inner.textContent = value; break;
case 'setHTML': inner.innerHTML = value; break;
case 'setImage': {
var img = inner.querySelector('img');
if (img) img.src = value;
else {
inner.style.backgroundImage = value ? 'url(' + value + ')' : 'none';
inner.style.backgroundSize = inner.style.backgroundSize || 'cover';
inner.style.backgroundPosition = inner.style.backgroundPosition || 'center';
}
break;
}
case 'setBackground':
inner.style.backgroundImage = value ? 'url(' + value + ')' : 'none';
inner.style.backgroundSize = 'cover';
inner.style.backgroundPosition = 'center';
break;
case 'setVideo': {
var vid = inner.querySelector('video');
if (vid && value) { vid.src = value; vid.load(); }
break;
}
case 'setColor': inner.style.color = value; break;
case 'show': carouselSetTargetVisible(dom, true); break;
case 'hide': carouselSetTargetVisible(dom, false); break;
}
}
/** Every element touched by any item's show/hide actions — reset before each slide */
function carouselManagedVisibility(el) {
var ids = {};
(el.carouselItems || []).forEach(function(it) {
(it.actions || []).forEach(function(a) {
if (a && a.targetId && (a.action === 'show' || a.action === 'hide')) ids[a.targetId] = true;
});
});
return Object.keys(ids);
}
function applyCarouselLinks(el, index) {
var items = el.carouselItems || [];
if (!items.length) return;
if (index < 0 || index >= items.length) index = 0;
var item = items[index];
// 1. Reset anything a slide controls, so show/hide is per-slide rather than sticky
if (el.carResetVisibility !== false) {
carouselManagedVisibility(el).forEach(function(id) {
carouselSetTargetVisible(carouselTargetDom(id), carouselTargetBaseVisible(id));
});
}
// 2. Carousel-wide links (same mapping for every slide)
(el.carLinks || []).forEach(function(link) {
if (!link || !link.targetId) return;
applyCarouselAction(carouselTargetDom(link.targetId), link.action, carouselFieldValue(item, link.field, index));
});
// 3. This slide's own triggers
(item.actions || []).forEach(function(a) {
if (!a || !a.targetId) return;
var val = a.value;
if (a.useField) val = carouselFieldValue(item, a.useField, index);
applyCarouselAction(carouselTargetDom(a.targetId), a.action, val);
});
if (el.carChangeFunc && typeof window[el.carChangeFunc] === 'function') {
try { window[el.carChangeFunc](item, index, el); } catch(err) { console.warn('[Carousel] change function error:', err); }
}
}
/** Current real item index — asks Owl first (clone-safe), then falls back to the DOM */
function carouselCurrentIndex(el, wrap, $t) {
var n = (el.carouselItems || []).length;
if (n) {
try {
var inst = $t && $t.data ? $t.data('owl.carousel') : null;
if (inst && typeof inst.relative === 'function') {
var rel = inst.relative(inst.current());
if (!isNaN(rel)) return ((rel % n) + n) % n;
}
} catch(e) {}
}
return carouselActiveIndex(wrap);
}
function syncCarouselLinks(el, wrap, $t) {
applyCarouselLinks(el, carouselCurrentIndex(el, wrap, $t));
}
function renderUICarousel(container, el, live) {
var items = el.carouselItems || [];
if (typeof disposeUIModel === 'function') {
Array.prototype.forEach.call(container.querySelectorAll('.car-model'), function(m) { disposeUIModel(m); });
}
container.innerHTML = '';
applyCarouselCSS(el);
if (items.length === 0) return;
var wrap = document.createElement('div');
wrap.className = 'car-wrap car-' + el.id;
wrap.style.cssText = 'width:100%;';
var ready = owlAvailable();
var track = document.createElement('div');
track.className = ready ? 'owl-carousel owl-theme' : 'car-fallback';
items.forEach(function(it, idx) {
var box = document.createElement('div');
box.className = 'car-item';
box.dataset.index = idx;
box.innerHTML = carouselItemInnerHTML(el, it);
if (!ready) {
var per = parseInt(el.carItems) || 3;
var slotIdx = carUsesSlotMode(el) ? carActiveSlotOf(el, per) : (el.carCenter ? Math.floor(Math.min(items.length, per) / 2) : -1);
if (slotIdx >= 0 && idx === slotIdx) box.classList.add('center');
}
var modelBox = box.querySelector('.car-model');
if (modelBox && typeof renderUIModel === 'function') {
renderUIModel(modelBox, {
id: el.id + '_m' + idx,
src: it.model,
borderRadius: el.carImgRadius,
modelBg: it.modelBg || el.carModelBg || 'transparent',
modelStubBg: (el.carShowImage !== false && it.image) ? 'transparent' : null,
modelAutoRotate: carItemSpins(el, it, true),
modelRotateSpeed: el.carModelRotateSpeed,
modelControls: el.carModelControls,
modelDragRotate: el.carModelDragRotate,
modelZoom: el.carModelZoom,
modelPan: el.carModelPan,
modelDragSpeed: el.carModelDragSpeed,
modelZoomSpeed: el.carModelZoomSpeed,
modelMinDistance: el.carModelMinDistance,
modelMaxDistance: el.carModelMaxDistance,
modelMinPolar: el.carModelMinPolar,
modelMaxPolar: el.carModelMaxPolar,
modelDamping: el.carModelDamping,
modelStopSpinOnInteract: el.carModelStopSpinOnInteract,
modelResumeSpin: el.carModelResumeSpin,
modelDistance: it.modelDistance || el.carModelDistance,
modelFov: el.carModelFov,
modelLighting: el.carModelLighting,
modelLightIntensity: el.carModelLightIntensity,
modelScale: it.modelScale !== undefined ? it.modelScale : el.carModelScale,
modelRotX: it.modelRotX !== undefined ? it.modelRotX : el.carModelRotX,
modelRotY: it.modelRotY !== undefined ? it.modelRotY : el.carModelRotY,
modelRotZ: it.modelRotZ !== undefined ? it.modelRotZ : el.carModelRotZ,
modelY: it.modelY !== undefined ? it.modelY : el.carModelY,
modelX: it.modelX !== undefined ? it.modelX : el.carModelX,
modelZ: it.modelZ !== undefined ? it.modelZ : el.carModelZ,
modelInset: it.modelInset,
onModelReady: function() { markCarouselActive(el, wrap, $track); },
modelCamHeight: el.carModelCamHeight,
modelAutoFit: true
}, live);
}
if (live && el.carItemClick && el.carItemClick !== 'none') {
box.addEventListener('click', function() {
if (el.carItemClick === 'link' && it.link) {
var t = el.carLinkTarget || 'newTab';
if (t === 'same') window.location.href = it.link;
else if (t === 'iframe' && typeof showIframeOverlay === 'function') showIframeOverlay(it.link);
else window.open(it.link, '_blank');
} else if (el.carItemClick === 'function' && el.carItemFunc && typeof window[el.carItemFunc] === 'function') {
try { window[el.carItemFunc](it, idx, el); } catch(err) { console.warn('[Carousel]', err); }
}
});
}
track.appendChild(box);
});
wrap.appendChild(track);
container.appendChild(wrap);
var $track = null;
if (ready) {
try {
var $t = window.jQuery(track);
$track = $t;
$t.on('initialized.owl.carousel changed.owl.carousel translated.owl.carousel', function(e) {
// Prefer Owl's own index (clone-safe), fall back to reading the DOM
var idx = null;
try {
if (e && e.item && e.relatedTarget && typeof e.relatedTarget.relative === 'function') {
idx = e.relatedTarget.relative(e.item.index);
}
} catch(err) {}
markCarouselActive(el, wrap, $t);
var n = items.length;
if (idx !== null && !isNaN(idx) && n) applyCarouselLinks(el, ((idx % n) + n) % n);
else syncCarouselLinks(el, wrap, $t);
// Re-read once the slide has settled
window.setTimeout(function() {
markCarouselActive(el, wrap, $t);
syncCarouselLinks(el, wrap, $t);
}, (parseInt(el.carSpeed) || 450) + 40);
});
// Clicking a side item brings it to the centre (feels natural with center mode)
if ((el.carCenter || carUsesSlotMode(el)) && live && el.carClickToCenter !== false) {
$t.on('click', '.owl-item:not(.center):not(.car-active) .car-item', function(e) {
if (el.carModelControls && e.target && e.target.closest && e.target.closest('.car-model')) return;
var i = parseInt(this.dataset.index);
if (!isNaN(i)) $t.trigger('to.owl.carousel', [i, parseInt(el.carSpeed) || 450]);
});
}
$t.owlCarousel(owlOptionsFor(el, live));
} catch(err) {
console.warn('[Carousel] init failed:', err);
track.classList.add('owl-loaded'); // show the cards rather than nothing
}
}
window.setTimeout(function() {
markCarouselActive(el, wrap, $track);
syncCarouselLinks(el, wrap, $track);
}, 30);
// Keep Owl in step with size / orientation changes. Owl caches item widths at init,
// so without this a resized (or rotated) carousel shows the wrong number of items.
if (ready && $track) {
var lastPer = owlOptionsFor(el, live).items;
var reflowTimer = null;
var reflow = function() {
window.clearTimeout(reflowTimer);
reflowTimer = window.setTimeout(function() {
if (!document.body.contains(track)) return; // element was re-rendered
var per = carItemsForViewport(el);
if (per !== lastPer) {
lastPer = per;
try {
$track.trigger('destroy.owl.carousel');
} catch(e) { console.warn('[Carousel] destroy failed:', e); }
try {
$track.addClass('owl-carousel owl-theme');
$track.owlCarousel(owlOptionsFor(el, live));
} catch(e) {
console.warn('[Carousel] re-init failed:', e);
$track.addClass('owl-loaded'); // fall back to a plain visible row
}
} else {
try { $track.trigger('refresh.owl.carousel'); } catch(e) {}
}
markCarouselActive(el, wrap, $track);
syncCarouselLinks(el, wrap, $track);
}, 120);
};
window.addEventListener('resize', reflow);
window.addEventListener('orientationchange', reflow);
if (typeof ResizeObserver !== 'undefined') {
try { new ResizeObserver(reflow).observe(wrap); } catch(e) {}
}
}
}
function controlUICarousel(dom, cmd, index) {
if (!owlAvailable() || !dom) return;
var $t = window.jQuery(dom).find('.owl-carousel');
if (!$t.length) return;
switch (cmd || 'next') {
case 'next': $t.trigger('next.owl.carousel'); break;
case 'prev': $t.trigger('prev.owl.carousel'); break;
case 'goTo': $t.trigger('to.owl.carousel', [parseInt(index) || 0, 300]); break;
case 'play': $t.trigger('play.owl.autoplay'); break;
case 'stop': $t.trigger('stop.owl.autoplay'); break;
case 'refresh': $t.trigger('refresh.owl.carousel'); break;
}
}
/* ── Design stage: reproduce the builder's canvas exactly ───────────────────
The builder lays out on a fixed canvas (960×540 on desktop) and scales it to
fit. Positions are percentages, but font sizes, image heights, paddings and
radii are px/rem — so rendering the same UI into a raw browser window of a
different size changes every one of those proportions. Runtimes therefore
size the overlay to the design resolution and scale it, exactly like the
builder does. */
var UI_DESIGN_SIZES = {
desktop: { w: 960, h: 540 },
tablet: { w: 512, h: 683 },
phone: { w: 320, h: 568 },
'phone-land': { w: 568, h: 320 }
};
/** Anchor → translate offsets (the element's own position is a % of the stage) */
function uiAnchorTranslate(anchor) {
switch (anchor || 'center') {
case 'top-left': return { tx: '0', ty: '0' };
case 'top-center': return { tx: '-50%', ty: '0' };
case 'top-right': return { tx: '-100%', ty: '0' };
case 'center-left': return { tx: '0', ty: '-50%' };
case 'center-right': return { tx: '-100%', ty: '-50%' };
case 'bottom-left': return { tx: '0', ty: '-100%' };
case 'bottom-center': return { tx: '-50%', ty: '-100%' };
case 'bottom-right': return { tx: '-100%', ty: '-100%' };
default: return { tx: '-50%', ty: '-50%' };
}
}
/** The transform an element carries: anchor offset plus a hover/active scale hook */
function uiAnchorTransform(anchor) {
var a = uiAnchorTranslate(anchor);
return 'translate(' + a.tx + ', ' + a.ty + ') scale(var(--gui-scale, 1))';
}
function uiDeviceBucket() {
var w = (typeof window !== 'undefined' && window.innerWidth) ? window.innerWidth : 1280;
var h = (typeof window !== 'undefined' && window.innerHeight) ? window.innerHeight : 800;
var landscape = w > h;
if (landscape && (w <= 768 || (h <= 500 && w <= 1200))) return 'phone-land';
if (w <= 480) return 'phone';
if (w <= 1024) return 'tablet';
return 'desktop';
}
function uiDesignSize(uiData) {
var sizes = (uiData && uiData.designSizes) || UI_DESIGN_SIZES;
return sizes[uiDeviceBucket()] || sizes.desktop || UI_DESIGN_SIZES.desktop;
}
function applyUIStage(uiData, overlayId) {
var overlay = document.getElementById(overlayId || 'game-ui-overlay');
if (!overlay) return;
var mode = (uiData && uiData.scaleMode) || 'fit';
if (mode === 'stretch') { // legacy: fill the window, px sizes unscaled
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.left = '0';
overlay.style.top = '0';
overlay.style.transform = '';
overlay.style.transformOrigin = '';
return;
}
var size = uiDesignSize(uiData);
var w = window.innerWidth, h = window.innerHeight;
var scale = mode === 'fill'
? Math.max(w / size.w, h / size.h)
: Math.min(w / size.w, h / size.h);
overlay.style.width = size.w + 'px';
overlay.style.height = size.h + 'px';
overlay.style.left = '50%';
overlay.style.top = '50%';
overlay.style.transformOrigin = 'center center';
overlay.style.transform = 'translate(-50%, -50%) scale(' + scale + ')';
}
/** Re-apply on resize / rotate; safe to call repeatedly. */
function watchUIStage(getUIData, overlayId) {
if (window.__uiStageWatch) window.removeEventListener('resize', window.__uiStageWatch);
var timer = null;
window.__uiStageWatch = function() {
window.clearTimeout(timer);
timer = window.setTimeout(function() { applyUIStage(getUIData(), overlayId); }, 60);
};
window.addEventListener('resize', window.__uiStageWatch);
window.addEventListener('orientationchange', window.__uiStageWatch);
}
/* ── 3D model viewer (Three.js + GLB) ──────────────────────────────────────
Loads Three.js on demand the first time a model element is rendered, so a
project without one pays nothing. */
var UI_THREE_VERSION = '0.160.0';
var UI_THREE_CDN = 'https://cdn.jsdelivr.net/npm/three@' + UI_THREE_VERSION;
var UI_THREE_ESM = 'https://esm.sh/three@' + UI_THREE_VERSION;
var _uiThreePromise = null;
var _uiThreeError = '';
/**
* Three's add-ons (GLTFLoader, OrbitControls) are published as ES modules that
* \`import ... from 'three'\` — a bare specifier the browser can only resolve through
* an import map. Loading them straight from a CDN therefore fails with
* "Failed to resolve module specifier". So: make sure an import map exists first,
* and keep a self-resolving CDN as a backup.
*/
function ensureThreeImportMap() {
if (typeof document === 'undefined') return false;
var existing = document.querySelector('script[type="importmap"]');
if (existing) {
// A host page (game-level) already maps "three" — use whatever it points at
return true;
}
try {
var map = document.createElement('script');
map.type = 'importmap';
map.textContent = JSON.stringify({
imports: {
'three': UI_THREE_CDN + '/build/three.module.js',
'three/': UI_THREE_CDN + '/',
'three/addons/': UI_THREE_CDN + '/examples/jsm/'
}
});
document.head.appendChild(map);
return true;
} catch(e) {
return false;
}
}
function loadUIThree() {
if (typeof window !== 'undefined' && window.__uiThreeStub) return Promise.resolve(window.__uiThreeStub);
if (_uiThreePromise) return _uiThreePromise;
var viaImportMap = function() {
ensureThreeImportMap();
return Promise.all([
import(/* webpackIgnore: true */ 'three'),
import(/* webpackIgnore: true */ 'three/addons/loaders/GLTFLoader.js'),
import(/* webpackIgnore: true */ 'three/addons/controls/OrbitControls.js')
]);
};
// esm.sh rewrites the add-ons' internal "three" imports, so it needs no map
var viaEsmSh = function() {
return Promise.all([
import(/* webpackIgnore: true */ UI_THREE_ESM),
import(/* webpackIgnore: true */ UI_THREE_ESM + '/examples/jsm/loaders/GLTFLoader.js'),
import(/* webpackIgnore: true */ UI_THREE_ESM + '/examples/jsm/controls/OrbitControls.js')
]);
};
_uiThreePromise = viaImportMap()
.catch(function(err) {
console.warn('[Model3D] import-map load failed (' + (err && err.message) + '), retrying via esm.sh');
return viaEsmSh();
})
.then(function(m) {
return { THREE: m[0], GLTFLoader: m[1].GLTFLoader, OrbitControls: m[2].OrbitControls };
})
.catch(function(err) {
_uiThreeError = (err && err.message) || 'unknown error';
console.warn('[Model3D] Three.js could not be loaded:', _uiThreeError,
'\\nIf you are offline, host three.module.js yourself and add an import map to the page.');
return null;
});
return _uiThreePromise;
}
function disposeUIModel(container) {
if (!container) return;
var host = container.querySelector('.ui-model');
if (host && host._modelCleanup) { try { host._modelCleanup(); } catch(e) {} }
}
function modelPlaceholder(el, message, spinner) {
return '<div class="ui-model-stub" style="width:100%;height:100%;min-height:60px;display:flex;flex-direction:column;' +
'gap:6px;align-items:center;justify-content:center;background:' +
(el.modelStubBg ? el.modelStubBg : (el.modelBg && el.modelBg !== 'transparent' ? el.modelBg : 'rgba(0,0,0,0.3)')) +
';border-radius:' + (el.borderRadius || '8px') + ';color:#c9d2e0;text-align:center;padding:8px;box-sizing:border-box;">' +
'<i class="bi ' + (spinner ? 'bi-arrow-repeat' : 'bi-box') + '" style="font-size:1.5rem;opacity:0.65;' +
(spinner ? 'animation:uiModelSpin 1.1s linear infinite;' : '') + '"></i>' +
'<span style="font-size:0.5rem;opacity:0.75;">' + message + '</span></div>';
}
function renderUIModel(container, el, live) {
disposeUIModel(container);
container.innerHTML = '';
if (!document.getElementById('ui-model-spin-css')) {
var st = document.createElement('style');
st.id = 'ui-model-spin-css';
st.textContent = '@keyframes uiModelSpin { to { transform:rotate(360deg); } }';
document.head.appendChild(st);
}
if (!el.src) {
container.innerHTML = modelPlaceholder(el, 'Set a .glb / .gltf path', false);
return null;
}
var host = document.createElement('div');
host.className = 'ui-model';
host.style.cssText = 'position:relative;width:100%;height:100%;min-height:40px;overflow:hidden;border-radius:' +
(el.borderRadius || '0') + ';' + (el.modelBg && el.modelBg !== 'transparent' ? 'background:' + el.modelBg + ';' : '');
var loading = document.createElement('div');
loading.className = 'ui-model-loading';
loading.style.cssText = 'position:absolute;inset:0;';
loading.innerHTML = modelPlaceholder(el, 'Loading 3D model…', true);
host.appendChild(loading);
container.appendChild(host);
var cancelled = false;
host._modelEl = el;
host._modelCleanup = function() { cancelled = true; };
loadUIThree().then(function(libs) {
if (cancelled || !host.isConnected) return;
if (!libs || !libs.THREE) {
host.innerHTML = modelPlaceholder(el, '3D viewer unavailable' + (_uiThreeError ? ' — ' + _uiThreeError : ''), false);
return;
}
var THREE = libs.THREE;
var w = host.clientWidth || 300, h = host.clientHeight || 200;
var renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(w, h, false);
renderer.domElement.style.cssText = 'width:100%;height:100%;display:block;';
if (renderer.outputColorSpace !== undefined && THREE.SRGBColorSpace) renderer.outputColorSpace = THREE.SRGBColorSpace;
if (el.modelShadows && renderer.shadowMap) { renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; }
var scene = new THREE.Scene();
if (el.modelBg && el.modelBg !== 'transparent') {
try { scene.background = new THREE.Color(el.modelBg); } catch(e) {}
} else {
renderer.setClearColor(0x000000, 0);
}
var camera = new THREE.PerspectiveCamera(parseFloat(el.modelFov) || 45, w / h, 0.01, 2000);
// Lighting presets
var preset = el.modelLighting || 'studio';
var intensity = el.modelLightIntensity !== undefined ? parseFloat(el.modelLightIntensity) : 1;
var amb = new THREE.AmbientLight(0xffffff, (preset === 'dramatic' ? 0.25 : preset === 'soft' ? 0.9 : 0.55) * intensity);
scene.add(amb);
var key = new THREE.DirectionalLight(0xffffff, (preset === 'dramatic' ? 2.2 : preset === 'soft' ? 0.8 : 1.4) * intensity);
key.position.set(3, 5, 4);
if (el.modelShadows) { key.castShadow = true; }
scene.add(key);
if (preset !== 'dramatic') {
var fill = new THREE.DirectionalLight(0xffffff, 0.6 * intensity);
fill.position.set(-4, 2, -3);
scene.add(fill);
}
if (el.modelHemi !== false && THREE.HemisphereLight) {
scene.add(new THREE.HemisphereLight(0xffffff, 0x223344, 0.4 * intensity));
}
var root = new THREE.Group();
scene.add(root);
var controls = null;
if (el.modelControls && libs.OrbitControls) {
controls = new libs.OrbitControls(camera, renderer.domElement);
controls.enableDamping = el.modelDamping !== false;
controls.dampingFactor = parseFloat(el.modelDampingFactor) || 0.08;
controls.enableRotate = el.modelDragRotate !== false;
controls.enableZoom = el.modelZoom !== false;
controls.enablePan = !!el.modelPan;
controls.rotateSpeed = parseFloat(el.modelDragSpeed) || 1;
controls.zoomSpeed = parseFloat(el.modelZoomSpeed) || 1;
controls.autoRotate = !!(live && el.modelAutoRotate);
controls.autoRotateSpeed = parseFloat(el.modelRotateSpeed) || 2;
if (el.modelMinDistance !== undefined && el.modelMinDistance !== '') controls.minDistance = parseFloat(el.modelMinDistance);
if (el.modelMaxDistance !== undefined && el.modelMaxDistance !== '') controls.maxDistance = parseFloat(el.modelMaxDistance);
if (el.modelMinPolar !== undefined && el.modelMinPolar !== '') controls.minPolarAngle = parseFloat(el.modelMinPolar) * Math.PI / 180;
if (el.modelMaxPolar !== undefined && el.modelMaxPolar !== '') controls.maxPolarAngle = parseFloat(el.modelMaxPolar) * Math.PI / 180;
// Touch gestures: one finger turns, two fingers zoom (and pan when allowed)
if (THREE.TOUCH) {
controls.touches = {
ONE: el.modelDragRotate === false ? THREE.TOUCH.PAN : THREE.TOUCH.ROTATE,
TWO: el.modelPan ? THREE.TOUCH.DOLLY_PAN : THREE.TOUCH.DOLLY_ROTATE
};
}
renderer.domElement.style.touchAction = 'none';
renderer.domElement.style.cursor = 'grab';
// Don't let a carousel (or any parent) steal the gesture
['pointerdown', 'mousedown', 'touchstart', 'wheel', 'click'].forEach(function(evt) {
renderer.domElement.addEventListener(evt, function(e) { e.stopPropagation(); }, { passive: evt === 'wheel' ? false : true });
});
// Spinning pauses while the user is dragging, and can come back afterwards
controls.addEventListener('start', function() {
renderer.domElement.style.cursor = 'grabbing';
if (el.modelStopSpinOnInteract !== false) {
controls.autoRotate = false;
if (host._modelState) host._modelState.spinning = false;
}
if (host._resumeTimer) { window.clearTimeout(host._resumeTimer); host._resumeTimer = null; }
});
controls.addEventListener('end', function() {
renderer.domElement.style.cursor = 'grab';
var delay = parseFloat(el.modelResumeSpin);
if (el.modelStopSpinOnInteract !== false && el.modelAutoRotate && delay > 0) {
host._resumeTimer = window.setTimeout(function() {
controls.autoRotate = true;
if (host._modelState) host._modelState.spinning = true;
}, delay);
}
});
}
function frameModel(obj) {
var box = new THREE.Box3().setFromObject(obj);
if (!isFinite(box.min.x)) return;
var size = box.getSize(new THREE.Vector3());
var center = box.getCenter(new THREE.Vector3());
var maxDim = Math.max(size.x, size.y, size.z) || 1;
if (el.modelAutoFit !== false) {
obj.position.sub(center); // sit on the origin
var target = 1.6;
obj.scale.multiplyScalar(target / maxDim);
}
var dist = (parseFloat(el.modelDistance) || 4);
camera.position.set(0, parseFloat(el.modelCamHeight) || 0.4, dist);
camera.lookAt(0, parseFloat(el.modelTargetY) || 0, 0);
if (controls) { controls.target.set(0, parseFloat(el.modelTargetY) || 0, 0); controls.update(); }
}
function applyTransform(obj) {
obj.position.x += parseFloat(el.modelX) || 0;
obj.position.y += parseFloat(el.modelY) || 0;
obj.position.z += parseFloat(el.modelZ) || 0;
var sc = parseFloat(el.modelScale);
if (!sc && sc !== 0) sc = 1;
obj.scale.multiplyScalar(sc);
obj.rotation.set(
(parseFloat(el.modelRotX) || 0) * Math.PI / 180,
(parseFloat(el.modelRotY) || 0) * Math.PI / 180,
(parseFloat(el.modelRotZ) || 0) * Math.PI / 180
);
}
var mixer = null, clock = new THREE.Clock();
var loader = new libs.GLTFLoader();
loader.load(el.src, function(gltf) {
if (cancelled || !host.isConnected) return;
var obj = gltf.scene || gltf.scenes[0];
if (el.modelShadows) {
obj.traverse(function(n) { if (n.isMesh) { n.castShadow = true; n.receiveShadow = true; } });
}
frameModel(obj);
host._modelBaseScale = obj.scale.x; // scale after auto-fit, before user scale
applyTransform(obj);
root.add(obj);
host._modelObject = obj;
if (gltf.animations && gltf.animations.length && el.modelPlayClips !== false) {
mixer = new THREE.AnimationMixer(obj);
gltf.animations.forEach(function(clip) { mixer.clipAction(clip).play(); });
}
if (loading.parentNode) loading.remove();
// Let the host (e.g. a carousel) re-apply its pose now the model exists
if (typeof el.onModelReady === 'function') {
try { el.onModelReady(host); } catch(e) { console.warn('[Model3D] onModelReady:', e); }
}
}, undefined, function(err) {
var isFile = (location.protocol === 'file:');
console.warn('[Model3D] failed to load', el.src, err && err.message,
isFile ? '\\nThe page was opened from disk (file://) — browsers refuse to fetch .glb files that way. ' +
'Tick "Embed 3D models" when exporting, or serve the folder over http.' : '');
host.innerHTML = modelPlaceholder(el,
isFile ? 'Cannot load models from file:// — embed them on export or serve over http'
: 'Could not load ' + el.src, false);
});
host.appendChild(renderer.domElement);
// Keep the drawing buffer in step with the element
var ro = null;
function resize() {
var nw = host.clientWidth || w, nh = host.clientHeight || h;
if (!nw || !nh) return;
camera.aspect = nw / nh;
camera.updateProjectionMatrix();
renderer.setSize(nw, nh, false);
}
if (typeof ResizeObserver !== 'undefined') {
try { ro = new ResizeObserver(resize); ro.observe(host); } catch(e) {}
}
window.addEventListener('resize', resize);
var raf = null, spinning = !!(live && el.modelAutoRotate && !controls);
host._modelState = { spinning: spinning };
function tick() {
if (cancelled || !host.isConnected) return;
raf = requestAnimationFrame(tick);
var dt = clock.getDelta();
if (mixer) mixer.update(dt);
if (host._modelState.spinning && host._modelObject) {
host._modelObject.rotation.y += (parseFloat(el.modelRotateSpeed) || 2) * 0.25 * dt;
}
if (controls) controls.update();
renderer.render(scene, camera);
}
tick();
host._modelCleanup = function() {
cancelled = true;
if (raf) cancelAnimationFrame(raf);
window.removeEventListener('resize', resize);
if (ro) { try { ro.disconnect(); } catch(e) {} }
if (controls) { try { controls.dispose(); } catch(e) {} }
try {
scene.traverse(function(n) {
if (n.geometry) n.geometry.dispose();
if (n.material) {
var mats = Array.isArray(n.material) ? n.material : [n.material];
mats.forEach(function(m) {
Object.keys(m).forEach(function(k) { if (m[k] && m[k].isTexture) m[k].dispose(); });
m.dispose();
});
}
});
renderer.dispose();
} catch(e) {}
};
host._modelApi = {
renderer: renderer, scene: scene, camera: camera, root: root, controls: controls,
setSpin: function(on) {
host._modelState.spinning = !!on && !controls;
if (controls) controls.autoRotate = !!on;
},
setPose: function(pose) {
if (!pose) return;
var obj = host._modelObject;
if (obj) {
if (pose.rotX !== undefined || pose.rotY !== undefined || pose.rotZ !== undefined) {
obj.rotation.set(
(pose.rotX || 0) * Math.PI / 180,
(pose.rotY || 0) * Math.PI / 180,
(pose.rotZ || 0) * Math.PI / 180
);
}
if (pose.scale !== undefined && host._modelBaseScale) {
var sc = host._modelBaseScale * pose.scale;
obj.scale.set(sc, sc, sc);
}
}
if (pose.distance !== undefined && camera) {
camera.position.z = pose.distance;
if (controls) controls.update();
}
},
controlsFor: function() { return controls; },
reset: function() {
if (host._modelObject) {
host._modelObject.rotation.set(
(parseFloat(el.modelRotX) || 0) * Math.PI / 180,
(parseFloat(el.modelRotY) || 0) * Math.PI / 180,
(parseFloat(el.modelRotZ) || 0) * Math.PI / 180
);
}
if (controls) controls.reset();
}
};
});
return host;
}
function controlUIModel(dom, cmd, value) {
var host = dom ? dom.querySelector('.ui-model') : null;
if (!host) return;
var api = host._modelApi;
switch (cmd || 'toggleSpin') {
case 'spin': if (api) api.setSpin(true); break;
case 'stop': if (api) api.setSpin(false); break;
case 'toggleSpin': if (api) api.setSpin(!(host._modelState && host._modelState.spinning)); break;
case 'reset': if (api) api.reset(); break;
case 'setModel':
if (value && host.parentNode) {
var el = host._modelEl || {};
var next = {};
for (var k in el) next[k] = el[k];
next.src = value;
renderUIModel(host.parentNode, next, true);
}
break;
}
}
/* ── Iframe / embed element ── */
function uiEmbedURL(src) {
var u = String(src || '').trim();
if (!u) return '';
// Friendly conversions so a pasted share link just works
var yt = u.match(/(?:youtube\\.com\\/(?:watch\\?v=|live\\/|shorts\\/)|youtu\\.be\\/)([\\w-]{6,})/i);
if (yt) return 'https://www.youtube.com/embed/' + yt[1];
var vim = u.match(/vimeo\\.com\\/(?:video\\/)?(\\d+)/i);
if (vim) return 'https://player.vimeo.com/video/' + vim[1];
var gmap = u.match(/google\\.[a-z.]+\\/maps\\/place\\/([^/?]+)/i);
if (gmap && u.indexOf('/embed') === -1) {
return 'https://www.google.com/maps?q=' + encodeURIComponent(decodeURIComponent(gmap[1])) + '&output=embed';
}
return u;
}
function buildUIFrame(el) {
var frame = document.createElement('iframe');
frame.className = 'ui-frame';
frame.src = uiEmbedURL(el.src);
frame.style.cssText = 'width:100%;height:100%;display:block;border:' + (el.frameBorder || 'none') +
';border-radius:' + (el.borderRadius || '0') + ';background:' + (el.frameBg || 'transparent') + ';';
if (el.frameTitle) frame.title = el.frameTitle;
frame.setAttribute('allow', el.frameAllow || 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture');
if (el.frameAllowFullscreen !== false) frame.setAttribute('allowfullscreen', '');
if (el.frameLazy) frame.setAttribute('loading', 'lazy');
if (el.frameSandbox) frame.setAttribute('sandbox', el.frameSandboxRules || 'allow-scripts allow-same-origin allow-popups allow-forms');
if (el.frameScroll === false) frame.setAttribute('scrolling', 'no');
return frame;
}
function renderUIFrame(container, el, live) {
container.innerHTML = '';
if (!el.src) {
container.innerHTML = '<div style="width:100%;height:100%;min-height:60px;display:flex;flex-direction:column;gap:4px;' +
'align-items:center;justify-content:center;background:rgba(0,0,0,0.25);border:1px dashed rgba(255,255,255,0.2);' +
'border-radius:' + (el.borderRadius || '8px') + ';"><i class="bi bi-window" style="font-size:1.5rem;opacity:0.5;"></i>' +
'<span style="font-size:0.5rem;opacity:0.6;">Set a page URL</span></div>';
return null;
}
// While designing, a placeholder avoids loading (and autoplaying) the real page
if (!live && el.framePreviewOnly !== false) {
var box = document.createElement('div');
box.className = 'ui-frame-stub';
box.style.cssText = 'width:100%;height:100%;display:flex;flex-direction:column;gap:6px;align-items:center;justify-content:center;' +
'background:' + (el.frameBg || 'rgba(0,0,0,0.35)') + ';border:' + (el.frameBorder || '1px solid rgba(255,255,255,0.12)') +
';border-radius:' + (el.borderRadius || '8px') + ';color:#cfd6e4;text-align:center;padding:8px;box-sizing:border-box;overflow:hidden;';
box.innerHTML = '<i class="bi bi-window-stack" style="font-size:1.4rem;opacity:0.7;"></i>' +
'<div style="font-size:0.5rem;font-weight:700;letter-spacing:1px;">EMBEDDED PAGE</div>' +
'<div style="font-size:0.45rem;opacity:0.7;word-break:break-all;max-width:90%;">' + escAttr(uiEmbedURL(el.src)) + '</div>' +
'<div style="font-size:0.4rem;opacity:0.5;">loads in Preview and in the player</div>';
container.appendChild(box);
return null;
}
var frame = buildUIFrame(el);
container.appendChild(frame);
return frame;
}
function controlUIFrame(dom, cmd, value) {
var frame = dom ? dom.querySelector('iframe') : null;
if (!frame) return;
switch (cmd || 'reload') {
case 'reload': frame.src = frame.src; break;
case 'setSrc': if (value) frame.src = uiEmbedURL(value); break;
case 'blank': frame.src = 'about:blank'; break;
}
}
/* ── Hero section (full-bleed backgrounds that cross-fade) ── */
function heroSlidesOf(el) {
var list = el.heroSlides || [];
return list.length ? list : [{ id: 'hs_empty', image: '', title: '', text: '' }];
}
function buildHeroCSS(el) {
var s = '.hero-' + el.id;
var dur = parseInt(el.heroTransDuration);
if (!dur && dur !== 0) dur = 900;
var trans = el.heroTransition || 'fade';
var pad = el.heroPadding || '6%';
var alignH = el.heroAlignH || 'center';
var alignV = el.heroAlignV || 'center';
var justify = alignV === 'top' ? 'flex-start' : alignV === 'bottom' ? 'flex-end' : 'center';
var items = alignH === 'left' ? 'flex-start' : alignH === 'right' ? 'flex-end' : 'center';
var textAlign = alignH === 'left' ? 'left' : alignH === 'right' ? 'right' : 'center';
// How an inactive slide sits before/after it becomes the active one
var restState, activeState;
if (trans === 'slide-left') { restState = 'transform:translateX(100%);'; activeState = 'transform:translateX(0);'; }
else if (trans === 'slide-right') { restState = 'transform:translateX(-100%);'; activeState = 'transform:translateX(0);'; }
else if (trans === 'slide-up') { restState = 'transform:translateY(100%);'; activeState = 'transform:translateY(0);'; }
else if (trans === 'zoom') { restState = 'opacity:0; transform:scale(1.15);'; activeState = 'opacity:1; transform:scale(1);'; }
else { restState = 'opacity:0;'; activeState = 'opacity:1;'; }
var out = [
s + ' { position:relative; width:100%; height:100%; overflow:hidden; }',
s + ' .hero-bgs { position:absolute; inset:0; overflow:hidden; }',
s + ' .hero-bg { position:absolute; inset:0; background-size:' + (el.heroBgFit || 'cover') +
'; background-position:' + (el.heroBgPosition || 'center') + '; background-repeat:no-repeat; ' +
restState + ' transition:opacity ' + dur + 'ms ease, transform ' + dur + 'ms ease; }',
s + ' .hero-bg.active { ' + activeState + ' z-index:1; }',
s + ' .hero-bg video { width:100%; height:100%; object-fit:' + (el.heroBgFit || 'cover') + '; display:block; }',
s + ' .hero-overlay { position:absolute; inset:0; z-index:2; pointer-events:none; background:' +
(el.heroOverlay || 'none') + '; opacity:' + (el.heroOverlayOpacity !== undefined ? el.heroOverlayOpacity : 1) + '; }',
s + ' .hero-content { position:absolute; inset:0; z-index:3; display:flex; flex-direction:column; box-sizing:border-box;' +
' justify-content:' + justify + '; align-items:' + items + '; text-align:' + textAlign +
'; padding:' + pad + '; gap:' + (el.heroGap || '10px') + '; pointer-events:none; }',
s + ' .hero-content > * { pointer-events:auto; max-width:' + (el.heroMaxWidth || '100%') + '; }',
s + ' .hero-title { font-family:' + (el.heroTitleFont || el.fontFamily || "'Orbitron',sans-serif") +
'; font-size:' + (el.heroTitleSize !== undefined ? el.heroTitleSize : 2.4) + (el.heroTitleUnit || 'rem') +
'; font-weight:' + (el.heroTitleWeight || '700') + '; color:' + (el.heroTitleColor || '#ffffff') +
'; line-height:' + (el.heroTitleLineHeight || '1.1') + '; letter-spacing:' + (el.heroTitleSpacing || 'normal') +
'; text-shadow:' + (el.heroTitleShadow || 'none') + '; margin:0; }',
s + ' .hero-sub { font-family:' + (el.heroSubFont || el.fontFamily || "'Rajdhani',sans-serif") +
'; font-size:' + (el.heroSubSize !== undefined ? el.heroSubSize : 1) + (el.heroSubUnit || 'rem') +
'; font-weight:' + (el.heroSubWeight || '400') + '; color:' + (el.heroSubColor || '#d0d8e4') +
'; line-height:' + (el.heroSubLineHeight || '1.5') + '; text-shadow:' + (el.heroSubShadow || 'none') + '; margin:0; }',
s + ' .hero-children { position:absolute; inset:0; z-index:4; pointer-events:none; }',
s + ' .hero-dots { position:absolute; z-index:5; left:0; right:0; bottom:' + (parseFloat(el.heroDotBottom) || 18) +
'px; display:flex; justify-content:center; align-items:center; gap:' + (parseFloat(el.heroDotGap) || 8) + 'px; }',
s + ' .hero-dot { width:' + (parseFloat(el.heroDotWidth) || 10) + 'px; height:' + (parseFloat(el.heroDotHeight) || 10) +
'px; border-radius:' + (el.heroDotRadius || '50%') + '; background:' + (el.heroDotColor || 'rgba(255,255,255,0.35)') +
'; cursor:pointer; transition:all 0.25s; border:none; padding:0; }',
s + ' .hero-dot.active { background:' + (el.heroDotActiveColor || '#ffffff') + ';' +
(el.heroDotActiveWidth ? ' width:' + parseFloat(el.heroDotActiveWidth) + 'px;' : '') + ' }',
s + ' .hero-arrow { position:absolute; z-index:5; top:50%; transform:translateY(-50%); width:' +
(parseFloat(el.heroArrowSize) || 36) + 'px; height:' + (parseFloat(el.heroArrowSize) || 36) +
'px; border-radius:' + (el.heroArrowRadius || '50%') + '; background:' + (el.heroArrowBg || 'rgba(0,0,0,0.35)') +
'; color:' + (el.heroArrowColor || '#fff') + '; border:none; cursor:pointer; display:flex; align-items:center; justify-content:center; font-size:' +
(el.heroArrowIconSize !== undefined ? el.heroArrowIconSize : 1) + 'rem; }',
s + ' .hero-arrow.prev { left:' + (parseFloat(el.heroArrowInset) || 12) + 'px; }',
s + ' .hero-arrow.next { right:' + (parseFloat(el.heroArrowInset) || 12) + 'px; }'
];
if (el.heroKenBurns) {
out.push('@keyframes heroKen-' + el.id + ' { from { transform:scale(1); } to { transform:scale(' +
(el.heroKenScale || 1.12) + '); } }');
out.push(s + ' .hero-bg.active { animation:heroKen-' + el.id + ' ' +
((parseInt(el.heroInterval) || 6000) + dur) + 'ms ease-out forwards; }');
}
return out.join('\\n');
}
function applyHeroCSS(el) {
var st = document.getElementById('hero-style-' + el.id);
if (!st) { st = document.createElement('style'); st.id = 'hero-style-' + el.id; document.head.appendChild(st); }
st.textContent = buildHeroCSS(el);
}
function heroShowSlide(wrap, el, index, live) {
var slides = heroSlidesOf(el);
var n = slides.length;
if (!n) return;
index = ((index % n) + n) % n;
wrap._heroIndex = index;
var bgs = wrap.querySelectorAll('.hero-bg');
Array.prototype.forEach.call(bgs, function(b, i) { b.classList.toggle('active', i === index); });
var dots = wrap.querySelectorAll('.hero-dot');
Array.prototype.forEach.call(dots, function(d, i) { d.classList.toggle('active', i === index); });
// Per-slide headings fall back to the hero's own text
var slide = slides[index] || {};
var t = wrap.querySelector('.hero-title');
var sub = wrap.querySelector('.hero-sub');
if (t) {
var title = slide.title !== undefined && slide.title !== '' ? slide.title : (el.heroTitle || '');
t.innerHTML = title;
t.style.display = title ? '' : 'none';
}
if (sub) {
var text = slide.text !== undefined && slide.text !== '' ? slide.text : (el.heroSubtitle || '');
sub.innerHTML = text;
sub.style.display = text ? '' : 'none';
}
// Videos only play while their slide is on screen
Array.prototype.forEach.call(bgs, function(b, i) {
var v = b.querySelector('video');
if (!v) return;
if (i === index && live) { v.play().catch(function(){}); }
else { v.pause(); }
});
if (live && el.heroChangeFunc && typeof window[el.heroChangeFunc] === 'function') {
try { window[el.heroChangeFunc](slide, index, el); } catch(e) { console.warn('[Hero]', e); }
}
}
function heroStopAuto(wrap) {
if (wrap && wrap._heroTimer) { window.clearInterval(wrap._heroTimer); wrap._heroTimer = null; }
}
function heroStartAuto(wrap, el, live) {
heroStopAuto(wrap);
if (!live || !el.heroAutoplay) return;
var slides = heroSlidesOf(el);
if (slides.length < 2) return;
wrap._heroTimer = window.setInterval(function() {
if (!document.body.contains(wrap)) { heroStopAuto(wrap); return; }
heroShowSlide(wrap, el, (wrap._heroIndex || 0) + 1, live);
}, Math.max(600, parseInt(el.heroInterval) || 6000));
}
function renderUIHero(container, el, live) {
var prev = container.querySelector('.hero-wrap');
if (prev) heroStopAuto(prev);
container.innerHTML = '';
applyHeroCSS(el);
var wrap = document.createElement('div');
wrap.className = 'hero-wrap hero-' + el.id;
var slides = heroSlidesOf(el);
var bgs = document.createElement('div');
bgs.className = 'hero-bgs';
slides.forEach(function(sl, i) {
var b = document.createElement('div');
b.className = 'hero-bg';
if (sl.video) {
var v = document.createElement('video');
v.src = sl.video; v.muted = true; v.loop = true; v.playsInline = true; v.preload = 'auto';
b.appendChild(v);
} else if (sl.image) {
b.style.backgroundImage = 'url(' + sl.image + ')';
} else {
b.style.background = sl.color || (el.heroEmptyBg || 'linear-gradient(135deg,#1b2030,#0b0e17)');
}
bgs.appendChild(b);
});
wrap.appendChild(bgs);
var ov = document.createElement('div');
ov.className = 'hero-overlay';
wrap.appendChild(ov);
var content = document.createElement('div');
content.className = 'hero-content';
var title = document.createElement('div');
title.className = 'hero-title';
var sub = document.createElement('div');
sub.className = 'hero-sub';
content.appendChild(title);
content.appendChild(sub);
wrap.appendChild(content);
// Anything parented onto the hero lands here
var kids = document.createElement('div');
kids.className = 'hero-children gui-children ui-el-children';
wrap.appendChild(kids);
if (el.heroArrows && slides.length > 1) {
['prev', 'next'].forEach(function(dir) {
var b = document.createElement('button');
b.className = 'hero-arrow ' + dir;
b.innerHTML = '<i class="bi ' + (dir === 'prev'
? (el.heroPrevIcon || 'bi-chevron-left')
: (el.heroNextIcon || 'bi-chevron-right')) + '"></i>';
b.addEventListener('click', function(e) {
e.stopPropagation();
heroShowSlide(wrap, el, (wrap._heroIndex || 0) + (dir === 'next' ? 1 : -1), live);
heroStartAuto(wrap, el, live);
});
wrap.appendChild(b);
});
}
if (el.heroDots && slides.length > 1) {
var dotBox = document.createElement('div');
dotBox.className = 'hero-dots';
slides.forEach(function(sl, i) {
var dt = document.createElement('button');
dt.className = 'hero-dot';
dt.addEventListener('click', function(e) {
e.stopPropagation();
heroShowSlide(wrap, el, i, live);
heroStartAuto(wrap, el, live);
});
dotBox.appendChild(dt);
});
wrap.appendChild(dotBox);
}
wrap._heroEl = el;
wrap._heroLive = live;
container.appendChild(wrap);
heroShowSlide(wrap, el, parseInt(el.heroStart) || 0, live);
heroStartAuto(wrap, el, live);
return wrap;
}
function controlUIHero(dom, cmd, index) {
var wrap = dom ? dom.querySelector('.hero-wrap') : null;
if (!wrap) return;
var el = wrap._heroEl;
if (!el) return;
switch (cmd || 'next') {
case 'next': heroShowSlide(wrap, el, (wrap._heroIndex || 0) + 1, true); break;
case 'prev': heroShowSlide(wrap, el, (wrap._heroIndex || 0) - 1, true); break;
case 'goTo': heroShowSlide(wrap, el, parseInt(index) || 0, true); break;
case 'play': heroStartAuto(wrap, el, true); break;
case 'stop': heroStopAuto(wrap); break;
}
}
/* ── Video chroma key (colour → transparency) ── */
function uiHexToRgb(hex) {
var h = String(hex || '#00ff00').trim();
if (h.charAt(0) === '#') h = h.slice(1);
if (h.length === 3) h = h.charAt(0) + h.charAt(0) + h.charAt(1) + h.charAt(1) + h.charAt(2) + h.charAt(2);
var n = parseInt(h, 16);
if (isNaN(n)) return { r: 0, g: 255, b: 0 };
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
function stopUIVideoChroma(container) {
if (!container) return;
var old = container.querySelector('canvas.ui-chroma');
if (old) { old._stop = true; old.remove(); }
}
/**
* Replaces the <video> with a canvas whose keyed colour is made transparent.
* Falls back to the plain video if the source taints the canvas (cross-origin).
*/
function setupUIVideoChroma(container, vid, el) {
stopUIVideoChroma(container);
if (!container || !vid) return;
vid.style.removeProperty('display');
if (!el.chromaKey) return;
var canvas = document.createElement('canvas');
canvas.className = 'ui-chroma';
canvas.style.cssText = vid.style.cssText;
canvas.style.background = 'transparent';
var ctx = null;
try { ctx = canvas.getContext('2d', { willReadFrequently: true }); } catch(e) {}
if (!ctx) return;
vid.style.display = 'none';
container.appendChild(canvas);
var key = uiHexToRgb(el.chromaColor || '#00ff00');
var sim = el.chromaSimilarity !== undefined ? parseFloat(el.chromaSimilarity) : 0.4;
var smooth = el.chromaSmoothness !== undefined ? parseFloat(el.chromaSmoothness) : 0.08;
var spill = el.chromaSpill !== undefined ? parseFloat(el.chromaSpill) : 0;
var lastTime = -1, lastReady = -1;
function draw() {
if (canvas._stop || !canvas.isConnected) return;
var w = vid.videoWidth, h = vid.videoHeight;
// Only re-key when there is a new frame (a paused video stays cheap)
if (w && h && (vid.currentTime !== lastTime || vid.readyState !== lastReady)) {
lastTime = vid.currentTime; lastReady = vid.readyState;
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
try {
ctx.clearRect(0, 0, w, h);
ctx.drawImage(vid, 0, 0, w, h);
var img = ctx.getImageData(0, 0, w, h), d = img.data;
var range = Math.max(0.0001, smooth);
for (var i = 0; i < d.length; i += 4) {
var dr = d[i] - key.r, dg = d[i + 1] - key.g, db = d[i + 2] - key.b;
var dist = Math.sqrt(dr * dr + dg * dg + db * db) / 441.6729;
if (dist < sim) {
d[i + 3] = 0;
} else if (dist < sim + range) {
var a = (dist - sim) / range;
d[i + 3] = d[i + 3] * a;
if (spill > 0) { // pull the key colour out of the soft edge
d[i] = d[i] + (d[i] - key.r) * spill * (1 - a);
d[i + 1] = d[i + 1] + (d[i + 1] - key.g) * spill * (1 - a);
d[i + 2] = d[i + 2] + (d[i + 2] - key.b) * spill * (1 - a);
}
}
}
ctx.putImageData(img, 0, 0);
} catch(err) {
// Tainted canvas: the browser refuses to read frames from a cross-origin or
// file:// video. Try once with CORS, then fall back to the untouched video.
if (!vid._chromaRetried && /^https?:/i.test(vid.src)) {
vid._chromaRetried = true;
var wasTime = vid.currentTime, wasPlaying = !vid.paused;
vid.crossOrigin = 'anonymous';
vid.load();
vid.addEventListener('loadeddata', function once() {
vid.removeEventListener('loadeddata', once);
try { vid.currentTime = wasTime; } catch(e) {}
if (wasPlaying) vid.play().catch(function(){});
});
lastTime = -1; lastReady = -1;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(draw);
return;
}
console.warn('[Video] chroma key unavailable for this source (' + (err && err.message) + '). ' +
'Frames cannot be read from a file:// or cross-origin video — embed the video in the export, ' +
'or serve it from the same origin with CORS enabled.');
canvas._stop = true;
canvas.remove();
vid.style.removeProperty('display');
return;
}
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(draw);
else window.setTimeout(draw, 40);
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(draw);
else window.setTimeout(draw, 40);
}
function buildUIVideo(el) {
const vid = document.createElement('video');
vid.className = 'gui-video';
vid.src = el.src;
vid.playsInline = true;
vid.preload = 'auto';
if (el.videoPoster) vid.poster = el.videoPoster;
vid.muted = el.muted !== false;
vid.volume = Math.max(0, Math.min(1, el.volume !== undefined ? el.volume : 1));
vid.loop = el.loopMode === 'loop';
if (el.controls) vid.controls = true;
var vfit = el.videoFit || 'contain';
var vcss = 'display:block;border-radius:' + (el.borderRadius || 0) + ';';
if (vfit === 'fit-width') vcss += 'width:100%;height:auto;';
else if (vfit === 'fit-height') vcss += 'height:100%;width:auto;';
else vcss += 'width:100%;height:100%;object-fit:' + (vfit === 'stretch' ? 'fill' : vfit) + ';';
vid.style.cssText = vcss;
if (el.loopMode === 'count') {
var vPlays = 1;
vid.addEventListener('ended', function() {
if (vPlays < (parseInt(el.loopCount) || 1)) { vPlays++; vid.currentTime = 0; vid.play().catch(function(){}); }
});
}
if ((el.playMode || 'auto') === 'auto') {
vid.autoplay = true;
var pr = vid.play(); if (pr && pr.catch) pr.catch(function(){});
} else if (el.playMode === 'click' && !el.controls) {
vid.style.cursor = 'pointer';
vid.addEventListener('click', function() { if (vid.paused) vid.play().catch(function(){}); else vid.pause(); });
}
return vid;
}
function controlUIVideo(dom, cmd) {
const vid = dom ? dom.querySelector('video') : null;
if (!vid) return;
switch (cmd || 'play') {
case 'play': vid.play().catch(function(){}); break;
case 'pause': vid.pause(); break;
case 'toggle': vid.paused ? vid.play().catch(function(){}) : vid.pause(); break;
case 'stop': vid.pause(); try { vid.currentTime = 0; } catch(e) {} break;
case 'restart': try { vid.currentTime = 0; } catch(e) {} vid.play().catch(function(){}); break;
}
}
function createUIElement(el) {
const dom = document.createElement('div');
dom.className = 'gui-el';
dom.id = 'gui-' + el.id;
const anchor = el.anchor || 'center';
dom.style.left = el.x + '%';
dom.style.top = el.y + '%';
dom.style.transform = uiAnchorTransform(anchor);
dom.style.width = el.w > 0 ? el.w + '%' : 'auto';
dom.style.height = el.h > 0 ? el.h + '%' : 'auto';
dom.style.zIndex = el.zIndex || 10;
dom.style.opacity = el.opacity !== undefined ? el.opacity : 1;
if (!el.visible) dom.classList.add('gui-hidden');
const inner = document.createElement('div');
inner.className = 'gui-inner';
inner.style.width = '100%'; inner.style.height = '100%';
const styleProps = ['fontFamily','fontSize','fontWeight','color','background','borderRadius',
'padding','border','textAlign','textShadow','boxShadow','letterSpacing','lineHeight',
'textTransform','backdropFilter','objectFit','aspectRatio','minWidth','minHeight',
'cursor','display','justifyContent','alignItems','borderTop','overflow'];
styleProps.forEach(prop => {
if (el[prop] && el[prop] !== '0' && el[prop] !== '' && el[prop] !== 'none' && el[prop] !== 'normal') {
if (prop === 'fontSize') inner.style[prop] = el[prop] + (el.fontUnit || 'rem');
else inner.style[prop] = el[prop];
}
});
inner.style.overflow = (el.type === 'panel' || el.type === 'carousel') ? 'visible' : 'hidden';
if (el.type === 'hero') inner.style.position = 'relative';
if (el.type === 'panel') inner.style.position = 'relative';
// Custom CSS
if (el.customCSS) {
try {
el.customCSS.split(';').filter(p => p.includes(':')).forEach(p => {
const [prop, ...rest] = p.split(':');
const cssProp = prop.trim();
const val = rest.join(':').trim();
if (cssProp && val) {
const camel = cssProp.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
inner.style[camel] = val;
}
});
} catch(e) {}
}
// Animation sequence (entrance → attention → exit); legacy animateClass is treated as an entrance
if (uiAnimHasSequence(el) && el.animTrigger !== 'manual') {
window.setTimeout(function() { runUIAnimSequence(inner, el, 'all'); }, 0);
}
// Type-specific content
if (el.type === 'text' || el.type === 'button') {
inner.textContent = resolveBindingText(el.content, el.binding);
if (el.type === 'button') inner.style.cursor = el.cursor || 'pointer';
} else if (el.type === 'image') {
if (el.src && el.objectFit === 'background') {
inner.style.backgroundImage = 'url(' + el.src + ')';
inner.style.backgroundSize = el.bgSize || 'cover';
inner.style.backgroundPosition = el.bgPosition || 'center';
inner.style.backgroundRepeat = el.bgRepeat || 'no-repeat';
} else if (el.src) {
const img = document.createElement('img');
img.src = el.src;
img.style.cssText = 'width:100%;height:100%;object-fit:' + (el.objectFit||'contain') + ';border-radius:' + (el.borderRadius||0) + ';display:block;';
img.draggable = false;
inner.appendChild(img);
}
} else if (el.type === 'icon') {
const i = document.createElement('i');
i.className = 'bi ' + (el.iconClass || 'bi-circle');
i.style.fontSize = (el.fontSize || 2) + (el.fontUnit || 'rem');
i.style.color = el.color || '#ffffff';
if (el.textShadow) i.style.textShadow = el.textShadow;
inner.appendChild(i);
} else if (el.type === 'video') {
if (el.src) {
const vid = buildUIVideo(el);
if (el.videoFit === 'fit-width' || el.videoFit === 'fit-height') {
inner.style.display = 'flex'; inner.style.alignItems = 'center'; inner.style.justifyContent = 'center';
}
if (el.videoBlend && el.videoBlend !== 'none') vid.style.mixBlendMode = el.videoBlend;
inner.appendChild(vid);
if (el.chromaKey) setupUIVideoChroma(inner, vid, el);
}
} else if (el.type === 'carousel') {
inner.style.display = 'flex'; inner.style.alignItems = 'center'; inner.style.overflow = 'visible';
renderUICarousel(inner, el, true);
} else if (el.type === 'hero') {
inner.style.overflow = 'hidden'; inner.style.position = 'relative';
renderUIHero(inner, el, true);
} else if (el.type === 'iframe') {
renderUIFrame(inner, el, true);
} else if (el.type === 'model3d') {
inner.style.overflow = 'hidden';
renderUIModel(inner, el, true);
} else if (el.type === 'progressbar') {
const pct = Math.max(0, Math.min(100, el.barValue || 0));
const track = document.createElement('div');
track.className = 'gui-progressbar-track';
track.style.height = el.barHeight || '12px';
track.style.background = el.barBgColor || 'rgba(255,255,255,0.08)';
track.style.borderRadius = el.borderRadius || '6px';
if (el.showLabel && el.labelText) {
const label = document.createElement('span');
label.className = 'gui-progressbar-label';
label.textContent = el.labelText;
track.appendChild(label);
}
const fill = document.createElement('div');
fill.className = 'gui-progressbar-fill';
fill.style.width = pct + '%';
fill.style.background = el.barColor || '#00e5a0';
fill.style.borderRadius = el.borderRadius || '6px';
fill.dataset.binding = el.binding || '';
track.appendChild(fill);
inner.appendChild(track);
} else if (el.type === 'panel') {
const childBox = document.createElement('div');
childBox.className = 'gui-children';
childBox.style.cssText = 'position:absolute;inset:0;pointer-events:none;';
inner.appendChild(childBox);
// D-Pad
if (el.controlMode === 'dpad') {
const cBg = el.controlBg || 'rgba(255,255,255,0.08)';
const cArrow = el.controlKnob || 'rgba(255,255,255,0.7)';
const cActive = el.controlKnob || 'rgba(0,229,160,0.7)';
const dpad = document.createElement('div');
dpad.className = 'gui-dpad';
dpad.style.cssText = 'position:absolute;inset:5%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;user-select:none;touch-action:none;z-index:5;';
const btnStyle = 'display:flex;align-items:center;justify-content:center;width:36%;aspect-ratio:1;border-radius:8px;background:' + cBg + ';border:1px solid rgba(255,255,255,0.15);color:' + cArrow + ';font-size:1.2rem;cursor:pointer;user-select:none;-webkit-user-select:none;touch-action:none;transition:background 0.1s;';
const midRow = document.createElement('div');
midRow.style.cssText = 'display:flex;gap:2px;width:100%;justify-content:center;';
function makeDir(key, icon, parent) {
const btn = document.createElement('div');
btn.style.cssText = btnStyle;
btn.innerHTML = '<i class="bi ' + icon + '"></i>';
const press = () => { if (window.playerKeys) window.playerKeys[key] = true; btn.style.background = cActive; };
const release = () => { if (window.playerKeys) window.playerKeys[key] = false; btn.style.background = cBg; };
btn.addEventListener('pointerdown', e => { e.preventDefault(); e.stopPropagation(); press(); });
btn.addEventListener('pointerup', e => { e.stopPropagation(); release(); });
btn.addEventListener('pointerleave', release);
btn.addEventListener('pointercancel', release);
parent.appendChild(btn);
}
makeDir('w', 'bi-caret-up-fill', dpad);
makeDir('a', 'bi-caret-left-fill', midRow);
makeDir('d', 'bi-caret-right-fill', midRow);
dpad.appendChild(midRow);
makeDir('s', 'bi-caret-down-fill', dpad);
inner.appendChild(dpad);
}
// Joystick
if (el.controlMode === 'joystick') {
const cBg = el.controlBg || 'rgba(255,255,255,0.04)';
const cKnob = el.controlKnob || 'rgba(0,229,160,0.3)';
const cKnobActive = el.controlKnob || 'rgba(0,229,160,0.5)';
inner.style.position = 'relative'; inner.style.touchAction = 'none'; inner.style.userSelect = 'none';
const base = document.createElement('div');
base.style.cssText = 'position:absolute;inset:10%;border-radius:50%;background:' + cBg + ';border:1px solid rgba(255,255,255,0.1);';
const knob = document.createElement('div');
knob.style.cssText = 'position:absolute;width:40%;height:40%;border-radius:50%;background:' + cKnob + ';border:2px solid ' + cKnobActive + ';left:50%;top:50%;transform:translate(-50%,-50%);cursor:grab;touch-action:none;transition:background 0.1s;';
base.appendChild(knob); inner.appendChild(base);
let dragging = false, baseRect = null;
const deadzone = 0.2;
knob.addEventListener('pointerdown', e => { e.preventDefault(); e.stopPropagation(); dragging = true; baseRect = base.getBoundingClientRect(); knob.setPointerCapture(e.pointerId); knob.style.background = cKnobActive; });
const onMove = e => {
if (!dragging || !baseRect) return;
const cx = baseRect.left + baseRect.width/2, cy = baseRect.top + baseRect.height/2, maxR = baseRect.width/2;
let dx = (e.clientX-cx)/maxR, dy = (e.clientY-cy)/maxR;
const dist = Math.sqrt(dx*dx+dy*dy);
if (dist>1) { dx/=dist; dy/=dist; }
knob.style.left = (50+dx*40)+'%'; knob.style.top = (50+dy*40)+'%';
if (window.playerKeys) { window.playerKeys.w=dy<-deadzone; window.playerKeys.s=dy>deadzone; window.playerKeys.a=dx<-deadzone; window.playerKeys.d=dx>deadzone; window.playerKeys.shift=dist>0.85; }
};
const onRelease = () => { if (!dragging) return; dragging=false; knob.style.left='50%'; knob.style.top='50%'; knob.style.background=cKnob; if(window.playerKeys){window.playerKeys.w=false;window.playerKeys.s=false;window.playerKeys.a=false;window.playerKeys.d=false;window.playerKeys.shift=false;}};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onRelease);
window.addEventListener('pointercancel', onRelease);
}
}
dom.appendChild(inner);
dom.style.pointerEvents = 'auto';
// Event-based action chains
const evts = el.events || {};
if (!el.events && el.actions) evts.click = el.actions;
if (el.onClick && (!evts.click || evts.click.length === 0)) {
evts.click = [{ type:'callFunction', funcName:el.onClick, delay:0 }];
}
const hasClick = evts.click && evts.click.length > 0;
const hasDbl = evts.dblclick && evts.dblclick.length > 0;
const hasDown = evts.mousedown && evts.mousedown.length > 0;
const hasUp = evts.mouseup && evts.mouseup.length > 0;
const hasHoverEvt = evts.hover && evts.hover.length > 0;
if (hasClick||hasDbl||hasDown||hasUp||hasHoverEvt) inner.style.cursor = inner.style.cursor || 'pointer';
if (hasClick) dom.addEventListener('click', () => executeActions(evts.click, el, evts.clickMode));
if (hasDbl) dom.addEventListener('dblclick', () => executeActions(evts.dblclick, el, evts.dblclickMode));
if (hasDown) dom.addEventListener('mousedown', () => executeActions(evts.mousedown, el, evts.mousedownMode));
if (hasUp) dom.addEventListener('mouseup', () => executeActions(evts.mouseup, el, evts.mouseupMode));
if (hasHoverEvt) dom.addEventListener('mouseenter', () => executeActions(evts.hover, el, evts.hoverMode));
// Hover/Active states
const origBg = el.background || '', origColor = el.color || '';
const hasHover = el.hoverBackground||el.hoverColor||el.hoverScale||el.hoverSrc||el.hoverBoxShadow;
const hasActive = el.activeBackground||el.activeColor||el.activeScale||el.activeSrc||el.activeBoxShadow;
const defaultBtnHover = (el.type==='button' && !hasHover);
inner.style.transition = 'all 0.15s ease';
inner.addEventListener('mouseenter', () => {
if (hasHover) {
if (el.hoverBackground) inner.style.background = el.hoverBackground;
if (el.hoverColor) inner.style.color = el.hoverColor;
if (el.hoverScale) dom.style.setProperty('--gui-scale', el.hoverScale);
if (el.hoverBoxShadow) inner.style.boxShadow = el.hoverBoxShadow;
if (el.hoverSrc && el.type==='image') { const img=inner.querySelector('img'); if(img)img.src=el.hoverSrc; }
} else if (defaultBtnHover) inner.style.filter = 'brightness(1.15)';
});
inner.addEventListener('mouseleave', () => {
if (hasHover) {
inner.style.background=origBg; inner.style.color=origColor; inner.style.boxShadow=el.boxShadow||'';
dom.style.removeProperty('--gui-scale');
if (el.hoverSrc && el.type==='image') { const img=inner.querySelector('img'); if(img)img.src=el.src; }
} else if (defaultBtnHover) inner.style.filter = '';
});
if (hasActive) {
inner.addEventListener('mousedown', () => {
if (el.activeBackground) inner.style.background=el.activeBackground;
if (el.activeColor) inner.style.color=el.activeColor;
if (el.activeScale) dom.style.setProperty('--gui-scale', el.activeScale);
if (el.activeBoxShadow) inner.style.boxShadow=el.activeBoxShadow;
if (el.activeSrc && el.type==='image') { const img=inner.querySelector('img'); if(img)img.src=el.activeSrc; }
});
inner.addEventListener('mouseup', () => {
inner.style.background = hasHover&&el.hoverBackground?el.hoverBackground:origBg;
inner.style.color = hasHover&&el.hoverColor?el.hoverColor:origColor;
inner.style.boxShadow = (hasHover&&el.hoverBoxShadow)?el.hoverBoxShadow:(el.boxShadow||'');
dom.style.removeProperty('--gui-scale');
});
}
return dom;
}
function executeActions(actions, sourceEl, mode) {
const isChain = mode === 'chain';
let cumDelay = 0;
actions.forEach((act, i) => {
if (isChain) {
cumDelay += (i > 0 ? (act.delay || 0) : 0);
}
const delay = isChain ? cumDelay : (act.delay || 0);
setTimeout(() => {
const targetId = act.targetId || sourceEl.id;
const entry = gameUIElements[targetId];
if (!entry && !['callFunction','addAnimation','removeAnimation','playSound','loadScene','playMusic','stopMusic','setVolume','loadURL','showHTML'].includes(act.type)) return;
switch (act.type) {
case 'show':
if (entry) { entry.dom.classList.remove('gui-hidden'); entry.data.visible = true; }
break;
case 'hide':
if (entry) { entry.dom.classList.add('gui-hidden'); entry.data.visible = false; }
break;
case 'toggle':
if (entry) { const h = entry.dom.classList.toggle('gui-hidden'); entry.data.visible = !h; }
break;
case 'setText':
if (entry) {
entry.data.content = act.value || '';
const inner = entry.dom.querySelector('.gui-inner');
if (inner) inner.textContent = resolveBindingText(act.value || '', entry.data.binding);
}
break;
case 'setProgress':
if (entry) { const f = entry.dom.querySelector('.gui-progressbar-fill'); if(f) f.style.width = Math.max(0,Math.min(100,act.value||0))+'%'; }
break;
case 'videoControl':
if (entry) controlUIVideo(entry.dom, act.videoCmd);
break;
case 'carouselControl':
if (entry) controlUICarousel(entry.dom, act.carCmd, act.carIndex);
break;
case 'heroControl':
if (entry) controlUIHero(entry.dom, act.heroCmd, act.heroIndex);
break;
case 'frameControl':
if (entry) controlUIFrame(entry.dom, act.frameCmd, act.frameValue);
break;
case 'modelControl':
if (entry) controlUIModel(entry.dom, act.modelCmd, act.modelValue);
break;
case 'playAnimation':
if (entry) {
const aInner = entry.dom.querySelector('.gui-inner');
if (aInner) runUIAnimSequence(aInner, entry.data, act.animStage || 'all');
}
break;
case 'playSound':
if (act.soundUrl) { try { const a=new Audio(act.soundUrl); a.volume=act.volume!==undefined?act.volume:1; a.play().catch(e=>{}); } catch(e){} }
break;
case 'callFunction':
if (act.funcName && typeof window[act.funcName] === 'function') {
let extraArgs = [];
if (act.funcArgs) { try { extraArgs = Function('return [' + act.funcArgs + ']')(); } catch(e){} }
window[act.funcName](...extraArgs, sourceEl, entry ? entry.data : null);
}
break;
case 'addAnimation':
if (entry && act.animClass) {
const aInner = entry.dom.querySelector('.gui-inner');
if (aInner) {
[...aInner.classList].filter(c=>c.startsWith('animate__')&&c!=='animate__animated').forEach(c=>aInner.classList.remove(c));
aInner.classList.remove('animate__animated');
void aInner.offsetWidth;
aInner.classList.add('animate__animated', act.animClass);
if (act.animDuration) aInner.style.setProperty('--animate-duration', act.animDuration);
else aInner.style.removeProperty('--animate-duration');
if (act.animDelay) aInner.style.animationDelay = act.animDelay;
else aInner.style.animationDelay = '';
if (act.animRepeat) aInner.style.animationIterationCount = act.animRepeat;
else aInner.style.animationIterationCount = '';
}
}
break;
case 'removeAnimation':
if (entry) {
const rInner = entry.dom.querySelector('.gui-inner');
if (rInner) {
if (act.animClass) {
rInner.classList.remove(act.animClass);
const has = [...rInner.classList].some(c=>c.startsWith('animate__')&&c!=='animate__animated');
if (!has) rInner.classList.remove('animate__animated');
} else {
[...rInner.classList].filter(c=>c.startsWith('animate__')).forEach(c=>rInner.classList.remove(c));
}
rInner.style.removeProperty('--animate-duration');
rInner.style.animationDelay = '';
rInner.style.animationIterationCount = '';
}
}
break;
case 'loadScene':
if (act.levelFile && window.GameAPI && window.GameAPI.loadScene) {
const params = {};
if (act.sceneParams) try { Object.assign(params, typeof act.sceneParams==='string'?JSON.parse(act.sceneParams):act.sceneParams); } catch(e){}
window.GameAPI.loadScene(act.levelFile, act.uiFile||null, params);
}
break;
case 'playMusic':
if (act.musicSrc && window.GameAPI && window.GameAPI.playMusic) window.GameAPI.playMusic(act.musicSrc, act.musicLoop!==false);
break;
case 'stopMusic':
if (window.GameAPI && window.GameAPI.stopMusic) window.GameAPI.stopMusic();
break;
case 'setVolume':
if (window.GameAPI && window.GameAPI.setMusicVolume) window.GameAPI.setMusicVolume(act.volume!==undefined?act.volume:0.5);
break;
case 'loadURL':
if (act.url) {
var tgt = act.urlTarget || 'newTab';
if (tgt==='same') window.location.href=act.url;
else if (tgt==='newTab') window.open(act.url,'_blank');
else if (tgt==='newWindow') window.open(act.url,'_blank','width=800,height=600');
else if (tgt==='iframe' && typeof showIframeOverlay==='function') showIframeOverlay(act.url,act.iframeBackIcon,act.iframeCloseSound);
}
break;
case 'showHTML':
if (act.htmlContent) {
if (act.htmlMode==='3d' && typeof show3DHTMLBillboard==='function') show3DHTMLBillboard(null,act.htmlContent,{bgColor:act.htmlBgColor,textColor:act.htmlTextColor});
else if (typeof showHTMLPopup==='function') showHTMLPopup(act.htmlContent,{bgColor:act.htmlBgColor,textColor:act.htmlTextColor});
}
break;
}
}, delay);
});
}
function updateGameUI() {
if (!gameUIData) return;
for (const [id, entry] of Object.entries(gameUIElements)) {
const el = entry.data;
if (el.binding && (el.type === 'text' || el.type === 'button')) {
const inner = entry.dom.querySelector('.gui-inner');
if (inner) inner.textContent = resolveBindingText(el.content, el.binding);
}
if (el.type === 'progressbar' && el.binding) {
const fill = entry.dom.querySelector('.gui-progressbar-fill');
if (fill) {
const val = gameUIState[el.binding] !== undefined ? gameUIState[el.binding] : el.barValue;
fill.style.width = Math.max(0, Math.min(100, val || 0)) + '%';
}
}
}
}
// ══════════════════════════════════════
// INIT — Build UI from data
// ══════════════════════════════════════
(function init() {
gameUIData = UI_DATA;
const overlay = document.getElementById('game-ui-overlay');
const parents = (UI_DATA.elements || []).filter(e => !e.parentId && !e.disabled);
const children = (UI_DATA.elements || []).filter(e => e.parentId && !e.disabled);
parents.forEach(el => {
const dom = createUIElement(el);
overlay.appendChild(dom);
gameUIElements[el.id] = { dom, data: el };
});
children.forEach(el => {
const parentEntry = gameUIElements[el.parentId];
const container = parentEntry ? (parentEntry.dom.querySelector('.gui-children') || parentEntry.dom.querySelector('.gui-inner .gui-children') || parentEntry.dom) : overlay;
const dom = createUIElement(el);
container.appendChild(dom);
dom.style.pointerEvents = 'auto';
gameUIElements[el.id] = { dom, data: el };
});
// Background image
if (UI_DATA.bgImage) {
const bg = document.createElement('div');
bg.style.cssText = 'position:fixed;inset:0;z-index:0;background-image:url(' + UI_DATA.bgImage + ');background-size:cover;background-position:center;';
document.body.insertBefore(bg, document.body.firstChild);
}
// Load game level if configured
if (CONFIG.gameLevelJson) {
fetch(CONFIG.gameLevelJson)
.then(r => r.json())
.then(levelData => {
console.log('[GameLevel] Loaded:', CONFIG.gameLevelJson, levelData);
// Initialize your 3D engine with levelData here
window.dispatchEvent(new CustomEvent('gamelevel-loaded', { detail: levelData }));
})
.catch(e => console.warn('[GameLevel] Could not load:', e));
}
applyUIStage(UI_DATA);
watchUIStage(function() { return UI_DATA; });
console.log('[UI] Initialized ' + Object.keys(gameUIElements).length + ' elements');
// Auto-call onReady() if defined in custom code
if (typeof onReady === 'function') onReady();
})();
<` + `/script>
</body>
</html>`;
}
document.getElementById('btn-load').onclick = () => document.getElementById('file-load').click();
document.getElementById('file-load').onchange = e => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
try {
const data = JSON.parse(ev.target.result);
loadUIData(data);
showToast(`Loaded "${data.name || 'UI'}"`);
} catch(err) {
showToast('Invalid file: ' + err.message, 'error');
}
};
reader.readAsText(file);
e.target.value = '';
};
function loadUIData(data) {
if (data && data.scaleMode) projectScaleMode = data.scaleMode;
elements = data.elements || [];
// Migrate & normalize
elements.forEach(el => {
if (!el.parentId) el.parentId = null;
// Migrate old actions[] → events.click[]
if (el.actions && !el.events) {
el.events = { click: el.actions, hover:[], mousedown:[], mouseup:[], dblclick:[] };
delete el.actions;
}
if (!el.events) el.events = { click:[], hover:[], mousedown:[], mouseup:[], dblclick:[] };
['click','hover','mousedown','mouseup','dblclick'].forEach(k => { if (!el.events[k]) el.events[k] = []; });
// Ensure overrides structure
if (!el.overrides) el.overrides = { tablet:{}, phone:{}, 'phone-land':{} };
['tablet','phone','phone-land'].forEach(d => { if (!el.overrides[d]) el.overrides[d] = {}; });
// Ensure showOn and controlMode fields
if (!el.showOn) el.showOn = [];
if (!el.controlMode) el.controlMode = 'none';
// Animation: migrate the old single animation into the entrance stage
if (el.animateClass && !el.animEnterClass && !el.animAttnClass && !el.animExitClass) {
el.animEnterClass = el.animateClass;
el.animEnterDelay = Math.round((parseFloat(el.animateDelay) || 0) * 1000);
el.animEnterDuration = Math.round((parseFloat(el.animateDuration) || 0.8) * 1000);
el.animEnterRepeat = el.animateRepeat || '1';
el.animateClass = '';
}
if (!el.fontUnit) el.fontUnit = 'rem';
if (!el.animTrigger) el.animTrigger = 'load';
const animDefs = { animEnterClass:'', animEnterDelay:0, animEnterDuration:800, animEnterRepeat:'1',
animAttnClass:'', animAttnDelay:400, animAttnDuration:1000, animAttnRepeat:'1',
animExitClass:'', animExitDelay:1000, animExitDuration:800, animExitRepeat:'1', animExitHide:true };
for (const [k, v] of Object.entries(animDefs)) if (el[k] === undefined) el[k] = v;
// 3D model defaults
if (el.type === 'model3d') {
const md = { modelBg:'transparent', modelAutoRotate:true, modelRotateSpeed:2, modelControls:false,
modelZoom:true, modelPan:false, modelAutoFit:true, modelDistance:4, modelFov:45,
modelDragRotate:true, modelDragSpeed:1, modelZoomSpeed:1, modelDamping:true,
modelStopSpinOnInteract:true, modelResumeSpin:0,
modelMinDistance:'', modelMaxDistance:'', modelMinPolar:'', modelMaxPolar:'',
modelCamHeight:0.4, modelTargetY:0, modelX:0, modelY:0, modelZ:0, modelScale:1,
modelRotX:0, modelRotY:0, modelRotZ:0, modelLighting:'studio', modelLightIntensity:1,
modelShadows:false, modelHemi:true, modelPlayClips:true, modelLiveOnCanvas:true };
for (const [k, v] of Object.entries(md)) if (el[k] === undefined) el[k] = v;
}
// Hero element defaults
if (el.type === 'hero') {
if (!Array.isArray(el.heroSlides)) el.heroSlides = [];
el.heroSlides.forEach(sl => { if (!sl.id) sl.id = 'hs_' + Math.random().toString(36).slice(2, 8); });
const hd = {
heroTitle:'', heroSubtitle:'', heroAutoplay:true, heroInterval:6000,
heroTransition:'fade', heroTransDuration:900, heroKenBurns:false, heroKenScale:1.12, heroStart:0,
heroBgFit:'cover', heroBgPosition:'center', heroEmptyBg:'linear-gradient(135deg,#1b2030,#0b0e17)',
heroOverlay:'linear-gradient(180deg,rgba(0,0,0,0.15),rgba(0,0,0,0.55))', heroOverlayOpacity:1,
heroAlignH:'center', heroAlignV:'center', heroPadding:'6%', heroGap:'10px', heroMaxWidth:'70%',
heroTitleFont:"'Orbitron',sans-serif", heroTitleSize:2.4, heroTitleUnit:'rem', heroTitleWeight:'700',
heroTitleColor:'#ffffff', heroTitleLineHeight:'1.1', heroTitleSpacing:'normal',
heroTitleShadow:'0 2px 18px rgba(0,0,0,0.5)',
heroSubFont:"'Rajdhani',sans-serif", heroSubSize:1, heroSubUnit:'rem', heroSubWeight:'400',
heroSubColor:'#d5dce8', heroSubLineHeight:'1.5', heroSubShadow:'0 1px 10px rgba(0,0,0,0.45)',
heroDots:true, heroDotWidth:10, heroDotHeight:10, heroDotRadius:'50%', heroDotGap:8,
heroDotBottom:18, heroDotColor:'rgba(255,255,255,0.35)', heroDotActiveColor:'#ffffff', heroDotActiveWidth:'',
heroArrows:false, heroArrowSize:36, heroArrowRadius:'50%', heroArrowBg:'rgba(0,0,0,0.35)',
heroArrowColor:'#ffffff', heroArrowIconSize:1, heroArrowInset:12,
heroPrevIcon:'bi-chevron-left', heroNextIcon:'bi-chevron-right', heroChangeFunc:'',
};
for (const [k, v] of Object.entries(hd)) if (el[k] === undefined) el[k] = v;
}
// Carousel element defaults
if (el.type === 'carousel') {
if (!Array.isArray(el.carouselItems)) el.carouselItems = [];
el.carouselItems.forEach(it => {
if (!it.id) it.id = 'ci_' + Math.random().toString(36).slice(2, 8);
if (!it.layer && it.model) it.layer = 'model';
else if (!it.layer && it.foreground) it.layer = 'image';
if (!Array.isArray(it.actions)) it.actions = [];
if (!it.fields || typeof it.fields !== 'object') it.fields = {};
});
if (!el.carDeviceOverrides) el.carDeviceOverrides = { tablet:{}, phone:{}, 'phone-land':{} };
['tablet','phone','phone-land'].forEach(dv => { if (!el.carDeviceOverrides[dv]) el.carDeviceOverrides[dv] = {}; });
if (el.carNavInset === undefined) el.carNavInset = el.carNavOffset !== undefined ? el.carNavOffset : 0;
const cd = {
carItems: 3, carItemsTablet: 2, carItemsPhone: 1, carMargin: 12, carStagePadding: 0,
carAutoWidth: false, carLoop: true, carCenter: true, carAutoplay: false,
carAutoplayTimeout: 4000, carAutoplayHoverPause: true, carSpeed: 450, carMouseDrag: true,
carNav: true, carDots: true, carStart: 0,
carCenterScale: 1.18, carSideScale: 0.88, carSideOpacity: 0.55,
carCenterOffsetX: 0, carCenterOffsetY: 0, carCenterWidth: '', carCenterHeight: '',
carItemWidth: '', carItemHeight: '', carClickToCenter: true, carLinks: [], carChangeFunc: '',
carActiveSlot: '', carItemsPhoneLand: '', carDotPosition: 'below', carDotBottom: 8, carResetVisibility: true,
carActiveZ: 10, carPadTop: 0, carPadBottom: 0,
carNavOffsetX: 0, carNavOffsetY: 0, carDotOffsetX: 0, carDotOffsetY: 0,
carNavPrevX: 0, carNavPrevY: 0, carNavNextX: 0, carNavNextY: 0,
carItemMargin: '0', carCenterPadding: '', carCenterMargin: '', carCenterRadius: '',
carCenterBg: '', carCenterBorder: '', carCenterShadow: '',
carShowFields: [], carFieldSize: 0.6, carFieldColor: '#8a93a8',
carModelHeight: '', carModelBg: 'transparent', carModelAutoRotate: true, carModelRotateSpeed: 2,
carModelControls: false, carModelDistance: 4, carModelFov: 45, carModelLighting: 'studio',
carModelDragRotate: true, carModelZoom: false, carModelPan: false,
carModelDragSpeed: 1, carModelZoomSpeed: 1, carModelDamping: true,
carModelStopSpinOnInteract: true, carModelResumeSpin: 0,
carModelMinDistance: '', carModelMaxDistance: '', carModelMinPolar: '', carModelMaxPolar: '',
carModelLightIntensity: 1, carModelScale: 1,
carModelWidth: '', carModelInset: 0, carModelRotX: 0, carModelRotY: 0, carModelRotZ: 0,
carModelY: 0, carModelCamHeight: 0.4, carModelSpinActiveOnly: false,
carModelActiveRotX: '', carModelActiveRotY: '', carModelActiveRotZ: '',
carModelActiveScale: '', carModelActiveDistance: '',
carModelSideRotX: '', carModelSideRotY: '', carModelSideRotZ: '',
carModelSideScale: '', carModelSideDistance: '',
carSideGap: 0, carSideGapStacks: true,
carTitleUnit: 'rem', carTextUnit: 'rem', carFieldUnit: 'rem',
carNavSize: 28, carNavRadius: '50%', carNavIconSize: 0.8, carNavBorder: 'none', carNavOffset: 0,
carNavPrevIcon: 'bi-chevron-left', carNavNextIcon: 'bi-chevron-right',
carDotWidth: 7, carDotHeight: 7, carDotRadius: '50%', carDotGap: 6,
carDotActiveWidth: '', carDotActiveHeight: '', carDotMarginTop: 6,
carItemBg: 'rgba(255,255,255,0.05)', carItemBorder: '1px solid rgba(255,255,255,0.1)',
carItemRadius: '12px', carItemPadding: '10px', carItemShadow: '0 6px 20px rgba(0,0,0,0.35)',
carItemAlign: 'center', carShowImage: true, carShowTitle: true, carShowText: true,
carImgHeight: '110px', carImgFit: 'cover', carImgRadius: '8px',
carImgMode: 'inline', carImgPosition: 'center', carCoverAlign: 'bottom',
carFgInset: 12, carFgFit: 'contain', carFgPosition: 'center', carFgRadius: '0', carFgShadow: '',
carFgPadding: 0, carFgWidth: '100%', carFgHeight: '100%', carFgAlignH: 'center', carFgAlignV: 'center',
carModelX: 0, carModelZ: 0,
carTitleSize: 0.85, carTitleColor: '#ffffff', carTitleWeight: '700',
carTextSize: 0.65, carTextColor: '#a0b0c0',
carNavColor: '#ffffff', carNavBg: 'rgba(0,0,0,0.4)',
carDotColor: 'rgba(255,255,255,0.25)', carDotActiveColor: '#00e5a0',
carItemTemplate: '', carItemClick: 'none', carItemFunc: '', carLinkTarget: 'newTab',
};
for (const [k, v] of Object.entries(cd)) if (el[k] === undefined) el[k] = v;
}
// Video element defaults
if (el.type === 'video') {
if (!el.videoFit) el.videoFit = 'contain';
if (!el.playMode) el.playMode = 'auto';
if (!el.loopMode) el.loopMode = 'loop';
if (el.loopCount === undefined) el.loopCount = 1;
if (el.muted === undefined) el.muted = true;
if (el.volume === undefined) el.volume = 1;
if (el.videoPoster === undefined) el.videoPoster = '';
if (el.chromaKey === undefined) el.chromaKey = false;
if (el.chromaColor === undefined) el.chromaColor = '#00ff00';
if (el.chromaSimilarity === undefined) el.chromaSimilarity = 0.4;
if (el.chromaSmoothness === undefined) el.chromaSmoothness = 0.08;
if (el.chromaSpill === undefined) el.chromaSpill = 0.1;
if (el.videoBlend === undefined) el.videoBlend = 'none';
}
// Migrate old mobileX/Y/W/H/etc → overrides.phone
if (el.mobileX || el.mobileY || el.mobileW || el.mobileH || el.mobileFontSize || el.mobilePadding || el.mobileBackground || el.mobileHidden) {
const ph = el.overrides.phone;
if (el.mobileX) ph.x = el.mobileX;
if (el.mobileY) ph.y = el.mobileY;
if (el.mobileW) ph.w = el.mobileW;
if (el.mobileH) ph.h = el.mobileH;
if (el.mobileFontSize) ph.fontSize = el.mobileFontSize;
if (el.mobilePadding) ph.padding = el.mobilePadding;
if (el.mobileBackground) ph.background = el.mobileBackground;
if (el.mobileHidden) ph.visible = false;
el.responsive = true;
// Clean old fields
delete el.mobileX; delete el.mobileY; delete el.mobileW; delete el.mobileH;
delete el.mobileFontSize; delete el.mobilePadding; delete el.mobileBackground; delete el.mobileHidden;
}
});
uiName = data.name || 'Game HUD';
bgImage = data.bgImage || '';
customCode = data.customCode || '';
blocklyFuncData = data.blocklyFunctions || {};
blocklyCurrentFunc = '';
customBlocks = data.customBlocks || [];
if (blocklyWorkspace) { blocklyWorkspace.clear(); refreshBlocklyFuncSelect(); registerCustomBlocks(); }
document.getElementById('ui-name').value = uiName;
// Recalc nextId
nextId = 1;
elements.forEach(el => {
const m = el.id.match(/_(\d+)$/);
if (m) nextId = Math.max(nextId, parseInt(m[1]) + 1);
});
deselectAll();
renderAllElements();
buildLayerList();
resizeViewport();
}
// ── Drag & drop JSON file onto canvas ──
document.body.addEventListener('dragover', e => e.preventDefault());
document.body.addEventListener('drop', e => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file && file.name.endsWith('.json')) {
const reader = new FileReader();
reader.onload = ev => {
try {
const data = JSON.parse(ev.target.result);
if (data.type === 'game-ui') { loadUIData(data); showToast(`Loaded "${data.name}"`); }
} catch(err) { showToast('Invalid file', 'error'); }
};
reader.readAsText(file);
}
});
// ══════════════════════════════════════
// KEYBOARD SHORTCUTS
// ══════════════════════════════════════
document.addEventListener('keydown', e => {
// Don't capture when typing in inputs
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
if (e.key === 'Delete' || e.key === 'Backspace') {
if (selectedId) { removeElement(selectedId); e.preventDefault(); }
}
if (e.key === 'Escape') {
deselectAll();
if (previewMode) { document.getElementById('btn-preview').click(); }
}
if ((e.ctrlKey || e.metaKey) && e.key === 'd') {
e.preventDefault();
if (selectedId) duplicateElement(selectedId);
}
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
document.getElementById('btn-save').click();
}
// Copy
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
if (selectedId) {
const src = elements.find(el => el.id === selectedId);
if (src) { clipboard = { ...JSON.parse(JSON.stringify(src)), _cut: false }; showToast('Copied'); }
e.preventDefault();
}
}
// Cut
if ((e.ctrlKey || e.metaKey) && e.key === 'x') {
if (selectedId) {
const src = elements.find(el => el.id === selectedId);
if (src) { clipboard = { ...JSON.parse(JSON.stringify(src)), _cut: true, _origId: src.id }; showToast('Cut'); }
e.preventDefault();
}
}
// Paste
if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
if (clipboard) {
e.preventDefault();
if (clipboard._cut) {
// Remove original if still exists
const origIdx = elements.findIndex(el => el.id === clipboard._origId);
if (origIdx >= 0) {
const origDom = viewport.querySelector(`[data-id="${clipboard._origId}"]`);
if (origDom) origDom.remove();
elements.splice(origIdx, 1);
}
}
const copy = JSON.parse(JSON.stringify(clipboard));
copy.id = 'el_' + (nextId++);
copy.name = (copy.name || '').replace(/ copy$/, '') + ' copy';
if (!clipboard._cut) { copy.x = Math.min(copy.x + 2, 98); copy.y = Math.min(copy.y + 2, 98); }
delete copy._cut; delete copy._origId;
copy.zIndex = 10 + elements.length;
elements.push(copy);
renderAllElements();
buildLayerList();
selectElement(copy.id);
showToast('Pasted');
if (clipboard._cut) clipboard = null; // clear cut buffer after paste
}
}
// Arrow keys nudge
if (selectedId && ['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.key)) {
const el = elements.find(e => e.id === selectedId);
if (el && !el.locked) {
e.preventDefault();
const step = e.shiftKey ? 5 : 1;
if (e.key === 'ArrowLeft') setVal(el, 'x', Math.max(0, getVal(el,'x') - step));
if (e.key === 'ArrowRight') setVal(el, 'x', Math.min(100, getVal(el,'x') + step));
if (e.key === 'ArrowUp') setVal(el, 'y', Math.max(0, getVal(el,'y') - step));
if (e.key === 'ArrowDown') setVal(el, 'y', Math.min(100, getVal(el,'y') + step));
buildPropsPanel(el);
}
}
});
// ══════════════════════════════════════
// SETTINGS
// ══════════════════════════════════════
document.getElementById('btn-settings').onclick = () => {
document.getElementById('settings-overlay').classList.add('visible');
document.getElementById('set-custom-code').value = customCode;
document.getElementById('set-theme').checked = document.documentElement.getAttribute('data-theme') === 'light';
document.getElementById('set-snap').checked = snapEnabled;
document.getElementById('set-grid').checked = viewport.classList.contains('show-grid');
};
document.getElementById('set-close').onclick = () => {
document.getElementById('settings-overlay').classList.remove('visible');
};
document.getElementById('settings-overlay').onclick = (e) => {
if (e.target === document.getElementById('settings-overlay')) {
document.getElementById('settings-overlay').classList.remove('visible');
}
};
document.getElementById('set-theme').onchange = function() {
document.documentElement.setAttribute('data-theme', this.checked ? 'light' : 'dark');
};
document.getElementById('set-snap').onchange = function() {
snapEnabled = this.checked;
document.getElementById('btn-snap').classList.toggle('active', snapEnabled);
};
document.getElementById('set-grid').onchange = function() {
viewport.classList.toggle('show-grid', this.checked);
document.getElementById('btn-grid').classList.toggle('active', this.checked);
};
document.getElementById('set-save').onclick = () => {
// Sync: if code editor changed customCode, settings textarea might be stale
const stc = document.getElementById('set-custom-code');
if (stc && stc.value !== customCode) customCode = stc.value;
else customCode = codeMain?.value || customCode;
const config = {
theme: document.documentElement.getAttribute('data-theme'),
snap: snapEnabled,
grid: viewport.classList.contains('show-grid'),
guides: viewport.classList.contains('show-guides'),
};
try { localStorage.setItem('uibuilder-config', JSON.stringify(config)); } catch(e) {}
showToast('Config saved');
document.getElementById('settings-overlay').classList.remove('visible');
};
// Load saved config on start
try {
const cfg = JSON.parse(localStorage.getItem('uibuilder-config') || '{}');
if (cfg.theme) document.documentElement.setAttribute('data-theme', cfg.theme);
if (cfg.snap === false) { snapEnabled = false; document.getElementById('btn-snap').classList.remove('active'); }
if (cfg.grid === false) { viewport.classList.remove('show-grid'); document.getElementById('btn-grid').classList.remove('active'); }
if (cfg.guides === false) { viewport.classList.remove('show-guides'); document.getElementById('btn-guides').classList.remove('active'); }
} catch(e) {}
// ══════════════════════════════════════
// CODE EDITOR
// ══════════════════════════════════════
const codeOverlay = document.getElementById('code-overlay');
const codeMain = document.getElementById('code-main');
let blocklyWorkspace = null;
let blocklyInited = false;
// Tab switching
document.querySelectorAll('.code-tab').forEach(tab => {
tab.onclick = () => {
document.querySelectorAll('.code-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.code-tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
if (tab.dataset.tab === 'blocks' && !blocklyInited) initBlockly();
if (tab.dataset.tab === 'blocks' && blocklyWorkspace) {
Blockly.svgResize(blocklyWorkspace);
}
};
});
// Add Function button
// ── Function creation dialog ──
function showFuncDialog(callback, existingName, existingArgs) {
let overlay = document.getElementById('func-dialog-overlay');
if (overlay) overlay.remove();
overlay = document.createElement('div');
overlay.id = 'func-dialog-overlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:400;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(3px);';
const box = document.createElement('div');
box.style.cssText = 'background:var(--bg-panel);border:1px solid var(--border-color);border-radius:10px;padding:16px;width:380px;max-width:90vw;';
let args = existingArgs ? [...existingArgs] : ['sourceEl', 'targetEl'];
function render() {
box.innerHTML = '';
// Title
const title = document.createElement('h6');
title.style.cssText = "font-family:'Orbitron',sans-serif;font-size:0.5rem;color:var(--accent);margin:0 0 12px;letter-spacing:1px;";
title.textContent = existingName ? 'EDIT FUNCTION' : 'NEW FUNCTION';
box.appendChild(title);
// Name
const nameRow = document.createElement('div');
nameRow.style.cssText = 'display:flex;align-items:center;gap:6px;margin-bottom:10px;';
const nameLbl = document.createElement('span');
nameLbl.style.cssText = 'font-size:0.5rem;color:var(--text-secondary);width:50px;';
nameLbl.textContent = 'Name';
const nameInp = document.createElement('input');
nameInp.className = 'pp-input'; nameInp.id = 'fd-name';
nameInp.value = existingName || ''; nameInp.placeholder = 'e.g. onAttack';
nameInp.style.flex = '1';
if (existingName) { nameInp.readOnly = true; nameInp.style.opacity = '0.6'; }
nameInp.oninput = () => {
const pv = box.querySelector('.fd-preview');
if (pv) pv.textContent = `function ${nameInp.value||'myFunc'}(${args.filter(a=>a).join(', ')}) { ... }`;
};
nameRow.appendChild(nameLbl); nameRow.appendChild(nameInp);
box.appendChild(nameRow);
// Args header
const argsHdr = document.createElement('div');
argsHdr.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;';
const argsLbl = document.createElement('span');
argsLbl.style.cssText = "font-family:'Orbitron',sans-serif;font-size:0.4rem;color:var(--accent2);letter-spacing:1px;";
argsLbl.textContent = 'ARGUMENTS';
const addArgBtn = document.createElement('button');
addArgBtn.className = 'pp-btn';
addArgBtn.style.cssText = 'font-size:0.4rem;padding:2px 8px;background:rgba(167,139,250,0.08);border-color:var(--accent2);color:var(--accent2);';
addArgBtn.innerHTML = '<i class="bi bi-plus"></i> Add';
addArgBtn.onclick = () => { args.push(''); render(); };
argsHdr.appendChild(argsLbl); argsHdr.appendChild(addArgBtn);
box.appendChild(argsHdr);
// Presets
const presetRow = document.createElement('div');
presetRow.style.cssText = 'display:flex;gap:4px;margin-bottom:8px;flex-wrap:wrap;';
const presets = [
{ label:'No args', args:[] },
{ label:'sourceEl, targetEl', args:['sourceEl','targetEl'] },
{ label:'sourceEl', args:['sourceEl'] },
{ label:'event', args:['event'] },
];
presets.forEach(p => {
const btn = document.createElement('button');
btn.className = 'pp-btn';
btn.style.cssText = 'font-size:0.38rem;padding:2px 6px;background:rgba(255,255,255,0.03);border-color:var(--border-color);color:var(--text-muted);';
btn.textContent = p.label || '(none)';
btn.onclick = () => { args = [...p.args]; render(); };
presetRow.appendChild(btn);
});
box.appendChild(presetRow);
// Arg list
if (args.length === 0) {
const empty = document.createElement('div');
empty.style.cssText = 'font-size:0.45rem;color:var(--text-muted);padding:4px 0 8px;font-style:italic;';
empty.textContent = 'function ' + (document.getElementById('fd-name')?.value || 'name') + '() — no arguments';
box.appendChild(empty);
}
args.forEach((arg, i) => {
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:4px;margin-bottom:4px;';
const num = document.createElement('span');
num.style.cssText = 'font-size:0.4rem;color:var(--text-muted);width:16px;text-align:right;';
num.textContent = (i+1) + '.';
const inp = document.createElement('input');
inp.className = 'pp-input'; inp.value = arg; inp.placeholder = 'argName';
inp.style.flex = '1';
inp.onchange = () => { args[i] = inp.value.replace(/[^a-zA-Z0-9_$]/g, ''); render(); };
const delBtn = document.createElement('div');
delBtn.style.cssText = 'width:20px;height:20px;border-radius:3px;background:rgba(255,77,106,0.08);border:1px solid var(--danger);color:var(--danger);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:0.4rem;flex-shrink:0;';
delBtn.innerHTML = '<i class="bi bi-x"></i>';
delBtn.onclick = () => { args.splice(i, 1); render(); };
row.appendChild(num); row.appendChild(inp); row.appendChild(delBtn);
box.appendChild(row);
});
// Preview
const preview = document.createElement('div');
const pName = document.getElementById('fd-name')?.value || existingName || 'myFunc';
preview.className = 'fd-preview';
preview.style.cssText = 'font-family:"Courier New",monospace;font-size:0.5rem;color:#9cdcfe;background:rgba(0,0,0,0.2);padding:6px 10px;border-radius:4px;margin:10px 0 12px;';
preview.textContent = `function ${pName}(${args.filter(a=>a).join(', ')}) { ... }`;
box.appendChild(preview);
// Buttons
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display:flex;gap:6px;';
const okBtn = document.createElement('button');
okBtn.className = 'pp-btn';
okBtn.style.cssText = 'flex:1;background:rgba(0,229,160,0.08);border-color:var(--accent);color:var(--accent);';
okBtn.innerHTML = '<i class="bi bi-check-lg"></i> ' + (existingName ? 'Update' : 'Create');
okBtn.onclick = () => {
const name = document.getElementById('fd-name').value.trim();
if (!name.match(/^[a-zA-Z_]\w*$/)) { showToast('Invalid function name', 'error'); return; }
const cleanArgs = args.filter(a => a && a.match(/^[a-zA-Z_$]\w*$/));
overlay.remove();
callback(name, cleanArgs);
};
const cancelBtn = document.createElement('button');
cancelBtn.className = 'pp-btn';
cancelBtn.style.cssText = 'flex:1;background:rgba(255,255,255,0.04);border-color:var(--border-color);color:var(--text-secondary);';
cancelBtn.innerHTML = 'Cancel';
cancelBtn.onclick = () => overlay.remove();
btnRow.appendChild(okBtn); btnRow.appendChild(cancelBtn);
box.appendChild(btnRow);
}
render();
overlay.appendChild(box);
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
document.body.appendChild(overlay);
// Focus name input
setTimeout(() => { const n = document.getElementById('fd-name'); if (n && !existingName) n.focus(); }, 50);
}
document.getElementById('btn-add-func').onclick = () => {
showFuncDialog((name, args) => {
const argStr = args.join(', ');
const template = `\nfunction ${name}(${argStr}) {\n // Your code here\n}\n`;
codeMain.value += template;
codeMain.scrollTop = codeMain.scrollHeight;
updateFuncSelect();
document.getElementById('code-func-select').value = name;
showToast(`Function "${name}" added`);
});
};
document.getElementById('btn-edit-func').onclick = () => {
const sel = document.getElementById('code-func-select');
const fname = sel.value;
if (!fname) { showToast('Select a function first', 'error'); return; }
const m = codeMain.value.match(new RegExp(`function\\s+${fname}\\s*\\(([^)]*)\\)`));
const curArgs = m ? m[1].split(',').map(a => a.trim()).filter(a => a) : [];
showFuncDialog((name, newArgs) => {
const oldSig = new RegExp(`function\\s+${fname}\\s*\\([^)]*\\)`);
codeMain.value = codeMain.value.replace(oldSig, `function ${fname}(${newArgs.join(', ')})`);
updateFuncSelect();
showToast(`Updated "${fname}" arguments`);
}, fname, curArgs);
};
document.getElementById('btn-del-func').onclick = () => {
const sel = document.getElementById('code-func-select');
const fname = sel.value;
if (!fname) { showToast('Select a function first', 'error'); return; }
if (!confirm(`Delete function "${fname}" from code?`)) return;
const regex = new RegExp(`\\n*function\\s+${fname}\\s*\\([^)]*\\)\\s*\\{[\\s\\S]*?\\n\\}`, 'm');
codeMain.value = codeMain.value.replace(regex, '');
updateFuncSelect();
showToast(`Deleted "${fname}"`);
};
document.getElementById('code-func-select').onchange = () => {
const fname = document.getElementById('code-func-select').value;
if (!fname) return;
const idx = codeMain.value.indexOf('function ' + fname);
if (idx >= 0) {
codeMain.focus();
codeMain.setSelectionRange(idx, idx);
// Scroll to the function
const lines = codeMain.value.substring(0, idx).split('\n').length;
const lineH = parseFloat(getComputedStyle(codeMain).lineHeight) || 18;
codeMain.scrollTop = Math.max(0, (lines - 2) * lineH);
}
};
function updateFuncSelect() {
const sel = document.getElementById('code-func-select');
const cur = sel.value;
sel.innerHTML = '<option value="">(select function)</option>';
const matches = [...codeMain.value.matchAll(/function\s+(\w+)\s*\(([^)]*)\)/g)];
matches.forEach(m => {
const opt = document.createElement('option');
opt.value = m[1];
opt.textContent = `${m[1]}(${m[2]})`;
sel.appendChild(opt);
});
// Restore selection if still exists
if (matches.some(m => m[1] === cur)) sel.value = cur;
}
codeMain.addEventListener('input', () => { clearTimeout(codeMain._chipTimer); codeMain._chipTimer = setTimeout(updateFuncSelect, 500); });
document.getElementById('btn-code').onclick = () => {
codeMain.value = customCode;
updateFuncSelect();
if (blocklyInited) refreshBlocklyFuncSelect();
codeOverlay.classList.add('visible');
if (blocklyWorkspace) Blockly.svgResize(blocklyWorkspace);
};
document.getElementById('code-close').onclick = () => {
codeOverlay.classList.remove('visible');
};
document.getElementById('code-save').onclick = () => {
if (blocklyWorkspace && blocklyCurrentFunc) saveCurrentBlocklyFunc();
customCode = codeMain.value;
const stc = document.getElementById('set-custom-code');
if (stc) stc.value = customCode;
codeOverlay.classList.remove('visible');
showToast('Code saved');
};
document.getElementById('code-run').onclick = () => {
if (blocklyWorkspace && blocklyCurrentFunc) saveCurrentBlocklyFunc();
customCode = codeMain.value;
let s = document.getElementById('preview-custom-code');
if (s) s.remove();
s = document.createElement('script');
s.id = 'preview-custom-code';
try {
s.textContent = customCode;
document.body.appendChild(s);
showToast('Code injected — functions available');
} catch(err) {
showToast('Error: ' + err.message, 'error');
}
};
// ── Blockly Setup ──
let blocklyFuncData = {}; // { funcName: { json: workspaceJson } }
let blocklyCurrentFunc = '';
let customBlocks = []; // user-defined blockly blocks
function initBlockly() {
if (blocklyInited || typeof Blockly === 'undefined') {
if (typeof Blockly === 'undefined') showToast('Blockly not loaded — check internet', 'error');
return;
}
blocklyInited = true;
const C_UI = 160, C_KEY = 210, C_OBJ = 260, C_QRY = 30, C_SCN = 330, C_TIME = 65, C_FUNC = 290;
// ── Function Definition block (visual wrapper) ──
const FLabel = Blockly.FieldLabelSerializable || Blockly.FieldLabel;
Blockly.Blocks['gameapi_func_def'] = { init() {
this.appendDummyInput('HEADER')
.appendField('⚡ function')
.appendField(new FLabel('myFunc'), 'NAME')
.appendField('(')
.appendField(new FLabel(''), 'ARGS')
.appendField(')');
this.appendStatementInput('BODY').appendField('do');
this.setColour(C_FUNC);
this.setDeletable(false);
this.setTooltip('Function body — add blocks inside');
}};
// ── UI API blocks ──
Blockly.Blocks['gameapi_set_value'] = { init() {
this.appendValueInput('VAL').setCheck(null).appendField('set UI var').appendField(new Blockly.FieldTextInput('score'), 'VAR').appendField('to');
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(C_UI);
}};
Blockly.Blocks['gameapi_get_value'] = { init() {
this.appendDummyInput().appendField('get UI var').appendField(new Blockly.FieldTextInput('score'), 'VAR');
this.setOutput(true); this.setColour(C_UI);
}};
Blockly.Blocks['gameapi_set_ui_visible'] = { init() {
this.appendValueInput('ID').setCheck(null).appendField('set element');
this.appendDummyInput().appendField('visible').appendField(new Blockly.FieldDropdown([['true','true'],['false','false']]), 'VIS');
this.setInputsInline(true);
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(C_UI);
}};
Blockly.Blocks['gameapi_set_content'] = { init() {
this.appendValueInput('ID').setCheck(null).appendField('set element');
this.appendValueInput('TEXT').setCheck(null).appendField('text to');
this.setInputsInline(true);
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(C_UI);
}};
Blockly.Blocks['gameapi_set_style'] = { init() {
this.appendValueInput('ID').setCheck(null).appendField('set element');
this.appendDummyInput().appendField('style').appendField(new Blockly.FieldTextInput('color'), 'PROP').appendField('to');
this.appendValueInput('VAL').setCheck(null);
this.setInputsInline(true);
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(C_UI);
}};
// ── Key Simulation blocks ──
const keyOpts = [['W','w'],['A','a'],['S','s'],['D','d'],['Shift','shift'],['Space','space']];
Blockly.Blocks['gameapi_press_key'] = { init() {
this.appendDummyInput().appendField('press key').appendField(new Blockly.FieldDropdown(keyOpts), 'KEY');
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(C_KEY);
}};
Blockly.Blocks['gameapi_release_key'] = { init() {
this.appendDummyInput().appendField('release key').appendField(new Blockly.FieldDropdown(keyOpts), 'KEY');
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(C_KEY);
}};
Blockly.Blocks['gameapi_simulate_key'] = { init() {
this.appendDummyInput().appendField('tap key').appendField(new Blockly.FieldDropdown(keyOpts), 'KEY')
.appendField('for').appendField(new Blockly.FieldNumber(200, 50, 5000), 'MS').appendField('ms');
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(C_KEY);
}};
Blockly.Blocks['gameapi_release_all'] = { init() {
this.appendDummyInput().appendField('release all keys');
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(C_KEY);
}};
// ── 3D Object blocks ──
Blockly.Blocks['gameapi_get_object'] = { init() {
this.appendValueInput('ID').setCheck(null).appendField('get object');
this.setInputsInline(true);
this.setOutput(true); this.setColour(C_OBJ);
}};
Blockly.Blocks['gameapi_get_objects'] = { init() {
this.appendValueInput('CAT').setCheck(null).appendField('get all objects of type');
this.setInputsInline(true);
this.setOutput(true); this.setColour(C_OBJ);
}};
Blockly.Blocks['gameapi_set_position'] = { init() {
this.appendValueInput('ID').setCheck(null).appendField('move');
this.appendValueInput('X').setCheck('Number').appendField('x');
this.appendValueInput('Y').setCheck('Number').appendField('y');
this.appendValueInput('Z').setCheck('Number').appendField('z');
this.setInputsInline(true);
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(C_OBJ);
}};
Blockly.Blocks['gameapi_set_rotation'] = { init() {
this.appendValueInput('ID').setCheck(null).appendField('rotate');
this.appendValueInput('RX').setCheck('Number').appendField('rx');
this.appendValueInput('RY').setCheck('Number').appendField('ry');
this.appendValueInput('RZ').setCheck('Number').appendField('rz');
this.setInputsInline(true);
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(C_OBJ);
}};
Blockly.Blocks['gameapi_set_scale'] = { init() {
this.appendValueInput('ID').setCheck(null).appendField('scale');
this.appendValueInput('SX').setCheck('Number').appendField('x');
this.appendValueInput('SY').setCheck('Number').appendField('y');
this.appendValueInput('SZ').setCheck('Number').appendField('z');
this.setInputsInline(true);
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(C_OBJ);
}};
Blockly.Blocks['gameapi_set_animation'] = { init() {
this.appendValueInput('CLIP').setCheck(null).appendField('play animation');
this.appendValueInput('ID').setCheck(null).appendField('on');
this.setInputsInline(true);
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(C_OBJ);
}};
Blockly.Blocks['gameapi_get_animations'] = { init() {
this.appendValueInput('ID').setCheck(null).appendField('get animations of');
this.setInputsInline(true);
this.setOutput(true); this.setColour(C_OBJ);
}};
Blockly.Blocks['gameapi_set_obj_visible'] = { init() {
this.appendValueInput('ID').setCheck(null).appendField('set 3D object');
this.appendDummyInput().appendField('visible').appendField(new Blockly.FieldDropdown([['true','true'],['false','false']]), 'VIS');
this.setInputsInline(true);
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(C_OBJ);
}};
Blockly.Blocks['gameapi_destroy'] = { init() {
this.appendValueInput('ID').setCheck(null).appendField('destroy object');
this.setInputsInline(true);
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(C_OBJ);
}};
// ── Query blocks ──
Blockly.Blocks['gameapi_get_position'] = { init() {
this.appendValueInput('ID').setCheck(null).appendField('position of');
this.setInputsInline(true);
this.setOutput(true); this.setColour(C_QRY);
}};
Blockly.Blocks['gameapi_get_distance'] = { init() {
this.appendValueInput('ID1').setCheck(null).appendField('distance');
this.appendValueInput('ID2').setCheck(null).appendField('to');
this.setInputsInline(true);
this.setOutput(true); this.setColour(C_QRY);
}};
Blockly.Blocks['gameapi_get_property'] = { init() {
this.appendDummyInput().appendField('get property').appendField(new Blockly.FieldTextInput('loot'), 'PROP').appendField('of');
this.appendValueInput('ID').setCheck(null);
this.setInputsInline(true);
this.setOutput(true); this.setColour(C_QRY);
}};
Blockly.Blocks['gameapi_set_property'] = { init() {
this.appendDummyInput().appendField('set property').appendField(new Blockly.FieldTextInput('opened'), 'PROP').appendField('of');
this.appendValueInput('ID').setCheck(null);
this.appendValueInput('VAL').setCheck(null).appendField('to');
this.setInputsInline(true);
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(C_QRY);
}};
// ── Scene blocks ──
Blockly.Blocks['gameapi_switch_camera'] = { init() {
this.appendValueInput('ID').setCheck(null).appendField('switch camera to');
this.setInputsInline(true);
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(C_SCN);
}};
Blockly.Blocks['gameapi_show_dialog'] = { init() {
this.appendValueInput('TEXT').setCheck(null).appendField('show dialog');
this.appendValueInput('SPEAKER').setCheck(null).appendField('speaker');
this.setInputsInline(true);
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(C_SCN);
}};
Blockly.Blocks['gameapi_toast'] = { init() {
this.appendValueInput('MSG').setCheck(null).appendField('show toast');
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(C_SCN);
}};
Blockly.Blocks['gameapi_play_audio'] = { init() {
this.appendValueInput('URL').setCheck(null).appendField('play audio');
this.setInputsInline(true);
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(C_SCN);
}};
Blockly.Blocks['gameapi_wait'] = { init() {
this.appendDummyInput().appendField('wait').appendField(new Blockly.FieldNumber(500, 10, 10000), 'MS').appendField('ms then');
this.appendStatementInput('DO');
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(C_TIME);
}};
Blockly.Blocks['gameapi_log'] = { init() {
this.appendValueInput('MSG').appendField('console.log');
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(C_TIME);
}};
Blockly.Blocks['gameapi_alert'] = { init() {
this.appendValueInput('MSG').appendField('alert');
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(C_TIME);
}};
Blockly.Blocks['gameapi_js_expr'] = { init() {
this.appendDummyInput().appendField('JS').appendField(new Blockly.FieldTextInput('expression'), 'CODE');
this.setOutput(true); this.setColour(C_TIME);
this.setTooltip('Raw JavaScript expression — returns a value');
}};
Blockly.Blocks['gameapi_js_stmt'] = { init() {
this.appendDummyInput().appendField('run JS:').appendField(new Blockly.FieldTextInput('// code'), 'CODE');
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(C_TIME);
this.setTooltip('Raw JavaScript statement');
}};
// Global variable blocks
Blockly.Blocks['gameapi_set_global'] = { init() {
this.appendValueInput('VAL').appendField('set global').appendField(new Blockly.FieldTextInput('myVar'),'NAME').appendField('to');
this.setPreviousStatement(true); this.setNextStatement(true); this.setColour(50);
this.setTooltip('Set a global variable accessible from any function');
}};
Blockly.Blocks['gameapi_get_global'] = { init() {
this.appendDummyInput().appendField('get global').appendField(new Blockly.FieldTextInput('myVar'),'NAME');
this.setOutput(true); this.setColour(50);
this.setTooltip('Get a global variable value');
}};
// ══ Code Generators ══
const gen = javascript.javascriptGenerator;
const O = javascript.Order;
gen.forBlock['gameapi_func_def'] = function(block) {
return gen.statementToCode(block, 'BODY');
};
gen.forBlock['gameapi_set_value'] = b => `GameAPI.setUIValue('${b.getFieldValue('VAR')}', ${gen.valueToCode(b,'VAL',O.NONE)||'0'});\n`;
gen.forBlock['gameapi_get_value'] = b => [`GameAPI.getUIValue('${b.getFieldValue('VAR')}')`, O.FUNCTION_CALL];
gen.forBlock['gameapi_set_ui_visible'] = b => `GameAPI.setUIVisible(${gen.valueToCode(b,'ID',O.NONE)||"'el_1'"}, ${b.getFieldValue('VIS')});\n`;
gen.forBlock['gameapi_set_content'] = b => `GameAPI.setUIContent(${gen.valueToCode(b,'ID',O.NONE)||"'el_1'"}, ${gen.valueToCode(b,'TEXT',O.NONE)||"''"});\n`;
gen.forBlock['gameapi_set_style'] = b => `GameAPI.setUIStyle(${gen.valueToCode(b,'ID',O.NONE)||"'el_1'"}, '${b.getFieldValue('PROP')}', ${gen.valueToCode(b,'VAL',O.NONE)||"''"});\n`;
gen.forBlock['gameapi_press_key'] = b => `GameAPI.pressKey('${b.getFieldValue('KEY')}');\n`;
gen.forBlock['gameapi_release_key'] = b => `GameAPI.releaseKey('${b.getFieldValue('KEY')}');\n`;
gen.forBlock['gameapi_simulate_key'] = b => `GameAPI.simulateKey('${b.getFieldValue('KEY')}', ${b.getFieldValue('MS')});\n`;
gen.forBlock['gameapi_release_all'] = () => 'GameAPI.releaseAllKeys();\n';
gen.forBlock['gameapi_get_object'] = b => [`GameAPI.getObject(${gen.valueToCode(b,'ID',O.NONE)||"'npc_1'"})`, O.FUNCTION_CALL];
gen.forBlock['gameapi_get_objects'] = b => [`GameAPI.getObjects(${gen.valueToCode(b,'CAT',O.NONE)||"'npc'"})`, O.FUNCTION_CALL];
gen.forBlock['gameapi_set_position'] = b => `GameAPI.setPosition(${gen.valueToCode(b,'ID',O.NONE)||"'obj_1'"}, ${gen.valueToCode(b,'X',O.NONE)||'0'}, ${gen.valueToCode(b,'Y',O.NONE)||'0'}, ${gen.valueToCode(b,'Z',O.NONE)||'0'});\n`;
gen.forBlock['gameapi_set_rotation'] = b => `GameAPI.setRotation(${gen.valueToCode(b,'ID',O.NONE)||"'obj_1'"}, ${gen.valueToCode(b,'RX',O.NONE)||'0'}, ${gen.valueToCode(b,'RY',O.NONE)||'0'}, ${gen.valueToCode(b,'RZ',O.NONE)||'0'});\n`;
gen.forBlock['gameapi_set_scale'] = b => `GameAPI.setScale(${gen.valueToCode(b,'ID',O.NONE)||"'obj_1'"}, ${gen.valueToCode(b,'SX',O.NONE)||'1'}, ${gen.valueToCode(b,'SY',O.NONE)||'1'}, ${gen.valueToCode(b,'SZ',O.NONE)||'1'});\n`;
gen.forBlock['gameapi_set_animation'] = b => `GameAPI.setAnimation(${gen.valueToCode(b,'ID',O.NONE)||"'npc_1'"}, ${gen.valueToCode(b,'CLIP',O.NONE)||"'Idle'"});\n`;
gen.forBlock['gameapi_get_animations'] = b => [`GameAPI.getAnimations(${gen.valueToCode(b,'ID',O.NONE)||"'npc_1'"})`, O.FUNCTION_CALL];
gen.forBlock['gameapi_set_obj_visible'] = b => `GameAPI.setVisible(${gen.valueToCode(b,'ID',O.NONE)||"'obj_1'"}, ${b.getFieldValue('VIS')});\n`;
gen.forBlock['gameapi_destroy'] = b => `GameAPI.destroy(${gen.valueToCode(b,'ID',O.NONE)||"'obj_1'"});\n`;
gen.forBlock['gameapi_get_position'] = b => [`GameAPI.getPosition(${gen.valueToCode(b,'ID',O.NONE)||"'player_1'"})`, O.FUNCTION_CALL];
gen.forBlock['gameapi_get_distance'] = b => [`GameAPI.getDistance(${gen.valueToCode(b,'ID1',O.NONE)||"'player_1'"}, ${gen.valueToCode(b,'ID2',O.NONE)||"'npc_1'"})`, O.FUNCTION_CALL];
gen.forBlock['gameapi_get_property'] = b => [`GameAPI.getProperty(${gen.valueToCode(b,'ID',O.NONE)||"'obj_1'"}, '${b.getFieldValue('PROP')}')`, O.FUNCTION_CALL];
gen.forBlock['gameapi_set_property'] = b => `GameAPI.setProperty(${gen.valueToCode(b,'ID',O.NONE)||"'obj_1'"}, '${b.getFieldValue('PROP')}', ${gen.valueToCode(b,'VAL',O.NONE)||'null'});\n`;
gen.forBlock['gameapi_switch_camera'] = b => `GameAPI.switchCamera(${gen.valueToCode(b,'ID',O.NONE)||"'camera_2'"});\n`;
gen.forBlock['gameapi_show_dialog'] = b => `GameAPI.showDialog(${gen.valueToCode(b,'TEXT',O.NONE)||"'Hello!'"}, {speaker:${gen.valueToCode(b,'SPEAKER',O.NONE)||"'NPC'"}});\n`;
gen.forBlock['gameapi_toast'] = b => `GameAPI.showToast(${gen.valueToCode(b,'MSG',O.NONE)||"''"});\n`;
gen.forBlock['gameapi_play_audio'] = b => `GameAPI.playAudio(${gen.valueToCode(b,'URL',O.NONE)||"''"});\n`;
gen.forBlock['gameapi_wait'] = b => `setTimeout(function() {\n${gen.statementToCode(b,'DO')}}, ${b.getFieldValue('MS')});\n`;
gen.forBlock['gameapi_log'] = b => `console.log(${gen.valueToCode(b,'MSG',O.NONE)||"''"});\n`;
gen.forBlock['gameapi_alert'] = b => `alert(${gen.valueToCode(b,'MSG',O.NONE)||"''"});\n`;
gen.forBlock['gameapi_js_expr'] = b => [b.getFieldValue('CODE') || '0', O.ATOMIC];
gen.forBlock['gameapi_js_stmt'] = b => (b.getFieldValue('CODE') || '') + ';\n';
gen.forBlock['gameapi_set_global'] = b => `GameAPI.setGlobal("${b.getFieldValue('NAME')}", ${gen.valueToCode(b,'VAL',O.NONE)||'null'});\n`;
gen.forBlock['gameapi_get_global'] = b => [`GameAPI.getGlobal("${b.getFieldValue('NAME')}")`, O.FUNCTION_CALL];
// ══ Toolbox ══
const toolbox = { kind:'categoryToolbox', contents:[
{ kind:'category', name:'UI', colour:C_UI, contents:[
{ kind:'block', type:'gameapi_set_value' },
{ kind:'block', type:'gameapi_get_value' },
{ kind:'block', type:'gameapi_set_ui_visible' },
{ kind:'block', type:'gameapi_set_content' },
{ kind:'block', type:'gameapi_set_style' },
]},
{ kind:'category', name:'Controls', colour:C_KEY, contents:[
{ kind:'block', type:'gameapi_press_key' },
{ kind:'block', type:'gameapi_release_key' },
{ kind:'block', type:'gameapi_simulate_key' },
{ kind:'block', type:'gameapi_release_all' },
]},
{ kind:'category', name:'3D Objects', colour:C_OBJ, contents:[
{ kind:'block', type:'gameapi_get_object' },
{ kind:'block', type:'gameapi_get_objects' },
{ kind:'block', type:'gameapi_set_position' },
{ kind:'block', type:'gameapi_set_rotation' },
{ kind:'block', type:'gameapi_set_scale' },
{ kind:'block', type:'gameapi_set_animation' },
{ kind:'block', type:'gameapi_get_animations' },
{ kind:'block', type:'gameapi_set_obj_visible' },
{ kind:'block', type:'gameapi_destroy' },
]},
{ kind:'category', name:'Queries', colour:C_QRY, contents:[
{ kind:'block', type:'gameapi_get_position' },
{ kind:'block', type:'gameapi_get_distance' },
{ kind:'block', type:'gameapi_get_property' },
{ kind:'block', type:'gameapi_set_property' },
]},
{ kind:'category', name:'Scene', colour:C_SCN, contents:[
{ kind:'block', type:'gameapi_switch_camera' },
{ kind:'block', type:'gameapi_show_dialog' },
{ kind:'block', type:'gameapi_toast' },
{ kind:'block', type:'gameapi_play_audio' },
]},
{ kind:'category', name:'Timing', colour:C_TIME, contents:[
{ kind:'block', type:'gameapi_wait' },
{ kind:'block', type:'gameapi_log' },
{ kind:'block', type:'gameapi_alert' },
{ kind:'block', type:'gameapi_js_expr' },
{ kind:'block', type:'gameapi_js_stmt' },
]},
{ kind:'sep' },
{ kind:'category', name:'Logic', categorystyle:'logic_category', contents:[
{ kind:'block', type:'controls_if' },
{ kind:'block', type:'logic_compare' },
{ kind:'block', type:'logic_operation' },
{ kind:'block', type:'logic_negate' },
{ kind:'block', type:'logic_boolean' },
]},
{ kind:'category', name:'Loops', categorystyle:'loop_category', contents:[
{ kind:'block', type:'controls_repeat_ext' },
{ kind:'block', type:'controls_whileUntil' },
{ kind:'block', type:'controls_for' },
]},
{ kind:'category', name:'Math', categorystyle:'math_category', contents:[
{ kind:'block', type:'math_number' },
{ kind:'block', type:'math_arithmetic' },
{ kind:'block', type:'math_random_int' },
{ kind:'block', type:'math_modulo' },
]},
{ kind:'category', name:'Text', categorystyle:'text_category', contents:[
{ kind:'block', type:'text' },
{ kind:'block', type:'text_join' },
{ kind:'block', type:'text_length' },
]},
{ kind:'category', name:'Variables', categorystyle:'variable_category', custom:'VARIABLE' },
]};
blocklyWorkspace = Blockly.inject('blockly-area', {
toolbox,
theme: Blockly.Themes.Classic,
grid: { spacing:20, length:3, colour:'#333', snap:true },
zoom: { controls:true, wheel:true, startScale:0.9 },
trashcan: true,
});
// Store base toolbox for custom block rebuilds
blocklyWorkspace._baseToolbox = JSON.parse(JSON.stringify(toolbox));
// Auto-preview generated code on change
blocklyWorkspace.addChangeListener(() => {
clearTimeout(blocklyWorkspace._genTimer);
blocklyWorkspace._genTimer = setTimeout(previewBlocklyCode, 400);
});
// ── Function selector UI ──
const funcSelect = document.getElementById('blockly-func-select');
funcSelect.onchange = () => {
saveCurrentBlocklyFunc();
blocklyCurrentFunc = funcSelect.value;
loadBlocklyFunc(blocklyCurrentFunc);
};
document.getElementById('blockly-add-func').onclick = () => {
showFuncDialog((name, args) => {
if (blocklyFuncData[name]) { showToast('Function already exists', 'error'); return; }
saveCurrentBlocklyFunc();
blocklyFuncData[name] = { json: null, args: args };
blocklyCurrentFunc = name;
blocklyWorkspace.clear();
registerArgBlocks(args);
placeFuncDefBlock(name, args);
refreshBlocklyFuncSelect();
funcSelect.value = name;
showToast(`Function "${name}" created — add blocks inside!`);
});
};
document.getElementById('blockly-gen-func').onclick = () => {
if (!blocklyCurrentFunc) { showToast('Select or create a function first', 'error'); return; }
saveCurrentBlocklyFunc();
insertBlocklyFuncIntoCode(blocklyCurrentFunc);
showToast(`Generated "${blocklyCurrentFunc}" → Code tab`);
};
document.getElementById('blockly-clear').onclick = () => {
if (!blocklyCurrentFunc) return;
if (!confirm('Clear workspace for "' + blocklyCurrentFunc + '"?')) return;
blocklyWorkspace.clear();
blocklyFuncData[blocklyCurrentFunc].json = null;
document.getElementById('blockly-code-output').textContent = '// (empty)';
};
document.getElementById('blockly-del-func').onclick = () => {
if (!blocklyCurrentFunc) return;
if (!confirm('Delete function "' + blocklyCurrentFunc + '" from Blockly? (Code tab not affected)')) return;
delete blocklyFuncData[blocklyCurrentFunc];
blocklyCurrentFunc = '';
blocklyWorkspace.clear();
registerArgBlocks([]);
refreshBlocklyFuncSelect();
document.getElementById('blockly-code-output').textContent = '// (empty)';
};
document.getElementById('blockly-edit-args').onclick = () => {
if (!blocklyCurrentFunc) { showToast('Select a function first', 'error'); return; }
const fd = blocklyFuncData[blocklyCurrentFunc];
const curArgs = (fd && fd.args) ? fd.args : ['sourceEl', 'targetEl'];
showFuncDialog((name, newArgs) => {
if (!blocklyFuncData[blocklyCurrentFunc]) blocklyFuncData[blocklyCurrentFunc] = {};
blocklyFuncData[blocklyCurrentFunc].args = newArgs;
updateFuncDefBlockLabel(blocklyCurrentFunc, newArgs);
registerArgBlocks(newArgs);
refreshBlocklyFuncSelect();
previewBlocklyCode();
showToast(`Updated "${blocklyCurrentFunc}" args → (${newArgs.join(', ')})`);
}, blocklyCurrentFunc, curArgs);
};
refreshBlocklyFuncSelect();
registerCustomBlocks();
showToast('Blockly workspace ready');
document.getElementById('blockly-build-block').onclick = () => showBlockManager();
}
function refreshBlocklyFuncSelect() {
const sel = document.getElementById('blockly-func-select');
if (!sel) return;
const cur = sel.value;
sel.innerHTML = '<option value="">(select function)</option>';
Object.keys(blocklyFuncData).sort().forEach(name => {
const fd = blocklyFuncData[name];
const argStr = (fd && fd.args) ? fd.args.join(', ') : 'sourceEl, targetEl';
const opt = document.createElement('option');
opt.value = name; opt.textContent = `${name}(${argStr})`;
sel.appendChild(opt);
});
sel.value = cur in blocklyFuncData ? cur : '';
}
function saveCurrentBlocklyFunc() {
if (!blocklyCurrentFunc || !blocklyWorkspace) return;
if (!blocklyFuncData[blocklyCurrentFunc]) blocklyFuncData[blocklyCurrentFunc] = {};
try {
blocklyFuncData[blocklyCurrentFunc].json = Blockly.serialization.workspaces.save(blocklyWorkspace);
} catch(e) { console.warn('Blockly save error:', e); }
}
function placeFuncDefBlock(name, args) {
if (!blocklyWorkspace) return;
const block = blocklyWorkspace.newBlock('gameapi_func_def');
block.setFieldValue(name, 'NAME');
block.setFieldValue(args.join(', '), 'ARGS');
block.initSvg();
block.render();
block.moveBy(20, 20);
}
function updateFuncDefBlockLabel(name, args) {
if (!blocklyWorkspace) return;
const blocks = blocklyWorkspace.getBlocksByType('gameapi_func_def');
if (blocks.length > 0) {
blocks[0].setFieldValue(name, 'NAME');
blocks[0].setFieldValue(args.join(', '), 'ARGS');
}
}
function loadBlocklyFunc(name) {
if (!blocklyWorkspace) return;
blocklyWorkspace.clear();
if (!name || !blocklyFuncData[name]) {
document.getElementById('blockly-code-output').textContent = name ? '// (empty — add blocks above)' : '// Select or create a function';
registerArgBlocks([]); // clear arg blocks
return;
}
const fd = blocklyFuncData[name];
// Register arg blocks BEFORE loading workspace (so deserialized arg blocks resolve)
registerArgBlocks(fd.args || ['sourceEl', 'targetEl']);
if (fd.json) {
try {
Blockly.serialization.workspaces.load(fd.json, blocklyWorkspace);
} catch(e) { console.warn('Blockly load error:', e); }
}
// Ensure func_def block exists
const defBlocks = blocklyWorkspace.getBlocksByType('gameapi_func_def');
if (defBlocks.length === 0) {
placeFuncDefBlock(name, fd.args || ['sourceEl', 'targetEl']);
} else {
updateFuncDefBlockLabel(name, fd.args || ['sourceEl', 'targetEl']);
}
previewBlocklyCode();
}
function previewBlocklyCode() {
if (!blocklyWorkspace) return;
try {
const code = javascript.javascriptGenerator.workspaceToCode(blocklyWorkspace);
const out = document.getElementById('blockly-code-output');
if (blocklyCurrentFunc) {
const fd = blocklyFuncData[blocklyCurrentFunc];
const argStr = (fd && fd.args) ? fd.args.join(', ') : 'sourceEl, targetEl';
out.textContent = `function ${blocklyCurrentFunc}(${argStr}) {\n${code.split('\n').map(l => ' ' + l).join('\n')}\n}`;
} else {
out.textContent = code || '// (empty)';
}
} catch(e) { console.warn('Blockly gen error:', e); }
}
function insertBlocklyFuncIntoCode(funcName) {
if (!blocklyWorkspace) return;
const bodyCode = javascript.javascriptGenerator.workspaceToCode(blocklyWorkspace);
const indented = bodyCode.split('\n').filter(l => l.trim()).map(l => ' ' + l).join('\n');
const fd = blocklyFuncData[funcName];
const argStr = (fd && fd.args) ? fd.args.join(', ') : 'sourceEl, targetEl';
const fullFunc = `function ${funcName}(${argStr}) {\n${indented}\n}`;
// Replace existing function or append
const regex = new RegExp(`function\\s+${funcName}\\s*\\([^)]*\\)\\s*\\{[\\s\\S]*?\\n\\}`, 'm');
if (regex.test(codeMain.value)) {
codeMain.value = codeMain.value.replace(regex, fullFunc);
} else {
codeMain.value = codeMain.value.trimEnd() + '\n\n' + fullFunc + '\n';
}
updateFuncSelect();
}
// ── Custom Block Registration ──
const TOOLBOX_CATEGORIES = ['UI','Controls','3D Objects','Queries','Scene','Timing','Custom'];
const CAT_COLORS = {'UI':160,'Controls':210,'3D Objects':260,'Queries':30,'Scene':330,'Timing':65,'Custom':45};
function registerCustomBlocks() {
if (!blocklyWorkspace || typeof Blockly === 'undefined') return;
const gen = javascript.javascriptGenerator;
const O = javascript.Order;
customBlocks.forEach(cb => {
Blockly.Blocks[cb.type] = { init() {
let hasValueIn = false;
cb.fields.forEach(f => {
if (f.kind === 'label') {
this.appendDummyInput().appendField(f.text);
} else if (f.kind === 'text') {
this.appendDummyInput().appendField(f.label || '').appendField(new Blockly.FieldTextInput(f.default || ''), f.name);
} else if (f.kind === 'number') {
this.appendDummyInput().appendField(f.label || '').appendField(new Blockly.FieldNumber(f.default||0, f.min, f.max), f.name);
} else if (f.kind === 'dropdown') {
const opts = (f.options || []).map(o => Array.isArray(o) ? o : [o, o]);
if (opts.length > 0) this.appendDummyInput().appendField(f.label || '').appendField(new Blockly.FieldDropdown(opts), f.name);
} else if (f.kind === 'checkbox') {
this.appendDummyInput().appendField(f.label || '').appendField(new Blockly.FieldCheckbox(f.default ? 'TRUE' : 'FALSE'), f.name);
} else if (f.kind === 'value') {
this.appendValueInput(f.name).setCheck(f.check || null).appendField(f.label || '');
hasValueIn = true;
} else if (f.kind === 'statement') {
this.appendStatementInput(f.name).appendField(f.label || '');
}
});
if (hasValueIn) this.setInputsInline(true);
if (cb.shape === 'value') this.setOutput(true);
else { this.setPreviousStatement(true); this.setNextStatement(true); }
this.setColour(cb.colour || 45);
if (cb.tooltip) this.setTooltip(cb.tooltip);
}};
gen.forBlock[cb.type] = function(block) {
let code = cb.codeTemplate || '';
cb.fields.forEach(f => {
if (f.kind === 'value') {
const val = gen.valueToCode(block, f.name, O.NONE) || f.default || '0';
code = code.replace(new RegExp(`\\{${f.name}\\}`, 'g'), val);
} else if (f.kind === 'statement') {
const stm = gen.statementToCode(block, f.name);
code = code.replace(new RegExp(`\\{${f.name}\\}`, 'g'), stm);
} else if (f.name) {
const val = block.getFieldValue(f.name);
code = code.replace(new RegExp(`\\{${f.name}\\}`, 'g'), val);
}
});
if (cb.shape === 'value') return [code, O.FUNCTION_CALL];
return code.endsWith('\n') ? code : code + '\n';
};
});
updateToolboxWithCustom();
}
let currentArgBlockTypes = []; // track registered arg blocks for cleanup
function registerArgBlocks(args) {
if (typeof Blockly === 'undefined') return;
const gen = javascript.javascriptGenerator;
const O = javascript.Order;
// Unregister old arg blocks
currentArgBlockTypes.forEach(t => {
delete Blockly.Blocks[t];
delete gen.forBlock[t];
});
currentArgBlockTypes = [];
if (!args || !args.length) { updateToolboxWithCustom(); return; }
// Register a value block for each argument
args.forEach(argName => {
const type = 'gameapi_arg_' + argName.replace(/\W/g, '_');
currentArgBlockTypes.push(type);
Blockly.Blocks[type] = { init() {
this.appendDummyInput().appendField('⚡ ' + argName);
this.setOutput(true);
this.setColour(290); // purple like func_def
this.setTooltip('Function argument: ' + argName);
this.setMovable(true);
this.setDeletable(true);
}};
gen.forBlock[type] = function() {
return [argName, O.ATOMIC];
};
});
updateToolboxWithCustom();
}
function updateToolboxWithCustom() {
if (!blocklyWorkspace || !blocklyWorkspace._baseToolbox) return;
const newToolbox = JSON.parse(JSON.stringify(blocklyWorkspace._baseToolbox));
const contents = newToolbox.contents;
// 1. Insert arg blocks category at the very top if we have args
if (currentArgBlockTypes.length > 0) {
const argBlocks = currentArgBlockTypes.map(t => ({ kind:'block', type: t }));
contents.unshift({ kind:'category', name:'⚡ Args', colour: 290, contents: argBlocks });
}
// 2. Group custom blocks by category
const grouped = {};
customBlocks.forEach(cb => {
const cat = cb.category || 'Custom';
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push({ kind:'block', type: cb.type });
});
// Add to existing categories or create new ones
Object.entries(grouped).forEach(([catName, blocks]) => {
const existing = contents.find(c => c.kind === 'category' && c.name === catName);
if (existing) {
existing.contents = existing.contents || [];
existing.contents.push(...blocks);
} else {
const sepIdx = contents.findIndex(c => c.kind === 'sep');
const insertAt = sepIdx >= 0 ? sepIdx : contents.length;
contents.splice(insertAt, 0, { kind:'category', name:catName, colour: CAT_COLORS[catName] || 45, contents: blocks });
}
});
try {
blocklyWorkspace.updateToolbox(newToolbox);
} catch(e) { console.warn('Toolbox update error:', e); }
}
// ── Block Manager (list, import/export, manage) ──
function showBlockManager() {
let overlay = document.getElementById('bb-overlay');
if (overlay) overlay.remove();
overlay = document.createElement('div');
overlay.id = 'bb-overlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:500;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(3px);';
const box = document.createElement('div');
box.style.cssText = 'background:var(--bg-panel);border:1px solid var(--border-color);border-radius:10px;padding:0;width:600px;max-width:95vw;max-height:90vh;display:flex;flex-direction:column;overflow:hidden;';
function renderManager() {
box.innerHTML = '';
// Header bar
const hdr = document.createElement('div');
hdr.style.cssText = 'display:flex;align-items:center;gap:8px;padding:12px 16px;border-bottom:1px solid var(--border-color);flex-shrink:0;';
const title = document.createElement('div');
title.style.cssText = "font-family:'Orbitron',sans-serif;font-size:0.5rem;color:var(--accent);letter-spacing:1px;flex:1;";
title.innerHTML = '<i class="bi bi-bricks"></i> CUSTOM BLOCK MANAGER';
hdr.appendChild(title);
// New block button
const newBtn = document.createElement('button'); newBtn.className = 'pp-btn';
newBtn.style.cssText = 'font-size:0.4rem;padding:4px 10px;background:rgba(0,229,160,0.08);border-color:var(--accent);color:var(--accent);';
newBtn.innerHTML = '<i class="bi bi-plus-lg"></i> New Block';
newBtn.onclick = () => { overlay.remove(); showBlockEditor(); };
hdr.appendChild(newBtn);
// Import button
const impBtn = document.createElement('button'); impBtn.className = 'pp-btn';
impBtn.style.cssText = 'font-size:0.4rem;padding:4px 10px;background:rgba(167,139,250,0.08);border-color:var(--accent2);color:var(--accent2);';
impBtn.innerHTML = '<i class="bi bi-upload"></i> Import';
impBtn.onclick = () => {
const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.json';
inp.onchange = () => {
const reader = new FileReader();
reader.onload = e => {
try {
const imported = JSON.parse(e.target.result);
const arr = Array.isArray(imported) ? imported : (imported.customBlocks || []);
if (!arr.length) { showToast('No blocks found in file', 'error'); return; }
let added = 0;
arr.forEach(cb => {
if (!cb.type || !cb.name) return;
if (customBlocks.some(c => c.type === cb.type)) {
cb.type += '_' + Date.now().toString(36).slice(-4);
}
customBlocks.push({ ...cb });
added++;
});
registerCustomBlocks();
renderManager();
showToast(`Imported ${added} block(s)`);
} catch(e) { showToast('Invalid JSON file', 'error'); }
};
reader.readAsText(inp.files[0]);
};
inp.click();
};
hdr.appendChild(impBtn);
// Export All button
const expBtn = document.createElement('button'); expBtn.className = 'pp-btn';
expBtn.style.cssText = 'font-size:0.4rem;padding:4px 10px;background:rgba(56,189,248,0.08);border-color:#38bdf8;color:#38bdf8;';
expBtn.innerHTML = '<i class="bi bi-download"></i> Export All';
expBtn.onclick = () => {
if (!customBlocks.length) { showToast('No custom blocks to export', 'error'); return; }
const blob = new Blob([JSON.stringify(customBlocks, null, 2)], { type:'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = `custom-blocks_${fileTimestamp()}.json`; a.click();
URL.revokeObjectURL(a.href);
showToast(`Exported ${customBlocks.length} block(s)`);
};
hdr.appendChild(expBtn);
// Close button
const closeBtn = document.createElement('div');
closeBtn.style.cssText = 'width:24px;height:24px;border-radius:4px;background:rgba(255,77,106,0.08);border:1px solid var(--danger);color:var(--danger);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:0.45rem;';
closeBtn.innerHTML = '<i class="bi bi-x-lg"></i>';
closeBtn.onclick = () => overlay.remove();
hdr.appendChild(closeBtn);
box.appendChild(hdr);
// Block list area
const listArea = document.createElement('div');
listArea.style.cssText = 'flex:1;overflow-y:auto;padding:12px 16px;';
if (customBlocks.length === 0) {
const empty = document.createElement('div');
empty.style.cssText = 'text-align:center;padding:40px 20px;color:var(--text-muted);';
empty.innerHTML = '<div style="font-size:2rem;margin-bottom:8px;opacity:0.3;"><i class="bi bi-bricks"></i></div>' +
'<div style="font-size:0.5rem;margin-bottom:4px;">No custom blocks yet</div>' +
'<div style="font-size:0.42rem;">Click <b>New Block</b> to create one, or <b>Import</b> a JSON file</div>';
listArea.appendChild(empty);
} else {
// Group by category
const grouped = {};
customBlocks.forEach((cb, idx) => {
const cat = cb.category || 'Custom';
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push({ cb, idx });
});
Object.entries(grouped).sort((a,b) => a[0].localeCompare(b[0])).forEach(([catName, items]) => {
// Category header
const catHdr = document.createElement('div');
catHdr.style.cssText = "display:flex;align-items:center;gap:6px;margin:8px 0 6px;";
const catDot = document.createElement('div');
const hue = CAT_COLORS[catName] || 45;
catDot.style.cssText = `width:10px;height:10px;border-radius:50%;background:hsl(${hue},70%,50%);flex-shrink:0;`;
const catLbl = document.createElement('div');
catLbl.style.cssText = "font-family:'Orbitron',sans-serif;font-size:0.38rem;color:var(--text-muted);letter-spacing:1px;text-transform:uppercase;";
catLbl.textContent = `${catName} (${items.length})`;
catHdr.appendChild(catDot); catHdr.appendChild(catLbl);
listArea.appendChild(catHdr);
// Block cards in this category
items.forEach(({ cb, idx }) => {
const card = document.createElement('div');
card.style.cssText = `display:flex;align-items:center;gap:8px;padding:8px 10px;margin-bottom:4px;background:rgba(0,0,0,0.15);border:1px solid var(--border-color);border-radius:6px;border-left:3px solid hsl(${cb.colour || 45},70%,50%);`;
// Info column
const info = document.createElement('div'); info.style.cssText = 'flex:1;min-width:0;';
const nameRow = document.createElement('div'); nameRow.style.cssText = 'display:flex;align-items:center;gap:6px;';
const nameSpan = document.createElement('span');
nameSpan.style.cssText = 'font-size:0.48rem;color:var(--text-primary);font-weight:600;';
nameSpan.textContent = cb.name;
const shapeTag = document.createElement('span');
shapeTag.style.cssText = `font-size:0.32rem;padding:1px 5px;border-radius:3px;background:rgba(255,255,255,0.05);color:var(--text-muted);border:1px solid var(--border-color);`;
shapeTag.textContent = cb.shape === 'value' ? '◯ value' : '▮ statement';
nameRow.appendChild(nameSpan); nameRow.appendChild(shapeTag);
info.appendChild(nameRow);
// Code template preview
const codePreview = document.createElement('div');
codePreview.style.cssText = "font-family:'Courier New',monospace;font-size:0.38rem;color:var(--text-muted);margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:320px;";
codePreview.textContent = (cb.codeTemplate || '').replace(/\n/g, ' ').substring(0, 60);
info.appendChild(codePreview);
// Fields summary
const fieldSum = document.createElement('div');
fieldSum.style.cssText = 'font-size:0.35rem;color:var(--text-muted);margin-top:2px;opacity:0.7;';
const fieldNames = cb.fields.filter(f => f.name).map(f => `{${f.name}}`).join(' ');
fieldSum.textContent = cb.fields.length + ' field' + (cb.fields.length !== 1 ? 's' : '') + (fieldNames ? ': ' + fieldNames : '');
info.appendChild(fieldSum);
card.appendChild(info);
// Action buttons
const acts = document.createElement('div'); acts.style.cssText = 'display:flex;gap:4px;flex-shrink:0;';
const editBtn = mk('bi-pencil', 'Edit', 'var(--accent3)', 'rgba(245,158,11,0.08)', () => {
overlay.remove(); showBlockEditor(cb);
});
const dupeBtn = mk('bi-copy', 'Duplicate', 'var(--accent2)', 'rgba(167,139,250,0.08)', () => {
const dup = JSON.parse(JSON.stringify(cb));
dup.name = cb.name + ' Copy';
dup.type = cb.type + '_copy_' + Date.now().toString(36).slice(-4);
customBlocks.push(dup);
registerCustomBlocks();
renderManager();
showToast(`Duplicated "${cb.name}"`);
});
const expOneBtn = mk('bi-download', 'Export', '#38bdf8', 'rgba(56,189,248,0.08)', () => {
const blob = new Blob([JSON.stringify([cb], null, 2)], { type:'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = `block-${cb.type}_${fileTimestamp()}.json`; a.click();
URL.revokeObjectURL(a.href);
});
const delBtn = mk('bi-trash3', 'Delete', 'var(--danger)', 'rgba(255,77,106,0.08)', () => {
if (!confirm(`Delete "${cb.name}"?`)) return;
customBlocks.splice(idx, 1);
delete Blockly.Blocks[cb.type];
updateToolboxWithCustom();
renderManager();
showToast(`Deleted "${cb.name}"`);
});
acts.appendChild(editBtn); acts.appendChild(dupeBtn); acts.appendChild(expOneBtn); acts.appendChild(delBtn);
card.appendChild(acts);
listArea.appendChild(card);
});
});
}
box.appendChild(listArea);
// Footer with count
const ftr = document.createElement('div');
ftr.style.cssText = 'padding:8px 16px;border-top:1px solid var(--border-color);font-size:0.38rem;color:var(--text-muted);display:flex;align-items:center;gap:8px;flex-shrink:0;';
ftr.textContent = `${customBlocks.length} custom block(s)`;
if (customBlocks.length > 0) {
const clearAll = document.createElement('span');
clearAll.style.cssText = 'color:var(--danger);cursor:pointer;margin-left:auto;';
clearAll.textContent = 'Delete All';
clearAll.onclick = () => {
if (!confirm(`Delete all ${customBlocks.length} custom blocks?`)) return;
customBlocks.forEach(cb => delete Blockly.Blocks[cb.type]);
customBlocks.length = 0;
updateToolboxWithCustom();
renderManager();
showToast('All custom blocks deleted');
};
ftr.appendChild(clearAll);
}
box.appendChild(ftr);
}
function mk(icon, title, color, bg, onclick) {
const btn = document.createElement('div');
btn.style.cssText = `width:24px;height:24px;border-radius:4px;background:${bg};border:1px solid ${color};color:${color};cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:0.4rem;`;
btn.innerHTML = `<i class="bi ${icon}"></i>`;
btn.title = title;
btn.onclick = onclick;
return btn;
}
renderManager();
overlay.appendChild(box);
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
document.body.appendChild(overlay);
}
// ── Block Editor (create / edit a single block) ──
function showBlockEditor(editBlock) {
let overlay = document.getElementById('bb-overlay');
if (overlay) overlay.remove();
overlay = document.createElement('div');
overlay.id = 'bb-overlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:500;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(3px);';
const box = document.createElement('div');
box.style.cssText = 'background:var(--bg-panel);border:1px solid var(--border-color);border-radius:10px;padding:16px;width:540px;max-width:95vw;max-height:90vh;overflow-y:auto;';
const state = editBlock ? { ...editBlock, fields: editBlock.fields.map(f => ({...f})) } : {
type: '', name: '', category: 'Custom', colour: 45, shape: 'statement', tooltip: '',
fields: [{ kind:'label', text:'my block', label:'', name:'', default:'' }],
codeTemplate: "// your code;\n"
};
function bbLbl(t) { const l = document.createElement('span'); l.style.cssText = 'font-size:0.42rem;color:var(--text-muted);white-space:nowrap;'; l.textContent = t; return l; }
function bbInp(v, fn, ph) { const i = document.createElement('input'); i.className = 'pp-input'; i.value = v||''; if(ph)i.placeholder=ph; i.onchange = () => fn(i.value); return i; }
function render() {
box.innerHTML = '';
// Header with back button
const hdr = document.createElement('div');
hdr.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:10px;';
const backBtn = document.createElement('div');
backBtn.style.cssText = 'width:24px;height:24px;border-radius:4px;background:rgba(255,255,255,0.04);border:1px solid var(--border-color);color:var(--text-muted);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:0.4rem;';
backBtn.innerHTML = '<i class="bi bi-arrow-left"></i>';
backBtn.title = 'Back to Manager';
backBtn.onclick = () => { overlay.remove(); showBlockManager(); };
hdr.appendChild(backBtn);
const h = document.createElement('div');
h.style.cssText = "font-family:'Orbitron',sans-serif;font-size:0.5rem;color:var(--accent);letter-spacing:1px;flex:1;";
h.textContent = editBlock ? 'EDIT BLOCK' : 'NEW BLOCK';
hdr.appendChild(h);
box.appendChild(hdr);
// Row: Name + Color
const r1 = document.createElement('div'); r1.style.cssText = 'display:flex;gap:6px;margin-bottom:8px;align-items:center;';
r1.appendChild(bbLbl('Name')); const ni = bbInp(state.name, v => state.name = v, 'e.g. Move NPC'); ni.style.flex = '1'; r1.appendChild(ni);
r1.appendChild(bbLbl('Hue')); const ci = document.createElement('input'); ci.type = 'number'; ci.className = 'pp-input';
ci.style.width = '50px'; ci.min = 0; ci.max = 360; ci.value = state.colour; ci.onchange = () => { state.colour = parseInt(ci.value)||45; }; r1.appendChild(ci);
box.appendChild(r1);
// Row: Category + Shape
const r2 = document.createElement('div'); r2.style.cssText = 'display:flex;gap:6px;margin-bottom:8px;align-items:center;';
r2.appendChild(bbLbl('Category'));
const catSel = document.createElement('select'); catSel.className = 'pp-select'; catSel.style.flex = '1';
// Include existing categories from customBlocks + defaults
const allCats = new Set(TOOLBOX_CATEGORIES);
customBlocks.forEach(cb => allCats.add(cb.category || 'Custom'));
[...allCats].sort().forEach(c => { const o = document.createElement('option'); o.value = c; o.textContent = c; if (c === state.category) o.selected = true; catSel.appendChild(o); });
catSel.onchange = () => state.category = catSel.value; r2.appendChild(catSel);
// Custom category input
r2.appendChild(bbLbl('or'));
const newCatInp = bbInp('', v => { if (v.trim()) { state.category = v.trim(); render(); } }, 'New category');
newCatInp.style.width = '80px'; r2.appendChild(newCatInp);
r2.appendChild(bbLbl('Returns'));
const shSel = document.createElement('select'); shSel.className = 'pp-select'; shSel.style.width = '100px';
[['statement','Statement ▮'],['value','Value ◯']].forEach(([v,t]) => {
const o = document.createElement('option'); o.value = v; o.textContent = t; if (v === state.shape) o.selected = true; shSel.appendChild(o);
}); shSel.onchange = () => state.shape = shSel.value; r2.appendChild(shSel);
box.appendChild(r2);
// Tooltip
const r3 = document.createElement('div'); r3.style.cssText = 'display:flex;gap:6px;margin-bottom:10px;align-items:center;';
r3.appendChild(bbLbl('Tooltip')); const tti = bbInp(state.tooltip||'', v => state.tooltip = v, 'Description'); tti.style.flex = '1'; r3.appendChild(tti);
box.appendChild(r3);
// Fields header
const fh = document.createElement('div'); fh.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;';
const fl = document.createElement('span'); fl.style.cssText = "font-family:'Orbitron',sans-serif;font-size:0.4rem;color:var(--accent2);letter-spacing:1px;"; fl.textContent = 'FIELDS';
const addBtn = document.createElement('button'); addBtn.className = 'pp-btn';
addBtn.style.cssText = 'font-size:0.4rem;padding:2px 8px;background:rgba(167,139,250,0.08);border-color:var(--accent2);color:var(--accent2);';
addBtn.innerHTML = '<i class="bi bi-plus"></i> Add Field';
addBtn.onclick = () => { state.fields.push({ kind:'text', text:'', label:'', name:'F'+state.fields.length, default:'' }); render(); };
fh.appendChild(fl); fh.appendChild(addBtn); box.appendChild(fh);
// Each field
state.fields.forEach((f, i) => {
const card = document.createElement('div');
card.style.cssText = 'background:rgba(0,0,0,0.15);border:1px solid var(--border-color);border-radius:6px;padding:6px 8px;margin-bottom:4px;';
const row = document.createElement('div'); row.style.cssText = 'display:flex;gap:4px;align-items:center;flex-wrap:wrap;';
const kindSel = document.createElement('select'); kindSel.className = 'pp-select'; kindSel.style.width = '85px';
['label','text','number','dropdown','checkbox','value','statement'].forEach(k => {
const o = document.createElement('option'); o.value = k; o.textContent = k; if (k === f.kind) o.selected = true; kindSel.appendChild(o);
});
kindSel.onchange = () => { f.kind = kindSel.value; if (!f.name && f.kind !== 'label') f.name = 'F'+i; render(); };
row.appendChild(kindSel);
if (f.kind === 'label') {
const ti = bbInp(f.text||'', v => f.text = v, 'display text'); ti.style.flex = '1'; row.appendChild(ti);
} else {
const nameI = bbInp(f.name||'', v => f.name = v.replace(/\W/g,''), 'NAME');
nameI.style.cssText = "width:60px;font-family:'Courier New',monospace;color:var(--accent);";
nameI.title = 'Use {' + (f.name||'NAME') + '} in code template'; row.appendChild(nameI);
if (f.kind === 'text') {
const li = bbInp(f.label||'', v => f.label = v, 'label'); li.style.width = '60px'; row.appendChild(li);
const di = bbInp(f.default||'', v => f.default = v, 'default'); di.style.flex = '1'; row.appendChild(di);
} else if (f.kind === 'number') {
const li = bbInp(f.label||'', v => f.label = v, 'lbl'); li.style.width = '40px'; row.appendChild(li);
const di = bbInp(f.default!=null?String(f.default):'0', v => f.default = parseFloat(v)||0, '0'); di.style.width = '40px'; row.appendChild(di);
} else if (f.kind === 'dropdown') {
const li = bbInp(f.label||'', v => f.label = v, 'lbl'); li.style.width = '40px'; row.appendChild(li);
const oi = bbInp((f.options||[]).map(o => Array.isArray(o)?o.join(':'):o).join(', '), v => {
f.options = v.split(',').map(s=>s.trim()).filter(s=>s).map(s => { const p=s.split(':'); return p.length>1?[p[0].trim(),p[1].trim()]:[s.trim(),s.trim()]; });
}, 'Label:val, Label:val'); oi.style.flex = '1'; row.appendChild(oi);
} else if (f.kind === 'value' || f.kind === 'statement' || f.kind === 'checkbox') {
const li = bbInp(f.label||'', v => f.label = v, 'label'); li.style.flex = '1'; row.appendChild(li);
}
}
const moveUp = document.createElement('div'); moveUp.style.cssText = 'cursor:pointer;font-size:0.4rem;color:var(--text-muted);padding:2px;'; moveUp.innerHTML = '▲'; moveUp.onclick = () => { if (i>0) { [state.fields[i-1],state.fields[i]]=[state.fields[i],state.fields[i-1]]; render(); }};
const moveDown = document.createElement('div'); moveDown.style.cssText = 'cursor:pointer;font-size:0.4rem;color:var(--text-muted);padding:2px;'; moveDown.innerHTML = '▼'; moveDown.onclick = () => { if (i<state.fields.length-1) { [state.fields[i],state.fields[i+1]]=[state.fields[i+1],state.fields[i]]; render(); }};
const del = document.createElement('div'); del.style.cssText = 'width:18px;height:18px;border-radius:3px;background:rgba(255,77,106,0.08);border:1px solid var(--danger);color:var(--danger);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:0.38rem;flex-shrink:0;'; del.innerHTML = '<i class="bi bi-x"></i>'; del.onclick = () => { state.fields.splice(i,1); render(); };
row.appendChild(moveUp); row.appendChild(moveDown); row.appendChild(del);
card.appendChild(row); box.appendChild(card);
});
// Code template
const ctH = document.createElement('div'); ctH.style.cssText = "font-family:'Orbitron',sans-serif;font-size:0.4rem;color:var(--accent2);letter-spacing:1px;margin:10px 0 4px;"; ctH.textContent = 'CODE TEMPLATE';
box.appendChild(ctH);
const ctHint = document.createElement('div'); ctHint.style.cssText = 'font-size:0.42rem;color:var(--text-muted);margin-bottom:4px;line-height:1.4;';
const fieldNames = state.fields.filter(f => f.name).map(f => '{' + f.name + '}');
ctHint.innerHTML = fieldNames.length
? 'Placeholders: ' + fieldNames.map(n => `<code style="color:var(--accent);background:rgba(0,229,160,0.08);padding:0 3px;border-radius:2px;">${n}</code>`).join(' ')
: 'Add named fields above, then use <code style="color:var(--accent);">{FIELD_NAME}</code> in your code';
box.appendChild(ctHint);
const ctTa = document.createElement('textarea'); ctTa.className = 'pp-input';
ctTa.style.cssText = 'width:100%;min-height:70px;resize:vertical;font-family:"Courier New",monospace;font-size:0.5rem;line-height:1.4;';
ctTa.value = state.codeTemplate||''; ctTa.onchange = () => state.codeTemplate = ctTa.value;
box.appendChild(ctTa);
// Action buttons
const btns = document.createElement('div'); btns.style.cssText = 'display:flex;gap:6px;margin-top:12px;';
const saveBtn = document.createElement('button'); saveBtn.className = 'pp-btn';
saveBtn.style.cssText = 'flex:1;background:rgba(0,229,160,0.08);border-color:var(--accent);color:var(--accent);padding:6px;font-size:0.45rem;';
saveBtn.innerHTML = '<i class="bi bi-check-lg"></i> ' + (editBlock ? 'Update Block' : 'Create Block');
saveBtn.onclick = () => {
if (!state.name.trim()) { showToast('Name required', 'error'); return; }
if (!state.type) state.type = 'custom_' + state.name.toLowerCase().replace(/[^a-z0-9]/g,'_').replace(/_+/g,'_');
state.fields = state.fields.filter(f => f.kind === 'label' ? f.text : true);
if (editBlock) {
const idx = customBlocks.findIndex(c => c.type === editBlock.type);
if (idx >= 0) customBlocks[idx] = { ...state };
} else {
if (customBlocks.some(c => c.type === state.type)) state.type += '_' + Date.now().toString(36).slice(-4);
customBlocks.push({ ...state });
}
registerCustomBlocks();
overlay.remove();
showBlockManager();
showToast(`Block "${state.name}" ${editBlock ? 'updated' : 'created'}`);
};
const cancelBtn = document.createElement('button'); cancelBtn.className = 'pp-btn';
cancelBtn.style.cssText = 'flex:1;background:rgba(255,255,255,0.04);border-color:var(--border-color);color:var(--text-secondary);padding:6px;font-size:0.45rem;';
cancelBtn.textContent = 'Back to Manager';
cancelBtn.onclick = () => { overlay.remove(); showBlockManager(); };
btns.appendChild(saveBtn); btns.appendChild(cancelBtn); box.appendChild(btns);
}
render();
overlay.appendChild(box);
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
document.body.appendChild(overlay);
}
codeMain.addEventListener('keydown', e => {
if (e.key === 'Tab') {
e.preventDefault();
const start = codeMain.selectionStart;
const end = codeMain.selectionEnd;
codeMain.value = codeMain.value.substring(0, start) + ' ' + codeMain.value.substring(end);
codeMain.selectionStart = codeMain.selectionEnd = start + 2;
}
// Ctrl+Enter to run
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
document.getElementById('code-run').click();
}
});
// ══════════════════════════════════════
// INIT
// ══════════════════════════════════════
canvasArea.classList.add('props-hidden');
loadConfig();
</script>
</body>
</html>