refactor: Implement mobile-friendly control panel toggle and remove keyboard shortcuts hint.
This commit is contained in:
@@ -6,6 +6,24 @@ import TuiButton from "./TuiButton.astro";
|
||||
---
|
||||
|
||||
<footer id="tui-controls" class="control-panel">
|
||||
<div class="mobile-controls-header">
|
||||
<span class="mobile-controls-title">CONTROLS</span>
|
||||
<svg
|
||||
class="mobile-toggle-icon"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="control-panel-inner">
|
||||
<div class="control-panel-content">
|
||||
<!-- Sliders Section -->
|
||||
<div class="panel-section sliders-section">
|
||||
@@ -31,6 +49,26 @@ import TuiButton from "./TuiButton.astro";
|
||||
title="Contrast"
|
||||
description="Increases or decreases the difference between light and dark areas."
|
||||
/>
|
||||
<TuiSlider
|
||||
id="shadows"
|
||||
label="SHD"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={0.0}
|
||||
title="Shadow Lift"
|
||||
description="Brightens dark areas to recover details in shadows."
|
||||
/>
|
||||
<TuiSlider
|
||||
id="highlights"
|
||||
label="HLT"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={0.0}
|
||||
title="Highlight Dim"
|
||||
description="Darkens bright areas to recover details in highlights."
|
||||
/>
|
||||
<TuiSlider
|
||||
id="saturation"
|
||||
label="SAT"
|
||||
@@ -51,6 +89,16 @@ import TuiButton from "./TuiButton.astro";
|
||||
title="Gamma Correction"
|
||||
description="Non-linear brightness adjustment. useful for correcting washed-out or too dark images."
|
||||
/>
|
||||
<TuiSlider
|
||||
id="sharpen"
|
||||
label="SHP"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.01}
|
||||
value={0.0}
|
||||
title="Sharpen"
|
||||
description="Enhances edges and fine details using an unsharp mask filter."
|
||||
/>
|
||||
<TuiSlider
|
||||
id="overlayStrength"
|
||||
label="OVL"
|
||||
@@ -81,6 +129,36 @@ import TuiButton from "./TuiButton.astro";
|
||||
title="Dither Strength"
|
||||
description="Applies ordered dithering to simulate shading. Useful for low-contrast areas."
|
||||
/>
|
||||
<TuiSlider
|
||||
id="edgeThreshold"
|
||||
label="THR"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={0.5}
|
||||
title="Edge Threshold"
|
||||
description="Sets the sensitivity for edge detection. Higher values detect only strong edges."
|
||||
/>
|
||||
<TuiSlider
|
||||
id="scanlines"
|
||||
label="SCN"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={0.0}
|
||||
title="Scanlines"
|
||||
description="Adds CRT-style scanline effect."
|
||||
/>
|
||||
<TuiSlider
|
||||
id="vignette"
|
||||
label="VIG"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={0.0}
|
||||
title="Vignette"
|
||||
description="Darkens the corners of the image."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -98,6 +176,21 @@ import TuiButton from "./TuiButton.astro";
|
||||
description="Toggles between monochrome text and colored HTML spans."
|
||||
/>
|
||||
|
||||
<!-- Color Picker for Monochrome -->
|
||||
<div
|
||||
class="tui-color-btn"
|
||||
title="Monochrome Tint Color"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
id="input-mono-color"
|
||||
value="#ffffff"
|
||||
/>
|
||||
<span id="color-swatch-display" class="color-swatch"
|
||||
></span>
|
||||
<span class="label">TINT</span>
|
||||
</div>
|
||||
|
||||
<TuiToggle
|
||||
id="toggle-denoise"
|
||||
label="DNZ"
|
||||
@@ -224,8 +317,25 @@ import TuiButton from "./TuiButton.astro";
|
||||
<span><kbd>E</kbd> Edges</span>
|
||||
<span><kbd>S</kbd> Charset</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const controlPanel = document.getElementById("tui-controls");
|
||||
const toggleBtn = controlPanel?.querySelector(".mobile-controls-header");
|
||||
|
||||
if (controlPanel && toggleBtn) {
|
||||
// Default to collapsed on mobile
|
||||
if (window.innerWidth <= 1200) {
|
||||
controlPanel.classList.add("collapsed");
|
||||
}
|
||||
|
||||
toggleBtn.addEventListener("click", () => {
|
||||
controlPanel.classList.toggle("collapsed");
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Main Container matching Sidebar visual style */
|
||||
.control-panel {
|
||||
@@ -384,10 +494,62 @@ import TuiButton from "./TuiButton.astro";
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mobile-controls-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1200px) {
|
||||
.control-panel {
|
||||
padding: 0; /* Let inner content handle padding or just reset */
|
||||
}
|
||||
|
||||
.mobile-controls-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
cursor: pointer;
|
||||
background: #000;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.mobile-controls-title {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.mobile-toggle-icon {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.control-panel.collapsed .mobile-toggle-icon {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.control-panel-inner {
|
||||
padding: 1.5rem;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-height 0.4s ease,
|
||||
opacity 0.4s ease,
|
||||
padding 0.4s ease;
|
||||
max-height: 2000px;
|
||||
opacity: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.control-panel.collapsed .control-panel-inner {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
padding: 0 1.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.control-panel-content {
|
||||
@@ -408,4 +570,55 @@ import TuiButton from "./TuiButton.astro";
|
||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.tui-color-btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s;
|
||||
height: 29px; /* Match toggle/button height approx */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tui-color-btn:hover {
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.tui-color-btn input[type="color"] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
border: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #ffffff;
|
||||
border-radius: 2px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 0 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.tui-color-btn .label {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,22 @@
|
||||
---
|
||||
|
||||
<aside class="sidebar">
|
||||
<div class="mobile-header">
|
||||
<span class="mobile-brand">SYNTAXBULLET</span>
|
||||
<svg
|
||||
class="mobile-toggle-icon"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="sidebar-content">
|
||||
<div class="brand-group">
|
||||
<a href="/" class="brand-link">
|
||||
@@ -10,24 +26,24 @@
|
||||
</a>
|
||||
<div class="brand-subtitle">
|
||||
FULL STACK ENGINEER
|
||||
<span class="muted">//</span>
|
||||
<span class="muted">|</span>
|
||||
CREATIVE TECHNOLOGIST
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="brand-bio">
|
||||
Crafting high-performance digital experiences at the intersection of
|
||||
code, art, and artificial intelligence.
|
||||
engineering, design, and artificial intelligence.
|
||||
</p>
|
||||
|
||||
<div class="sidebar-actions">
|
||||
<a href="/projects" class="sidebar-link">
|
||||
<span class="icon">⚡</span> PROJECTS
|
||||
<a href="/" class="sidebar-link">
|
||||
<span class="icon">⚡</span> GENERATE
|
||||
</a>
|
||||
<a href="/blog" class="sidebar-link">
|
||||
<span class="icon">📝</span> BLOG
|
||||
</a>
|
||||
<a href="mailto:contact@syntaxbullet.me" class="sidebar-link">
|
||||
<a href="mailto:me@syntaxbullet.com" class="sidebar-link">
|
||||
<span class="icon">✉️</span> CONTACT
|
||||
</a>
|
||||
</div>
|
||||
@@ -116,6 +132,10 @@
|
||||
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.mobile-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
padding: 4rem 3rem;
|
||||
display: flex;
|
||||
@@ -224,13 +244,52 @@
|
||||
min-width: 0;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 3rem 0; /* Increased padding */
|
||||
padding: 0; /* Remove padding from container, move to toggle */
|
||||
}
|
||||
|
||||
.mobile-header {
|
||||
display: flex; /* Show on mobile */
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
cursor: pointer;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.mobile-brand {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 900;
|
||||
color: #fff;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.mobile-toggle-icon {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .mobile-toggle-icon {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
padding: 1rem 3rem; /* Increased horizontal padding */
|
||||
padding: 0 3rem 3rem 3rem; /* Adjust padding */
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-height 0.4s ease,
|
||||
opacity 0.4s ease,
|
||||
padding 0.4s ease;
|
||||
max-height: 1000px; /* Arbitrary large height */
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-content {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
padding: 0 3rem; /* Collapse padding */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brand-subtitle,
|
||||
@@ -238,5 +297,29 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Hide the large title in the content on mobile since we have the header,
|
||||
or keep it? The user might want the full bio etc.
|
||||
Let's keep the content as is, but maybe hide the "SYNTAXBULLET" big text in content if it's redundant?
|
||||
Actually, the design seems to desire the big brand text. I'll leave it. */
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const sidebar = document.querySelector(".sidebar");
|
||||
const toggleBtn = document.querySelector(".mobile-header");
|
||||
|
||||
if (sidebar && toggleBtn) {
|
||||
// Default to collapsed on mobile initially?
|
||||
// User didn't specify, but usually "collapsible" implies starting collapsed or having the ability.
|
||||
// Let's start expanded or collapsed? "Make... collapsible".
|
||||
// I'll default to collapsed to save space as requested "mobile friendly".
|
||||
if (window.innerWidth <= 1024) {
|
||||
sidebar.classList.add("collapsed");
|
||||
}
|
||||
|
||||
toggleBtn.addEventListener("click", () => {
|
||||
sidebar.classList.toggle("collapsed");
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import { getEntry } from "astro:content";
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import Sidebar from "../../components/Sidebar.astro";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
if (!slug) {
|
||||
@@ -16,117 +17,243 @@ if (!entry) {
|
||||
const { Content } = await entry.render();
|
||||
---
|
||||
|
||||
<Layout title={entry.data.title} showScroll={true}>
|
||||
<main>
|
||||
<article>
|
||||
<section class="h-entry">
|
||||
<header>
|
||||
<Layout title={entry.data.title}>
|
||||
<div class="split-layout">
|
||||
<Sidebar />
|
||||
|
||||
<main class="content-workspace">
|
||||
<div class="content-container">
|
||||
<article class="h-entry">
|
||||
<header class="post-header">
|
||||
<a href="/blog" class="back-link">
|
||||
← Back to Blog
|
||||
</a>
|
||||
|
||||
<h1 class="p-name">{entry.data.title}</h1>
|
||||
|
||||
<div class="metadata">
|
||||
<time
|
||||
class="dt-published"
|
||||
datetime={entry.data.pubDate.toISOString()}
|
||||
>
|
||||
<span class="dt-published">
|
||||
{entry.data.pubDate.toISOString().slice(0, 10)}
|
||||
</time>
|
||||
</span>
|
||||
{
|
||||
entry.data.updatedDate && (
|
||||
<div class="last-updated">
|
||||
Last updated on{" "}
|
||||
<time>
|
||||
<span class="updated-date">
|
||||
• Updated:{" "}
|
||||
{entry.data.updatedDate
|
||||
.toISOString()
|
||||
.slice(0, 10)}
|
||||
</time>
|
||||
</div>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
</header>
|
||||
|
||||
<div class="e-content">
|
||||
<Content />
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
main {
|
||||
width: calc(100% - 2em);
|
||||
/* Split Layout */
|
||||
.split-layout {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
/* Content Workspace */
|
||||
.content-workspace {
|
||||
flex-grow: 1;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #050505;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.content-container {
|
||||
max-width: 800px;
|
||||
margin: 0;
|
||||
padding: 2em;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 4rem 2rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 2rem;
|
||||
/* Header Styling */
|
||||
.post-header {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
header a {
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
font-family: var(--font-mono);
|
||||
margin-bottom: 1.5rem;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
header a:hover {
|
||||
opacity: 1;
|
||||
.back-link:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2em;
|
||||
margin: 0.25em 0 0;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-top: 1px solid var(--text-color);
|
||||
opacity: 0.3;
|
||||
margin: 1rem 0;
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1.2;
|
||||
color: #fff;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.metadata {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Markdown Styles */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Content Styling (Markdown) */
|
||||
.e-content {
|
||||
line-height: 1.6;
|
||||
font-family: var(--font-mono); /* Keep vibe */
|
||||
font-size: 1rem;
|
||||
line-height: 1.8;
|
||||
font-size: 1.1rem;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif; /* Clean reading font */
|
||||
}
|
||||
|
||||
/* Typography Overrides */
|
||||
.e-content :global(h1),
|
||||
.e-content :global(h2),
|
||||
.e-content :global(h3),
|
||||
.e-content :global(h4) {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-color);
|
||||
font-weight: bold;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
color: #fff;
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.3;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.e-content :global(h2) {
|
||||
font-size: 1.8rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
.e-content :global(h3) {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.e-content :global(p) {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.e-content :global(a) {
|
||||
color: var(--text-color);
|
||||
text-decoration: underline;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.5);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.e-content :global(a:hover) {
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
|
||||
.e-content :global(ul),
|
||||
.e-content :global(ol) {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.e-content :global(li) {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.e-content :global(blockquote) {
|
||||
border-left: 4px solid rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
margin: 2rem 0;
|
||||
padding: 1rem 1.5rem;
|
||||
font-style: italic;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.e-content :global(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px; /* Softer corners */
|
||||
margin: 2rem 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Code Blocks */
|
||||
.e-content :global(pre) {
|
||||
background: #111 !important; /* Force override */
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px; /* Softer corners */
|
||||
padding: 1.5rem;
|
||||
margin: 2rem 0;
|
||||
overflow-x: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.e-content :global(code) {
|
||||
background: #111;
|
||||
padding: 2px 5px;
|
||||
border-radius: 2px;
|
||||
font-family: var(--font-mono);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.e-content :global(pre) {
|
||||
background: #111;
|
||||
padding: 1rem;
|
||||
border: 1px solid #333;
|
||||
overflow-x: auto;
|
||||
.e-content :global(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.split-layout {
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.content-workspace {
|
||||
height: auto;
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import Sidebar from "../../components/Sidebar.astro";
|
||||
import { getCollection, type CollectionEntry } from "astro:content";
|
||||
|
||||
const posts = (await getCollection("blog")).sort(
|
||||
@@ -8,92 +9,195 @@ const posts = (await getCollection("blog")).sort(
|
||||
);
|
||||
---
|
||||
|
||||
<Layout title="System Logs" showScroll={true}>
|
||||
<main>
|
||||
<section>
|
||||
<ul>
|
||||
<Layout title="System Logs">
|
||||
<div class="split-layout">
|
||||
<Sidebar />
|
||||
|
||||
<main class="content-workspace">
|
||||
<div class="content-container">
|
||||
<header class="page-header">
|
||||
<h1>Blog Articles</h1>
|
||||
<div class="divider"></div>
|
||||
</header>
|
||||
|
||||
<ul class="post-list">
|
||||
{
|
||||
posts.map((post: any) => (
|
||||
<li>
|
||||
<a href={`/blog/${post.slug}/`}>
|
||||
<a
|
||||
href={`/blog/${post.slug}/`}
|
||||
class="post-link"
|
||||
>
|
||||
<div class="post-meta">
|
||||
<span class="date">
|
||||
[
|
||||
{post.data.pubDate
|
||||
.toISOString()
|
||||
.slice(0, 10)}
|
||||
]
|
||||
</span>
|
||||
<span class="title">{post.data.title}</span>
|
||||
</div>
|
||||
<div class="post-info">
|
||||
<span class="title">
|
||||
{post.data.title}
|
||||
</span>
|
||||
<span class="desc">
|
||||
// {post.data.description}
|
||||
{post.data.description}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<p>END OF STREAM</p>
|
||||
<p>© {new Date().getFullYear()} Syntaxbullet</p>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
main {
|
||||
width: 960px;
|
||||
max-width: calc(100% - 2em);
|
||||
margin: 0 auto;
|
||||
padding: 2em 0;
|
||||
/* Split Layout - Consistent with Homepage */
|
||||
.split-layout {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
ul {
|
||||
/* Content Workspace (Right Side) */
|
||||
.content-workspace {
|
||||
flex-grow: 1;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #050505;
|
||||
overflow-y: auto; /* Enable scrolling for content */
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.content-container {
|
||||
max-width: 900px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 4rem 2rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Header Styling */
|
||||
.page-header {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem 0;
|
||||
letter-spacing: -0.5px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Post List Styling */
|
||||
.post-list {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 1rem;
|
||||
border-left: 2px solid transparent;
|
||||
transition: border-left-color 0.2s;
|
||||
}
|
||||
|
||||
li:hover {
|
||||
border-left-color: var(--text-color);
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
.post-link {
|
||||
display: flex;
|
||||
flex-direction: column; /* Mobile first */
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
padding: 5px 10px;
|
||||
color: var(--text-color);
|
||||
font-family: var(--font-mono);
|
||||
padding: 1.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: rgba(255, 103, 0, 0.6);
|
||||
margin-right: 1rem;
|
||||
.post-link:hover {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.post-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
margin-right: 1rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: rgba(255, 103, 0, 0.4);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
a:hover .title {
|
||||
text-decoration: underline;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 4rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
text-align: center;
|
||||
opacity: 0.3;
|
||||
opacity: 0.5;
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--font-mono);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (min-width: 768px) {
|
||||
.post-link {
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
min-width: 120px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.split-layout {
|
||||
flex-direction: column;
|
||||
overflow: hidden; /* Prevent body scroll, use inner scrolling */
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.content-workspace {
|
||||
height: auto;
|
||||
flex-grow: 1; /* Fill remaining space */
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@ import Tooltip from "../components/Tooltip.astro";
|
||||
import ControlPanel from "../components/ControlPanel.astro";
|
||||
---
|
||||
|
||||
<Layout title="Syntaxbullet - Digital Wizard">
|
||||
<Layout title="Syntaxbullet - Full Stack Engineer">
|
||||
<div class="split-layout">
|
||||
<Sidebar />
|
||||
|
||||
@@ -14,7 +14,7 @@ import ControlPanel from "../components/ControlPanel.astro";
|
||||
<!-- Canvas Layer -->
|
||||
<div class="canvas-layer">
|
||||
<div id="loading">Loading...</div>
|
||||
<pre id="ascii-result">Preparing art...</pre>
|
||||
<pre id="ascii-result">Initializing...</pre>
|
||||
<canvas id="ascii-canvas"></canvas>
|
||||
</div>
|
||||
|
||||
@@ -104,10 +104,11 @@ import ControlPanel from "../components/ControlPanel.astro";
|
||||
console.error(e);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount++;
|
||||
asciiResult.textContent = `SIGNAL LOST. RETRYING (${retryCount}/${MAX_RETRIES})...`;
|
||||
asciiResult.textContent = `Connection lost. Retrying (${retryCount}/${MAX_RETRIES})...`;
|
||||
setTimeout(loadNewImage, 2000);
|
||||
} else {
|
||||
asciiResult.textContent = "SIGNAL LOST. PLEASE REFRESH.";
|
||||
asciiResult.textContent =
|
||||
"Connection failed. Please refresh.";
|
||||
controller.hideLoading();
|
||||
}
|
||||
}
|
||||
@@ -170,6 +171,7 @@ import ControlPanel from "../components/ControlPanel.astro";
|
||||
image-rendering: pixelated;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
#loading {
|
||||
@@ -191,11 +193,14 @@ import ControlPanel from "../components/ControlPanel.astro";
|
||||
@media (max-width: 1024px) {
|
||||
.split-layout {
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
overflow: hidden; /* Prevent body scroll, use inner scrolling */
|
||||
height: 100dvh; /* Dynamic viewport height */
|
||||
}
|
||||
|
||||
.ascii-workspace {
|
||||
height: 80vh; /* Give space for scroll */
|
||||
height: auto;
|
||||
flex-grow: 1; /* Fill remaining space */
|
||||
overflow-y: auto; /* Allow scrolling inside workspace if needed */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -59,6 +59,12 @@ export class AsciiController {
|
||||
showMagnifier: false
|
||||
};
|
||||
|
||||
// Touch state
|
||||
private lastTouchDist = 0;
|
||||
private isDragging = false;
|
||||
private lastTouchPos = { x: 0, y: 0 };
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
// Callbacks
|
||||
private onSettingsChange?: () => void;
|
||||
|
||||
@@ -77,6 +83,33 @@ export class AsciiController {
|
||||
}
|
||||
|
||||
this.startRenderLoop();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
// Resize handling
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
// Debounce resize
|
||||
if (this.animFrameId) {
|
||||
// We are in a loop, just toggle a flag or call safely
|
||||
this.handleResize();
|
||||
}
|
||||
});
|
||||
if (this.canvas.parentElement) {
|
||||
this.resizeObserver.observe(this.canvas.parentElement);
|
||||
}
|
||||
|
||||
// Touch events
|
||||
this.canvas.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: false });
|
||||
this.canvas.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false });
|
||||
this.canvas.addEventListener('touchend', this.handleTouchEnd.bind(this));
|
||||
}
|
||||
|
||||
private handleResize(): void {
|
||||
// Re-calculate grid based on new dimensions
|
||||
this.calculateGrid().then(() => {
|
||||
this.requestRender('all');
|
||||
});
|
||||
}
|
||||
|
||||
private getDefaultSettings(): AsciiSettings {
|
||||
@@ -92,7 +125,14 @@ export class AsciiController {
|
||||
edgeMode: 0,
|
||||
overlayStrength: 0.3,
|
||||
resolution: 1.0,
|
||||
charSet: 'standard'
|
||||
charSet: 'standard',
|
||||
sharpen: 0,
|
||||
edgeThreshold: 0.5,
|
||||
shadows: 0,
|
||||
highlights: 0,
|
||||
scanlines: 0,
|
||||
vignette: 0,
|
||||
monoColor: '#ffffff'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -213,7 +253,14 @@ export class AsciiController {
|
||||
overlayStrength: suggestions.overlayStrength ?? this.settings.overlayStrength,
|
||||
charSet: validCharSet,
|
||||
resolution: this.settings.resolution,
|
||||
color: this.settings.color
|
||||
color: this.settings.color,
|
||||
sharpen: suggestions.sharpen ?? this.settings.sharpen,
|
||||
edgeThreshold: suggestions.edgeThreshold ?? this.settings.edgeThreshold,
|
||||
shadows: suggestions.shadows ?? this.settings.shadows,
|
||||
highlights: suggestions.highlights ?? this.settings.highlights,
|
||||
scanlines: suggestions.scanlines ?? this.settings.scanlines,
|
||||
vignette: suggestions.vignette ?? this.settings.vignette,
|
||||
monoColor: suggestions.monoColor ?? this.settings.monoColor
|
||||
};
|
||||
|
||||
this.onSettingsChange?.();
|
||||
@@ -280,6 +327,9 @@ export class AsciiController {
|
||||
|
||||
let widthCols = Math.floor(availW / 6);
|
||||
widthCols = Math.floor(widthCols * this.settings.resolution);
|
||||
|
||||
// Cap grid resolution on mobile specifically if needed,
|
||||
// but current logic is mostly fine as long as resolution slider is manageable.
|
||||
widthCols = Math.max(10, Math.min(1000, widthCols));
|
||||
|
||||
const imgEl = await this.resolveImage(this.currentImgUrl);
|
||||
@@ -362,7 +412,9 @@ export class AsciiController {
|
||||
|
||||
this.canvas.style.width = `${finalW}px`;
|
||||
this.canvas.style.height = `${finalH}px`;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// Cap DPR to improve mobile performance
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2.0);
|
||||
this.canvas.width = finalW * dpr;
|
||||
this.canvas.height = finalH * dpr;
|
||||
}
|
||||
@@ -375,7 +427,7 @@ export class AsciiController {
|
||||
this.requestRender('all');
|
||||
}
|
||||
|
||||
// ============= Zoom =============
|
||||
// ============= Zoom & Touch =============
|
||||
|
||||
handleWheel(e: WheelEvent): void {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
@@ -401,13 +453,16 @@ export class AsciiController {
|
||||
}
|
||||
|
||||
handleMouseMove(e: MouseEvent): void {
|
||||
if ('ontouchstart' in window && (e as any).sourceCapabilities?.firesTouchEvents) return; // Ignore simulated mouse events
|
||||
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const mx = (e.clientX - rect.left) / rect.width;
|
||||
const my = (e.clientY - rect.top) / rect.height;
|
||||
|
||||
this.zoomState.mousePos = { x: mx, y: my };
|
||||
const wasShowing = this.zoomState.showMagnifier;
|
||||
this.zoomState.showMagnifier = mx >= 0 && mx <= 1 && my >= 0 && my <= 1;
|
||||
// Only show magnifier if not zoomed out completely
|
||||
this.zoomState.showMagnifier = mx >= 0 && mx <= 1 && my >= 0 && my <= 1 && !('ontouchstart' in window);
|
||||
|
||||
if (this.zoomState.showMagnifier || wasShowing) {
|
||||
this.requestRender('uniforms');
|
||||
@@ -421,6 +476,74 @@ export class AsciiController {
|
||||
}
|
||||
}
|
||||
|
||||
// Touch Support
|
||||
handleTouchStart(e: TouchEvent): void {
|
||||
if (e.touches.length === 2) {
|
||||
e.preventDefault();
|
||||
this.lastTouchDist = this.getTouchDistance(e.touches);
|
||||
this.isDragging = false;
|
||||
} else if (e.touches.length === 1 && this.zoomState.zoom > 1.0) {
|
||||
e.preventDefault();
|
||||
this.isDragging = true;
|
||||
this.lastTouchPos = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||
}
|
||||
}
|
||||
|
||||
handleTouchMove(e: TouchEvent): void {
|
||||
if (e.touches.length === 2) {
|
||||
e.preventDefault();
|
||||
const dist = this.getTouchDistance(e.touches);
|
||||
const factor = dist / this.lastTouchDist;
|
||||
this.lastTouchDist = dist;
|
||||
|
||||
const oldZoom = this.zoomState.zoom;
|
||||
this.zoomState.zoom = Math.min(Math.max(this.zoomState.zoom * factor, 1.0), 10.0);
|
||||
|
||||
// Center zoom between touches
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const cx = ((e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left) / rect.width;
|
||||
const cy = ((e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top) / rect.height;
|
||||
|
||||
if (oldZoom !== this.zoomState.zoom) {
|
||||
const imgX = (cx - this.zoomState.zoomCenter.x) / oldZoom + this.zoomState.zoomCenter.x;
|
||||
const imgY = (cy - this.zoomState.zoomCenter.y) / oldZoom + this.zoomState.zoomCenter.y;
|
||||
this.zoomState.zoomCenter.x = (imgX - cx / this.zoomState.zoom) / (1 - 1 / this.zoomState.zoom);
|
||||
this.zoomState.zoomCenter.y = (imgY - cy / this.zoomState.zoom) / (1 - 1 / this.zoomState.zoom);
|
||||
}
|
||||
this.requestRender('uniforms');
|
||||
|
||||
} else if (e.touches.length === 1 && this.isDragging && this.zoomState.zoom > 1.0) {
|
||||
e.preventDefault();
|
||||
const curX = e.touches[0].clientX;
|
||||
const curY = e.touches[0].clientY;
|
||||
|
||||
const dx = (curX - this.lastTouchPos.x) / this.canvas.width;
|
||||
const dy = (curY - this.lastTouchPos.y) / this.canvas.height;
|
||||
|
||||
// Logarithmic pan speed based on zoom?
|
||||
// Simple mapping: move zoomCenter opposite to drag
|
||||
this.zoomState.zoomCenter.x -= dx / this.zoomState.zoom;
|
||||
this.zoomState.zoomCenter.y -= dy / this.zoomState.zoom;
|
||||
|
||||
this.lastTouchPos = { x: curX, y: curY };
|
||||
this.requestRender('uniforms');
|
||||
}
|
||||
}
|
||||
|
||||
handleTouchEnd(e: TouchEvent): void {
|
||||
this.isDragging = false;
|
||||
if (e.touches.length === 0 && this.zoomState.zoom <= 1.0) {
|
||||
this.zoomState.zoomCenter = { x: 0.5, y: 0.5 };
|
||||
this.requestRender('uniforms');
|
||||
}
|
||||
}
|
||||
|
||||
private getTouchDistance(touches: TouchList): number {
|
||||
const dx = touches[0].clientX - touches[1].clientX;
|
||||
const dy = touches[0].clientY - touches[1].clientY;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
// ============= Export =============
|
||||
|
||||
savePNG(): void {
|
||||
@@ -499,6 +622,9 @@ export class AsciiController {
|
||||
if (this.animFrameId !== null) {
|
||||
cancelAnimationFrame(this.animFrameId);
|
||||
}
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
}
|
||||
this.renderer?.dispose();
|
||||
this.renderer = null;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,13 @@ export interface AsciiOptions {
|
||||
denoise?: boolean;
|
||||
fontAspectRatio?: number;
|
||||
onProgress?: (progress: number) => void;
|
||||
sharpen?: number;
|
||||
edgeThreshold?: number;
|
||||
shadows?: number;
|
||||
highlights?: number;
|
||||
scanlines?: number;
|
||||
vignette?: number;
|
||||
monoColor?: string;
|
||||
}
|
||||
|
||||
export interface AsciiResult {
|
||||
@@ -55,6 +62,13 @@ export interface AsciiSettings {
|
||||
overlayStrength: number;
|
||||
resolution: number;
|
||||
charSet: CharSetKey;
|
||||
sharpen: number;
|
||||
edgeThreshold: number;
|
||||
shadows: number;
|
||||
highlights: number;
|
||||
scanlines: number;
|
||||
vignette: number;
|
||||
monoColor: string;
|
||||
}
|
||||
|
||||
// ============= Constants =============
|
||||
|
||||
@@ -62,6 +62,7 @@ export class UIBindings {
|
||||
init(): void {
|
||||
this.setupSliders();
|
||||
this.setupToggles();
|
||||
this.setupColorInput();
|
||||
this.setupSegments();
|
||||
this.setupButtons();
|
||||
this.setupKeyboard();
|
||||
@@ -81,7 +82,7 @@ export class UIBindings {
|
||||
}
|
||||
|
||||
// Cleanup Sliders
|
||||
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither'] as const;
|
||||
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither', 'sharpen', 'edgeThreshold', 'shadows', 'highlights'] as const;
|
||||
sliderIds.forEach(id => {
|
||||
const input = document.getElementById(id) as HTMLInputElement | null;
|
||||
const handler = this.sliderHandlers.get(id);
|
||||
@@ -162,7 +163,7 @@ export class UIBindings {
|
||||
// ============= Sliders =============
|
||||
|
||||
private setupSliders(): void {
|
||||
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither'] as const;
|
||||
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither', 'sharpen', 'edgeThreshold', 'shadows', 'highlights', 'scanlines', 'vignette'] as const;
|
||||
|
||||
sliderIds.forEach(id => {
|
||||
const input = document.getElementById(id) as HTMLInputElement | null;
|
||||
@@ -225,6 +226,23 @@ export class UIBindings {
|
||||
document.body.addEventListener('toggle-change', this.toggleHandler);
|
||||
}
|
||||
|
||||
// ============= Color Input =============
|
||||
|
||||
private setupColorInput(): void {
|
||||
const colorInput = document.getElementById('input-mono-color') as HTMLInputElement;
|
||||
const colorSwatch = document.getElementById('color-swatch-display');
|
||||
|
||||
if (colorInput) {
|
||||
colorInput.addEventListener('input', () => {
|
||||
if (this.isUpdatingUI) return;
|
||||
this.controller.setSetting('monoColor', colorInput.value);
|
||||
if (colorSwatch) {
|
||||
colorSwatch.style.backgroundColor = colorInput.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============= Segments =============
|
||||
|
||||
private setupSegments(): void {
|
||||
@@ -475,7 +493,7 @@ export class UIBindings {
|
||||
const settings = this.controller.getSettings();
|
||||
|
||||
// Update sliders
|
||||
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither'] as const;
|
||||
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither', 'sharpen', 'edgeThreshold', 'shadows', 'highlights', 'scanlines', 'vignette'] as const;
|
||||
sliderIds.forEach(id => {
|
||||
const input = document.getElementById(id) as HTMLInputElement | null;
|
||||
if (input && settings[id] !== undefined) {
|
||||
@@ -509,6 +527,19 @@ export class UIBindings {
|
||||
}
|
||||
window.updateSegmentValue?.('segment-edge', edgeShort);
|
||||
|
||||
// Update color input
|
||||
const colorInput = document.getElementById('input-mono-color') as HTMLInputElement;
|
||||
const colorSwatch = document.getElementById('color-swatch-display');
|
||||
|
||||
if (colorInput && settings.monoColor) {
|
||||
if (colorInput.value !== settings.monoColor) {
|
||||
colorInput.value = settings.monoColor;
|
||||
}
|
||||
if (colorSwatch) {
|
||||
colorSwatch.style.backgroundColor = settings.monoColor;
|
||||
}
|
||||
}
|
||||
|
||||
this.updateQueueDisplay();
|
||||
|
||||
this.isUpdatingUI = false;
|
||||
|
||||
@@ -15,6 +15,13 @@ export interface RenderOptions {
|
||||
edgeMode?: number; // 0=none, 1=simple, 2=sobel, 3=canny
|
||||
dither?: number;
|
||||
denoise?: boolean;
|
||||
sharpen?: number;
|
||||
edgeThreshold?: number;
|
||||
shadows?: number;
|
||||
highlights?: number;
|
||||
scanlines?: number;
|
||||
vignette?: number;
|
||||
monoColor?: string;
|
||||
zoom?: number;
|
||||
zoomCenter?: { x: number; y: number };
|
||||
mousePos?: { x: number; y: number };
|
||||
@@ -52,6 +59,10 @@ export class WebGLAsciiRenderer {
|
||||
}
|
||||
this.gl = gl;
|
||||
|
||||
// Enable required extensions for advanced rendering
|
||||
gl.getExtension('OES_standard_derivatives');
|
||||
gl.getExtension('EXT_shader_texture_lod');
|
||||
|
||||
this.program = null;
|
||||
this.textures = {};
|
||||
this.buffers = {};
|
||||
@@ -80,6 +91,8 @@ export class WebGLAsciiRenderer {
|
||||
|
||||
// Fragment Shader
|
||||
const fsSource = `
|
||||
#extension GL_OES_standard_derivatives : enable
|
||||
#extension GL_EXT_shader_texture_lod : enable
|
||||
precision mediump float;
|
||||
varying vec2 v_texCoord;
|
||||
|
||||
@@ -102,6 +115,13 @@ export class WebGLAsciiRenderer {
|
||||
uniform int u_edgeMode; // 0=none, 1=simple, 2=sobel, 3=canny
|
||||
uniform float u_dither; // Dither strength 0.0 - 1.0
|
||||
uniform bool u_denoise;
|
||||
uniform float u_sharpen;
|
||||
uniform float u_edgeThreshold;
|
||||
uniform float u_shadows;
|
||||
uniform float u_highlights;
|
||||
uniform float u_scanlines;
|
||||
uniform float u_vignette;
|
||||
uniform vec3 u_monoColor;
|
||||
|
||||
// Zoom & Magnifier
|
||||
uniform float u_zoom;
|
||||
@@ -125,11 +145,26 @@ export class WebGLAsciiRenderer {
|
||||
// Exposure
|
||||
color *= u_exposure;
|
||||
|
||||
// Shadows / Highlights
|
||||
float luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
|
||||
|
||||
// Shadows: lift dark areas
|
||||
if (u_shadows > 0.0) {
|
||||
float shadowFactor = (1.0 - luma) * u_shadows;
|
||||
color = color + (vec3(1.0) - color) * shadowFactor * 0.5;
|
||||
}
|
||||
|
||||
// Highlights: dim bright areas
|
||||
if (u_highlights > 0.0) {
|
||||
float highlightFactor = luma * u_highlights;
|
||||
color = color * (1.0 - highlightFactor * 0.5);
|
||||
}
|
||||
|
||||
// Contrast
|
||||
color = (color - 0.5) * u_contrast + 0.5;
|
||||
|
||||
// Saturation
|
||||
float luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
|
||||
luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
|
||||
color = mix(vec3(luma), color, u_saturation);
|
||||
|
||||
// Gamma
|
||||
@@ -232,6 +267,12 @@ export class WebGLAsciiRenderer {
|
||||
color = getAverageColor(sampleUV, cellSize);
|
||||
}
|
||||
|
||||
// Sharpening
|
||||
if (u_sharpen > 0.0) {
|
||||
vec3 blurred = getAverageColor(sampleUV, cellSize * 2.0);
|
||||
color = color + (color - blurred) * u_sharpen;
|
||||
}
|
||||
|
||||
// Edge Detection Logic
|
||||
if (u_edgeMode == 1) {
|
||||
// Simple Laplacian-like
|
||||
@@ -244,14 +285,20 @@ export class WebGLAsciiRenderer {
|
||||
|
||||
vec3 edges = abs(center - top) + abs(center - bottom) + abs(center - left) + abs(center - right);
|
||||
float edgeLum = dot(edges, vec3(0.2126, 0.7152, 0.0722));
|
||||
|
||||
// Use Threshold
|
||||
if (edgeLum > u_edgeThreshold * 0.1) {
|
||||
color = mix(color, color * (1.0 - edgeLum * 2.0), 0.5);
|
||||
}
|
||||
|
||||
} else if (u_edgeMode == 2) {
|
||||
// Sobel Gradient
|
||||
vec2 sobel = sobelFilter(sampleUV, cellSize);
|
||||
float edgeStr = clamp(sobel.x * 2.0, 0.0, 1.0);
|
||||
// Darken edges
|
||||
|
||||
if (edgeStr > u_edgeThreshold * 0.2) {
|
||||
color = mix(color, vec3(0.0), edgeStr * 0.8);
|
||||
}
|
||||
|
||||
} else if (u_edgeMode == 3) {
|
||||
// "Canny-like" (Sobel + gradient suppression)
|
||||
@@ -260,13 +307,12 @@ export class WebGLAsciiRenderer {
|
||||
float angle = sobel.y;
|
||||
|
||||
// Non-maximum suppression (simplified)
|
||||
// Check neighbors in gradient direction
|
||||
vec2 dir = vec2(cos(angle), sin(angle)) * cellSize;
|
||||
|
||||
vec2 s1 = sobelFilter(sampleUV + dir, cellSize);
|
||||
vec2 s2 = sobelFilter(sampleUV - dir, cellSize);
|
||||
|
||||
if (mag < s1.x || mag < s2.x || mag < 0.15) {
|
||||
// Use edge threshold
|
||||
if (mag < s1.x || mag < s2.x || mag < u_edgeThreshold * 0.3) { // scaled threshold
|
||||
mag = 0.0;
|
||||
} else {
|
||||
mag = 1.0; // Strong edge
|
||||
@@ -276,7 +322,7 @@ export class WebGLAsciiRenderer {
|
||||
color = mix(color, vec3(0.0), mag);
|
||||
}
|
||||
|
||||
// Apply adjustments
|
||||
// Apply adjustments (Contrast, etc.)
|
||||
color = adjust(color);
|
||||
|
||||
// Overlay blend-like effect (boost mid-contrast)
|
||||
@@ -319,18 +365,48 @@ export class WebGLAsciiRenderer {
|
||||
uvInCell.y * u_charSizeUV.y
|
||||
);
|
||||
|
||||
float charAlpha = texture2D(u_atlas, atlasUV).r;
|
||||
// --- MIPMAP FIX ---
|
||||
// Use zoomed 'uv' for derivatives to handle zoom correctly.
|
||||
// Multiply by 0.5 to bias towards sharper mipmaps (LOD bias).
|
||||
vec2 gradX = dFdx(uv) * u_gridSize * u_charSizeUV * 0.5;
|
||||
vec2 gradY = dFdy(uv) * u_gridSize * u_charSizeUV * 0.5;
|
||||
|
||||
// Use texture2DGradEXT to sample with explicit gradients, resolving cell boundary artifacts
|
||||
float charAlpha = texture2DGradEXT(u_atlas, atlasUV, gradX, gradY).r;
|
||||
|
||||
// Loup border effect
|
||||
if (u_showMagnifier) {
|
||||
float edgeWidth = 0.005;
|
||||
if (dist > u_magnifierRadius - edgeWidth && dist < u_magnifierRadius) {
|
||||
charAlpha = 1.0;
|
||||
color = vec3(1.0, 0.4039, 0.0); // Safety Orange border for the loupe
|
||||
color = vec3(1.0, 1.0, 1.0); // White border for the loupe
|
||||
}
|
||||
}
|
||||
|
||||
vec3 finalColor = u_color ? color : vec3(1.0, 0.4039, 0.0);
|
||||
// Vignette
|
||||
if (u_vignette > 0.0) {
|
||||
float dist = distance(uv, vec2(0.5));
|
||||
// Smoothsoft vignette
|
||||
float vig = smoothstep(0.8 + (1.0 - u_vignette) * 0.5, 0.2, dist);
|
||||
charAlpha *= vig;
|
||||
}
|
||||
|
||||
// Scanlines
|
||||
if (u_scanlines > 0.0) {
|
||||
// Sine wave based on grid rows
|
||||
float scan = 0.5 + 0.5 * sin(uv.y * u_gridSize.y * 3.14159 * 2.0);
|
||||
// Blend scanline effect based on strength
|
||||
float scanEffect = mix(1.0, scan, u_scanlines * 0.5);
|
||||
charAlpha *= scanEffect;
|
||||
}
|
||||
|
||||
// Mono Color
|
||||
vec3 finalColor;
|
||||
if (u_color) {
|
||||
finalColor = color;
|
||||
} else {
|
||||
finalColor = u_monoColor;
|
||||
}
|
||||
|
||||
gl_FragColor = vec4(finalColor * charAlpha, charAlpha);
|
||||
}
|
||||
@@ -366,6 +442,8 @@ export class WebGLAsciiRenderer {
|
||||
'u_exposure', 'u_contrast', 'u_saturation', 'u_gamma',
|
||||
'u_invert', 'u_color', 'u_overlayStrength', 'u_edgeMode',
|
||||
'u_dither', 'u_denoise',
|
||||
'u_sharpen', 'u_edgeThreshold', 'u_shadows', 'u_highlights',
|
||||
'u_scanlines', 'u_vignette', 'u_monoColor',
|
||||
'u_zoom', 'u_zoomCenter', 'u_mousePos',
|
||||
'u_magnifierRadius', 'u_magnifierZoom', 'u_showMagnifier', 'u_aspect'
|
||||
];
|
||||
@@ -518,6 +596,19 @@ export class WebGLAsciiRenderer {
|
||||
gl.uniform1i(u['u_edgeMode'], options.edgeMode || 0);
|
||||
gl.uniform1f(u['u_dither'], options.dither || 0.0);
|
||||
gl.uniform1i(u['u_denoise'], options.denoise ? 1 : 0);
|
||||
gl.uniform1f(u['u_sharpen'], options.sharpen || 0.0);
|
||||
gl.uniform1f(u['u_edgeThreshold'], options.edgeThreshold || 0.5); // Default to mid
|
||||
gl.uniform1f(u['u_shadows'], options.shadows || 0.0);
|
||||
gl.uniform1f(u['u_highlights'], options.highlights || 0.0);
|
||||
gl.uniform1f(u['u_scanlines'], options.scanlines || 0.0);
|
||||
gl.uniform1f(u['u_vignette'], options.vignette || 0.0);
|
||||
|
||||
// Parse hex color
|
||||
const hex = options.monoColor || '#ffffff';
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255.0;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255.0;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255.0;
|
||||
gl.uniform3f(u['u_monoColor'], r, g, b);
|
||||
|
||||
// Zoom & Magnifier
|
||||
gl.uniform1f(u['u_zoom'], options.zoom || 1.0);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
:root {
|
||||
--bg-color: #000000;
|
||||
--text-color: #FF6700;
|
||||
--text-color: #FFFFFF;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user