better responsiveness

This commit is contained in:
2025-07-05 18:42:17 +02:00
parent bacc6f0325
commit 7df2b49c26
5 changed files with 476 additions and 52 deletions

View File

@ -13,6 +13,9 @@
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Bitfielder - Bitfield Shader App">
<meta name="twitter:description" content="Interactive bitfield shader editor for creating visual patterns using bitwise operations">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--ui-opacity: 0.3;
@ -27,7 +30,7 @@
body {
background: #000;
color: #fff;
font-family: monospace;
font-family: 'IBM Plex Mono', monospace;
overflow: hidden;
}
@ -67,6 +70,137 @@
display: flex;
gap: 10px;
margin-left: auto;
align-items: center;
}
#topbar .controls-desktop {
display: flex;
gap: 10px;
align-items: center;
}
#topbar .controls-mobile {
display: none;
gap: 8px;
align-items: center;
margin-left: auto;
}
#hamburger-menu {
display: none;
background: rgba(255, 255, 255, 0.1);
border: 1px solid #555;
color: #fff;
width: 36px;
height: 36px;
padding: 0;
border-radius: 4px;
cursor: pointer;
align-items: center;
justify-content: center;
}
#hamburger-menu:hover {
background: rgba(255, 255, 255, 0.2);
}
#hamburger-menu svg {
width: 18px;
height: 18px;
}
#mobile-menu {
position: fixed;
top: 40px;
right: -320px;
width: 320px;
max-width: 80vw;
height: calc(100vh - 40px);
background: rgba(0, 0, 0, var(--ui-opacity));
backdrop-filter: blur(3px);
border-left: 1px solid rgba(255, 255, 255, 0.1);
z-index: 150;
transition: right 0.3s ease;
overflow-y: auto;
padding: 20px;
}
#mobile-menu.open {
right: 0;
}
#mobile-menu h3 {
color: #fff;
font-size: 16px;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.mobile-menu-section {
margin-bottom: 20px;
}
.mobile-menu-item {
margin-bottom: 15px;
}
.mobile-menu-item label {
display: block;
color: #ccc;
font-size: 12px;
margin-bottom: 5px;
}
.mobile-menu-item select,
.mobile-menu-item input[type="range"] {
width: 100%;
background: rgba(255, 255, 255, 0.1);
border: 1px solid #555;
color: #fff;
padding: 8px;
border-radius: 4px;
font-size: 14px;
}
.mobile-menu-buttons {
display: flex;
flex-direction: column;
gap: 10px;
}
.mobile-menu-buttons button {
background: rgba(255, 255, 255, 0.1);
border: 1px solid #555;
color: #fff;
padding: 12px;
border-radius: 4px;
cursor: pointer;
font-family: 'IBM Plex Mono', monospace;
font-size: 14px;
text-align: left;
display: flex;
align-items: center;
gap: 10px;
}
.mobile-menu-buttons button:hover {
background: rgba(255, 255, 255, 0.2);
}
#mobile-menu-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 149;
}
#mobile-menu-overlay.open {
display: block;
}
#topbar button {
@ -76,7 +210,7 @@
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-family: monospace;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
}
@ -84,6 +218,30 @@
background: rgba(255, 255, 255, 0.2);
}
.icon-button {
width: 36px;
height: 36px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.icon-button svg {
width: 18px;
height: 18px;
}
/* Lucide icon styles */
[data-lucide] {
display: inline-block;
vertical-align: middle;
}
button svg {
pointer-events: none;
}
#editor-panel {
position: fixed;
bottom: 0;
@ -113,7 +271,7 @@
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: #fff;
font-family: monospace;
font-family: 'IBM Plex Mono', monospace;
font-size: 16px;
padding: 15px;
resize: none;
@ -127,7 +285,7 @@
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
padding: 20px 30px;
font-family: monospace;
font-family: 'IBM Plex Mono', monospace;
font-size: 16px;
font-weight: bold;
cursor: pointer;
@ -230,7 +388,7 @@
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-family: monospace;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
z-index: 1000;
display: none;
@ -242,14 +400,14 @@
#shader-library {
position: fixed;
top: 0;
right: -300px;
top: 40px;
left: -300px;
width: 300px;
height: 100vh;
height: calc(100vh - 40px);
background: rgba(0, 0, 0, calc(var(--ui-opacity) + 0.1));
border-left: 1px solid rgba(255, 255, 255, 0.1);
border-right: 1px solid rgba(255, 255, 255, 0.1);
z-index: 90;
transition: right 0.3s ease;
transition: left 0.3s ease;
backdrop-filter: blur(3px);
overflow-y: auto;
}
@ -257,21 +415,21 @@
#shader-library-trigger {
position: fixed;
top: 0;
right: 0;
top: 40px;
left: 0;
width: 20px;
height: 100vh;
height: calc(100vh - 40px);
z-index: 91;
cursor: pointer;
}
#shader-library-trigger:hover + #shader-library,
#shader-library:hover {
right: 0;
left: 0;
}
#shader-library.open {
right: 0;
left: 0;
}
.library-header {
@ -302,7 +460,7 @@
color: #fff;
padding: 6px 8px;
border-radius: 4px;
font-family: monospace;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
}
@ -317,7 +475,7 @@
color: #fff;
padding: 6px 8px;
border-radius: 4px;
font-family: monospace;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
}
@ -328,7 +486,7 @@
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-family: monospace;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
}
@ -380,7 +538,7 @@
padding: 4px 6px;
border-radius: 3px;
transition: all 0.2s ease;
font-family: monospace;
font-family: 'IBM Plex Mono', monospace;
}
.shader-action:hover {
@ -411,7 +569,7 @@
padding: 8px 10px;
background: rgba(0, 0, 0, var(--ui-opacity));
color: #ccc;
font-family: monospace;
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
word-break: break-all;
@ -432,15 +590,29 @@
/* Responsive Design */
@media (max-width: 768px) {
#topbar .controls {
margin-left: auto;
}
#topbar .controls-desktop {
display: none;
}
#topbar .controls-mobile {
display: flex;
}
#hamburger-menu {
display: flex;
}
#topbar {
flex-wrap: wrap;
height: auto;
padding: 10px;
height: 40px;
padding: 0 10px;
}
#topbar .title {
margin-right: 10px;
margin-bottom: 5px;
margin-right: auto;
}
#topbar .controls {
@ -487,11 +659,13 @@
#shader-library {
width: 100%;
right: -100%;
left: -100%;
top: 40px;
height: calc(100vh - 40px);
}
#shader-library-trigger {
width: 30px;
display: none;
}
}
@ -558,26 +732,82 @@
<div id="topbar">
<div class="title">Bitfielder</div>
<div class="controls">
<label style="color: #ccc; font-size: 12px; margin-right: 10px;">
Resolution:
<select id="resolution-select" style="background: rgba(255,255,255,0.1); border: 1px solid #555; color: #fff; padding: 4px; border-radius: 4px;">
<div class="controls-desktop">
<label style="color: #ccc; font-size: 12px; margin-right: 10px;">
Resolution:
<select id="resolution-select" style="background: rgba(255,255,255,0.1); border: 1px solid #555; color: #fff; padding: 4px; border-radius: 4px;">
<option value="1">Full (1x)</option>
<option value="2">Half (2x)</option>
<option value="4">Quarter (4x)</option>
<option value="8">Eighth (8x)</option>
</select>
</label>
<label style="color: #ccc; font-size: 12px; margin-right: 10px;">
FPS:
<select id="fps-select" style="background: rgba(255,255,255,0.1); border: 1px solid #555; color: #fff; padding: 4px; border-radius: 4px;">
<option value="15">15 FPS</option>
<option value="30" selected>30 FPS</option>
<option value="60">60 FPS</option>
</select>
</label>
<label style="color: #ccc; font-size: 12px; margin-right: 10px;">
Render Mode:
<select id="render-mode-select" style="background: rgba(255,255,255,0.1); border: 1px solid #555; color: #fff; padding: 4px; border-radius: 4px;">
<option value="classic" selected>Classic</option>
<option value="grayscale">Grayscale</option>
<option value="red">Red Channel</option>
<option value="green">Green Channel</option>
<option value="blue">Blue Channel</option>
<option value="rgb">RGB Split</option>
<option value="hsv">HSV</option>
<option value="rainbow">Rainbow</option>
</select>
</label>
<label style="color: #ccc; font-size: 12px; margin-right: 10px;">
UI Opacity:
<input type="range" id="opacity-slider" min="10" max="100" value="30" style="width: 80px; vertical-align: middle;">
<span id="opacity-value" style="font-size: 11px;">30%</span>
</label>
<button id="help-btn">?</button>
<button id="fullscreen-btn">Fullscreen</button>
<button id="hide-ui-btn">Hide UI</button>
<button id="random-btn">Random</button>
<button id="share-btn">Share</button>
<button id="export-png-btn">Export PNG</button>
</div>
<div class="controls-mobile">
<button id="library-btn-mobile" class="icon-button" aria-label="Shader Library"></button>
<button id="random-btn-mobile" class="icon-button" aria-label="Random"></button>
<button id="hide-ui-btn-mobile" class="icon-button" aria-label="Hide UI"></button>
<button id="hamburger-menu" class="icon-button" aria-label="Menu"></button>
</div>
</div>
</div>
<div id="mobile-menu-overlay"></div>
<div id="mobile-menu">
<h3>Settings</h3>
<div class="mobile-menu-section">
<div class="mobile-menu-item">
<label>Resolution</label>
<select id="resolution-select-mobile">
<option value="1">Full (1x)</option>
<option value="2">Half (2x)</option>
<option value="4">Quarter (4x)</option>
<option value="8">Eighth (8x)</option>
</select>
</label>
<label style="color: #ccc; font-size: 12px; margin-right: 10px;">
FPS:
<select id="fps-select" style="background: rgba(255,255,255,0.1); border: 1px solid #555; color: #fff; padding: 4px; border-radius: 4px;">
</div>
<div class="mobile-menu-item">
<label>FPS</label>
<select id="fps-select-mobile">
<option value="15">15 FPS</option>
<option value="30" selected>30 FPS</option>
<option value="60">60 FPS</option>
</select>
</label>
<label style="color: #ccc; font-size: 12px; margin-right: 10px;">
Render Mode:
<select id="render-mode-select" style="background: rgba(255,255,255,0.1); border: 1px solid #555; color: #fff; padding: 4px; border-radius: 4px;">
</div>
<div class="mobile-menu-item">
<label>Render Mode</label>
<select id="render-mode-select-mobile">
<option value="classic" selected>Classic</option>
<option value="grayscale">Grayscale</option>
<option value="red">Red Channel</option>
@ -587,18 +817,19 @@
<option value="hsv">HSV</option>
<option value="rainbow">Rainbow</option>
</select>
</label>
<label style="color: #ccc; font-size: 12px; margin-right: 10px;">
UI Opacity:
<input type="range" id="opacity-slider" min="10" max="100" value="30" style="width: 80px; vertical-align: middle;">
<span id="opacity-value" style="font-size: 11px;">30%</span>
</label>
<button id="help-btn">?</button>
<button id="fullscreen-btn">Fullscreen</button>
<button id="hide-ui-btn">Hide UI</button>
<button id="random-btn">Random</button>
<button id="share-btn">Share</button>
<button id="export-png-btn">Export PNG</button>
</div>
<div class="mobile-menu-item">
<label>UI Opacity: <span id="opacity-value-mobile">30%</span></label>
<input type="range" id="opacity-slider-mobile" min="10" max="100" value="30">
</div>
</div>
<div class="mobile-menu-section">
<div class="mobile-menu-buttons">
<button id="help-btn-mobile"><span class="icon"></span> Help</button>
<button id="fullscreen-btn-mobile"><span class="icon"></span> Fullscreen</button>
<button id="share-btn-mobile"><span class="icon"></span> Share</button>
<button id="export-png-btn-mobile"><span class="icon"></span> Export PNG</button>
</div>
</div>
</div>

9
package-lock.json generated
View File

@ -8,6 +8,9 @@
"name": "bitfielder",
"version": "1.0.0",
"license": "AGPL3",
"dependencies": {
"lucide": "^0.525.0"
},
"devDependencies": {
"typescript": "^5.0.0",
"vite": "^4.0.0"
@ -443,6 +446,12 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/lucide": {
"version": "0.525.0",
"resolved": "https://registry.npmjs.org/lucide/-/lucide-0.525.0.tgz",
"integrity": "sha512-sfehWlaE/7NVkcEQ4T9JD3eID8RNMIGJBBUq9wF3UFiJIrcMKRbU3g1KGfDk4svcW7yw8BtDLXaXo02scDtUYQ==",
"license": "ISC"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",

View File

@ -42,5 +42,8 @@
"dist",
"README.md",
"LICENSE"
]
],
"dependencies": {
"lucide": "^0.525.0"
}
}

42
src/icons.ts Normal file
View File

@ -0,0 +1,42 @@
import { createIcons, icons } from 'lucide';
export function createIcon(name: string, size: number = 16): string {
const iconMap: Record<string, string> = {
'menu': 'menu',
'close': 'x',
'help': 'help-circle',
'fullscreen': 'maximize-2',
'show': 'eye',
'hide': 'eye-off',
'random': 'dice-3',
'share': 'share-2',
'export': 'download',
'play': 'play',
'settings': 'settings',
'resolution': 'monitor',
'fps': 'zap',
'palette': 'palette',
'library': 'book-open'
};
const iconName = iconMap[name];
if (!iconName) return '';
return `<i data-lucide="${iconName}" width="${size}" height="${size}" stroke-width="2"></i>`;
}
export function addIconToButton(button: HTMLElement, iconName: string, keepText: boolean = false): void {
const originalText = button.textContent || '';
const iconHtml = createIcon(iconName);
if (keepText) {
button.innerHTML = iconHtml + ' ' + originalText;
} else {
button.innerHTML = iconHtml;
button.setAttribute('aria-label', originalText);
}
}
export function initializeLucideIcons(): void {
createIcons({ icons });
}

View File

@ -1,5 +1,6 @@
import { FakeShader } from './FakeShader';
import { Storage } from './Storage';
import { addIconToButton, createIcon, initializeLucideIcons } from './icons';
class BitfielderApp {
private shader: FakeShader;
@ -21,6 +22,7 @@ class BitfielderApp {
this.shader = new FakeShader(this.canvas, this.editor.value);
this.setupEventListeners();
this.initializeIcons();
this.loadFromURL();
this.renderShaderLibrary();
this.render();
@ -59,11 +61,29 @@ class BitfielderApp {
const fpsSelect = document.getElementById('fps-select') as HTMLSelectElement;
const renderModeSelect = document.getElementById('render-mode-select') as HTMLSelectElement;
const opacitySlider = document.getElementById('opacity-slider') as HTMLInputElement;
const opacityValue = document.getElementById('opacity-value')!;
const evalBtn = document.getElementById('eval-btn')!;
const helpPopup = document.getElementById('help-popup')!;
const closeBtn = helpPopup.querySelector('.close-btn')!;
// Mobile elements
const hamburgerMenu = document.getElementById('hamburger-menu')!;
const mobileMenuOverlay = document.getElementById('mobile-menu-overlay')!;
const randomBtnMobile = document.getElementById('random-btn-mobile')!;
const hideUiBtnMobile = document.getElementById('hide-ui-btn-mobile')!;
const libraryBtnMobile = document.getElementById('library-btn-mobile')!;
// Mobile menu controls
const resolutionSelectMobile = document.getElementById('resolution-select-mobile') as HTMLSelectElement;
const fpsSelectMobile = document.getElementById('fps-select-mobile') as HTMLSelectElement;
const renderModeSelectMobile = document.getElementById('render-mode-select-mobile') as HTMLSelectElement;
const opacitySliderMobile = document.getElementById('opacity-slider-mobile') as HTMLInputElement;
// Mobile menu buttons
const helpBtnMobile = document.getElementById('help-btn-mobile')!;
const fullscreenBtnMobile = document.getElementById('fullscreen-btn-mobile')!;
const shareBtnMobile = document.getElementById('share-btn-mobile')!;
const exportPngBtnMobile = document.getElementById('export-png-btn-mobile')!;
// Library elements
const saveShaderBtn = document.getElementById('save-shader-btn')!;
const shaderNameInput = document.getElementById('shader-name-input') as HTMLInputElement;
@ -92,6 +112,48 @@ class BitfielderApp {
});
shaderSearchInput.addEventListener('input', () => this.renderShaderLibrary());
// Mobile event listeners
hamburgerMenu.addEventListener('click', () => this.toggleMobileMenu());
mobileMenuOverlay.addEventListener('click', () => this.closeMobileMenu());
randomBtnMobile.addEventListener('click', () => this.generateRandom());
hideUiBtnMobile.addEventListener('click', () => this.toggleUI());
libraryBtnMobile.addEventListener('click', () => this.toggleShaderLibrary());
// Mobile menu controls sync with desktop
resolutionSelectMobile.addEventListener('change', () => {
resolutionSelect.value = resolutionSelectMobile.value;
this.updateResolution();
});
fpsSelectMobile.addEventListener('change', () => {
fpsSelect.value = fpsSelectMobile.value;
this.updateFPS();
});
renderModeSelectMobile.addEventListener('change', () => {
renderModeSelect.value = renderModeSelectMobile.value;
this.updateRenderMode();
});
opacitySliderMobile.addEventListener('input', () => {
opacitySlider.value = opacitySliderMobile.value;
this.updateUIOpacity();
});
// Mobile menu buttons
helpBtnMobile.addEventListener('click', () => {
this.closeMobileMenu();
this.showHelp();
});
fullscreenBtnMobile.addEventListener('click', () => {
this.closeMobileMenu();
this.toggleFullscreen();
});
shareBtnMobile.addEventListener('click', () => {
this.closeMobileMenu();
this.shareURL();
});
exportPngBtnMobile.addEventListener('click', () => {
this.closeMobileMenu();
this.exportPNG();
});
// Close help popup when clicking outside
helpPopup.addEventListener('click', (e) => {
@ -285,13 +347,83 @@ class BitfielderApp {
private updateUIOpacity(): void {
const opacitySlider = document.getElementById('opacity-slider') as HTMLInputElement;
const opacityValue = document.getElementById('opacity-value')!;
const opacityValueMobile = document.getElementById('opacity-value-mobile')!;
const opacity = parseInt(opacitySlider.value) / 100;
document.documentElement.style.setProperty('--ui-opacity', opacity.toString());
opacityValue.textContent = `${opacitySlider.value}%`;
opacityValueMobile.textContent = `${opacitySlider.value}%`;
Storage.saveSettings({ uiOpacity: opacity });
}
private initializeIcons(): void {
// Desktop buttons
addIconToButton(document.getElementById('help-btn')!, 'help');
addIconToButton(document.getElementById('fullscreen-btn')!, 'fullscreen');
addIconToButton(document.getElementById('hide-ui-btn')!, 'hide');
addIconToButton(document.getElementById('random-btn')!, 'random');
addIconToButton(document.getElementById('share-btn')!, 'share');
addIconToButton(document.getElementById('export-png-btn')!, 'export');
// Mobile buttons
addIconToButton(document.getElementById('hamburger-menu')!, 'menu');
addIconToButton(document.getElementById('library-btn-mobile')!, 'library');
addIconToButton(document.getElementById('random-btn-mobile')!, 'random');
addIconToButton(document.getElementById('hide-ui-btn-mobile')!, 'hide');
addIconToButton(document.getElementById('show-ui-btn')!, 'show');
// Mobile menu buttons with text
const helpIcon = document.querySelector('#help-btn-mobile .icon') as HTMLElement;
const fullscreenIcon = document.querySelector('#fullscreen-btn-mobile .icon') as HTMLElement;
const shareIcon = document.querySelector('#share-btn-mobile .icon') as HTMLElement;
const exportIcon = document.querySelector('#export-png-btn-mobile .icon') as HTMLElement;
if (helpIcon) helpIcon.innerHTML = createIcon('help');
if (fullscreenIcon) fullscreenIcon.innerHTML = createIcon('fullscreen');
if (shareIcon) shareIcon.innerHTML = createIcon('share');
if (exportIcon) exportIcon.innerHTML = createIcon('export');
// Initialize all Lucide icons
initializeLucideIcons();
}
private toggleMobileMenu(): void {
const mobileMenu = document.getElementById('mobile-menu')!;
const mobileMenuOverlay = document.getElementById('mobile-menu-overlay')!;
const hamburgerMenu = document.getElementById('hamburger-menu')!;
mobileMenu.classList.toggle('open');
mobileMenuOverlay.classList.toggle('open');
// Update hamburger icon
if (mobileMenu.classList.contains('open')) {
addIconToButton(hamburgerMenu, 'close');
} else {
addIconToButton(hamburgerMenu, 'menu');
}
// Reinitialize icons
initializeLucideIcons();
}
private closeMobileMenu(): void {
const mobileMenu = document.getElementById('mobile-menu')!;
const mobileMenuOverlay = document.getElementById('mobile-menu-overlay')!;
const hamburgerMenu = document.getElementById('hamburger-menu')!;
mobileMenu.classList.remove('open');
mobileMenuOverlay.classList.remove('open');
addIconToButton(hamburgerMenu, 'menu');
// Reinitialize icons
initializeLucideIcons();
}
private toggleShaderLibrary(): void {
const shaderLibrary = document.getElementById('shader-library')!;
shaderLibrary.classList.toggle('open');
}
private evalShader(): void {
this.shader.setCode(this.editor.value);
@ -315,11 +447,18 @@ class BitfielderApp {
(document.getElementById('fps-select') as HTMLSelectElement).value = settings.fps.toString();
(document.getElementById('render-mode-select') as HTMLSelectElement).value = settings.renderMode || 'classic';
// Sync mobile controls
(document.getElementById('resolution-select-mobile') as HTMLSelectElement).value = settings.resolution.toString();
(document.getElementById('fps-select-mobile') as HTMLSelectElement).value = settings.fps.toString();
(document.getElementById('render-mode-select-mobile') as HTMLSelectElement).value = settings.renderMode || 'classic';
// Apply UI opacity
const opacity = settings.uiOpacity ?? 0.3;
document.documentElement.style.setProperty('--ui-opacity', opacity.toString());
(document.getElementById('opacity-slider') as HTMLInputElement).value = (opacity * 100).toString();
(document.getElementById('opacity-slider-mobile') as HTMLInputElement).value = (opacity * 100).toString();
document.getElementById('opacity-value')!.textContent = `${Math.round(opacity * 100)}%`;
document.getElementById('opacity-value-mobile')!.textContent = `${Math.round(opacity * 100)}%`;
// Load last shader code if no URL hash
if (!window.location.hash) {