Compare commits

...

15 Commits

Author SHA1 Message Date
syntaxbullet
9d014e4f1b feat: Implement edge panning and middle mouse button panning for zoomed views. 2026-02-11 22:43:30 +01:00
syntaxbullet
55ec01e3cd feat: Attach and detach zoom event listeners to both the workspace and the ASCII canvas 2026-02-11 18:20:40 +01:00
syntaxbullet
d5bac98b76 feat: Implement detailed mobile control panel with categorized slider controls for image adjustments. 2026-02-11 17:56:20 +01:00
syntaxbullet
8cfa39a4d4 Refactor: move ASCII generator to a dedicated page and embed navigation directly into the homepage. 2026-02-11 17:29:25 +01:00
syntaxbullet
a79f05c043 feat: Implement desktop-only mode for the ASCII art generator and sidebar, update breakpoints to 1600px, and simplify the blog page layout with a new back link. 2026-02-11 14:41:47 +01:00
syntaxbullet
f4a0e2a82b feat: implement a responsive mobile control panel with tabbed navigation and dynamic control staging, and refactor the desktop layout for mobile responsiveness. 2026-02-10 21:49:44 +01:00
syntaxbullet
cabf963e94 feat: Add background color setting and apply it to HTML and WebGL output. 2026-02-10 21:08:38 +01:00
syntaxbullet
2cdc9bd0b6 feat: Update ControlPanel inner content to use overflow-y: auto and max-height: 70dvh for improved scrolling and responsive sizing, and hide overflow when collapsed. 2026-02-10 20:22:08 +01:00
syntaxbullet
5cd52f2785 feat: Enhance UI components with full labels and abbreviations, and add port mapping to dev docker-compose. 2026-02-10 20:07:00 +01:00
syntaxbullet
a9d2c43bfd feat: adjust max and step values for sharpen, edgeThreshold, and scanlines controls. 2026-02-10 15:21:34 +01:00
syntaxbullet
73a6681ceb feat: adjust max and step values for sharpen, edgeThreshold, and scanlines controls. 2026-02-10 13:15:15 +01:00
syntaxbullet
faa9609254 refactor: Implement mobile-friendly control panel toggle and remove keyboard shortcuts hint. 2026-02-10 13:02:41 +01:00
syntaxbullet
bb4ca0610d feat: Introduce new Sidebar and ControlPanel components, and update styling for TUI Segment, Slider, Toggle, and Button components. 2026-02-10 11:23:16 +01:00
syntaxbullet
28bde53707 feat: Redesign homepage to a split layout with a brand sidebar, removing the interactive ASCII art controls. 2026-02-10 10:16:52 +01:00
syntaxbullet
36cb793048 feat: Implement image import functionality, restructure control panel layout, and apply glassmorphism styling to controls. 2026-02-09 23:16:26 +01:00
29 changed files with 4077 additions and 1111 deletions

15
Caddyfile Normal file
View File

@@ -0,0 +1,15 @@
{
email me@syntaxbullet.com
}
yourdomain.com {
reverse_proxy web:4321
# Enable compression
encode zstd gzip
# Robust logging
log {
output file /var/log/caddy/access.log
}
}

View File

@@ -1,10 +1,17 @@
# Build stage
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Runtime stage
FROM node:22-alpine AS runtime FROM node:22-alpine AS runtime
WORKDIR /app WORKDIR /app
COPY --from=build /app/dist ./dist
COPY . . COPY --from=build /app/package*.json ./
COPY --from=build /app/node_modules ./node_modules
RUN npm install
RUN npm run build
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
ENV PORT=4321 ENV PORT=4321

View File

@@ -1,9 +1,19 @@
services: services:
web: web:
build: . build: .
ports:
- "4321:4321"
volumes: volumes:
- .:/app - .:/app
- /app/node_modules - /app/node_modules
command: npm run dev -- --host command: npm run dev -- --host
environment: environment:
- NODE_ENV=development - NODE_ENV=development
caddy:
image: hello-world
entrypoint: ["true"]
restart: "no"
ports: []
volumes: []
depends_on: []

View File

@@ -1,8 +1,27 @@
services: services:
web: web:
build: . build: .
ports: restart: always
- "4321:4321"
environment: environment:
- PORT=4321 - PORT=4321
- HOST=0.0.0.0 - HOST=0.0.0.0
caddy:
image: caddy:latest
restart: always
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
- caddy_logs:/var/log/caddy
depends_on:
- web
volumes:
caddy_data:
caddy_config:
caddy_logs:

10
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@astrojs/check": "^0.9.6", "@astrojs/check": "^0.9.6",
"@astrojs/node": "^9.5.2", "@astrojs/node": "^9.5.2",
"@lucide/astro": "^0.563.0",
"astro": "^5.17.1", "astro": "^5.17.1",
"gifuct-js": "^2.1.2", "gifuct-js": "^2.1.2",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
@@ -1181,6 +1182,15 @@
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@lucide/astro": {
"version": "0.563.0",
"resolved": "https://registry.npmjs.org/@lucide/astro/-/astro-0.563.0.tgz",
"integrity": "sha512-X9fNJvRR6pLJfkIEAFQkizWaNVvcduunJoFyR3fwPu30Y6jOu5S9k4k7HTSk3ZrEfqK2eFEqrBqqWH4fwSNKCg==",
"license": "ISC",
"peerDependencies": {
"astro": "^4 || ^5"
}
},
"node_modules/@oslojs/encoding": { "node_modules/@oslojs/encoding": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",

View File

@@ -17,6 +17,7 @@
"dependencies": { "dependencies": {
"@astrojs/check": "^0.9.6", "@astrojs/check": "^0.9.6",
"@astrojs/node": "^9.5.2", "@astrojs/node": "^9.5.2",
"@lucide/astro": "^0.563.0",
"astro": "^5.17.1", "astro": "^5.17.1",
"gifuct-js": "^2.1.2", "gifuct-js": "^2.1.2",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",

4
public/favicon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" fill="black"/>
<text x="50" y="70" font-family="monospace" font-size="70" fill="white" text-anchor="middle" font-weight="900">S</text>
</svg>

After

Width:  |  Height:  |  Size: 240 B

4
public/robots.txt Normal file
View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://syntaxbullet.com/sitemap-index.xml

File diff suppressed because it is too large Load Diff

View File

@@ -55,8 +55,11 @@ const { pathname } = Astro.url;
left: 0; left: 0;
width: 100%; width: 100%;
height: 24px; height: 24px;
background: #000; background: rgba(10, 10, 10, 0.8);
border-bottom: 1px solid var(--text-color); -webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -81,30 +84,32 @@ const { pathname } = Astro.url;
align-items: center; align-items: center;
color: var(--text-color); color: var(--text-color);
text-decoration: none; text-decoration: none;
border-right: 1px solid rgba(255, 103, 0, 0.2); border-right: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.1s; transition: all 0.2s ease;
} }
.nav-link:hover { .nav-link:hover {
background: var(--text-color); background: rgba(255, 255, 255, 0.1);
color: #000; color: #fff;
text-decoration: none; text-decoration: none;
} }
.nav-link:hover .nav-index { .nav-link:hover .nav-index {
color: #000; color: #fff;
opacity: 1; opacity: 1;
} }
.status-item.active { .status-item.active {
background: var(--text-color); background: rgba(255, 255, 255, 0.15);
color: #000; color: #fff;
font-weight: bold; font-weight: bold;
box-shadow: inset 0 -2px 0 var(--text-color);
} }
.status-item.brand { .status-item.brand {
background: rgba(255, 103, 0, 0.1); background: rgba(255, 255, 255, 0.05);
font-weight: 900; font-weight: 900;
letter-spacing: 1px;
} }
.nav-index { .nav-index {
@@ -122,7 +127,7 @@ const { pathname } = Astro.url;
.status-right .status-item { .status-right .status-item {
border-right: none; border-right: none;
border-left: 1px solid rgba(255, 103, 0, 0.2); border-left: 1px solid rgba(255, 255, 255, 0.1);
} }
.prefix { .prefix {
@@ -134,6 +139,7 @@ const { pathname } = Astro.url;
#system-status { #system-status {
color: #0f0; color: #0f0;
font-weight: bold; font-weight: bold;
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
} }
</style> </style>

View File

@@ -89,6 +89,9 @@
}; };
const showTooltip = (target: Element, e: MouseEvent) => { const showTooltip = (target: Element, e: MouseEvent) => {
// Only show on devices with hover capability (mouse)
if (!window.matchMedia("(hover: hover)").matches) return;
const title = target.getAttribute("data-tooltip-title"); const title = target.getAttribute("data-tooltip-title");
const desc = target.getAttribute("data-tooltip-desc"); const desc = target.getAttribute("data-tooltip-desc");

View File

@@ -2,6 +2,7 @@
interface Props { interface Props {
id: string; id: string;
label: string; label: string;
abbr?: string;
shortcut?: string; shortcut?: string;
variant?: "default" | "primary" | "subtle"; variant?: "default" | "primary" | "subtle";
title?: string; title?: string;
@@ -11,6 +12,7 @@ interface Props {
const { const {
id, id,
label, label,
abbr,
shortcut, shortcut,
variant = "default", variant = "default",
title = "", title = "",
@@ -26,74 +28,103 @@ const {
data-tooltip-desc={description} data-tooltip-desc={description}
> >
{shortcut && <span class="tui-button-shortcut">{shortcut}</span>} {shortcut && <span class="tui-button-shortcut">{shortcut}</span>}
<span class="tui-button-label">{label}</span> <span class:list={["tui-button-label", { "has-abbr": !!abbr }]}>
<span class="full">{label}</span>
{abbr && <span class="abbr">{abbr}</span>}
</span>
</button> </button>
<style> <style>
.tui-button { .tui-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 6px;
background: none; background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 103, 0, 0.4); border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text-color); color: #fff;
opacity: 0.8;
font-family: inherit; font-family: inherit;
font-size: 11px; font-size: 11px;
padding: 3px 10px; padding: 4px 10px;
cursor: pointer; cursor: pointer;
opacity: 0.8; transition: all 0.2s;
transition: all 0.15s;
user-select: none; user-select: none;
border-radius: 2px;
} }
.tui-button:hover { .tui-button:hover {
color: var(--accent-color);
opacity: 1; opacity: 1;
border-color: var(--text-color); border-color: var(--accent-color);
background: rgba(255, 103, 0, 0.1); background: color-mix(in srgb, var(--accent-color), transparent 95%);
transform: translateY(-1px);
} }
.tui-button:active { .tui-button:active {
background: rgba(255, 103, 0, 0.2); background: rgba(255, 255, 255, 0.12);
transform: translateY(0);
} }
.tui-button--primary { .tui-button--primary {
border-color: var(--text-color); background: var(--accent-color);
background: rgba(255, 103, 0, 0.1); border-color: var(--accent-color);
color: #fff;
opacity: 1;
font-weight: 700;
box-shadow: 0 0 15px
color-mix(in srgb, var(--accent-color), transparent 80%);
} }
.tui-button--primary:hover { .tui-button--primary:hover {
background: var(--text-color); background: color-mix(in srgb, var(--accent-color), black 10%);
color: #000; border-color: color-mix(in srgb, var(--accent-color), black 10%);
color: #fff;
box-shadow: 0 0 20px
color-mix(in srgb, var(--accent-color), transparent 60%);
} }
.tui-button--subtle { .tui-button--subtle {
border-color: transparent; border-color: transparent;
background: transparent;
opacity: 0.6; opacity: 0.6;
} }
.tui-button--subtle:hover { .tui-button--subtle:hover {
border-color: rgba(255, 103, 0, 0.3); border-color: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.05);
opacity: 1; opacity: 1;
} }
.tui-button-shortcut { .tui-button-shortcut {
font-size: 9px; font-size: 9px;
opacity: 0.6; opacity: 0.5;
padding: 0 3px; padding: 1px 4px;
border: 1px solid currentColor; border: 1px solid rgba(255, 255, 255, 0.2);
line-height: 1.2; line-height: 1;
border-radius: 2px; border-radius: 3px;
} }
.tui-button:hover .tui-button-shortcut { .tui-button:hover .tui-button-shortcut {
opacity: 1; opacity: 0.8;
} border-color: rgba(255, 255, 255, 0.4);
.tui-button--primary:hover .tui-button-shortcut {
border-color: #000;
} }
.tui-button-label { .tui-button-label {
font-weight: bold; font-weight: 500;
letter-spacing: 0.5px;
display: flex;
}
.tui-button-label .abbr {
display: none;
}
@media (max-width: 1400px) {
.tui-button-label.has-abbr .full {
display: none;
}
.tui-button-label.has-abbr .abbr {
display: inline;
}
} }
</style> </style>

View File

@@ -2,6 +2,7 @@
interface Props { interface Props {
id: string; id: string;
label: string; label: string;
abbr?: string;
options: string[]; options: string[];
value?: string; value?: string;
title?: string; title?: string;
@@ -11,6 +12,7 @@ interface Props {
const { const {
id, id,
label, label,
abbr,
options, options,
value = options[0], value = options[0],
title = "", title = "",
@@ -24,7 +26,10 @@ const {
data-tooltip-title={title} data-tooltip-title={title}
data-tooltip-desc={description} data-tooltip-desc={description}
> >
<span class="tui-segment-label">{label}</span> <span class:list={["tui-segment-label", { "has-abbr": !!abbr }]}>
<span class="full">{label}</span>
{abbr && <span class="abbr">{abbr}</span>}
</span>
<div class="tui-segment-options" id={id} data-value={value}> <div class="tui-segment-options" id={id} data-value={value}>
{ {
options.map((opt) => ( options.map((opt) => (
@@ -47,33 +52,53 @@ const {
.tui-segment { .tui-segment {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 12px;
font-size: 11px; font-size: 11px;
user-select: none; user-select: none;
} }
.tui-segment-label { .tui-segment-label {
min-width: 3ch; min-width: 3ch;
font-weight: bold; font-weight: 700;
font-family: var(--font-mono);
color: #fff;
opacity: 0.7; opacity: 0.7;
display: flex;
transition: all 0.2s;
}
.tui-segment-label .abbr {
display: none;
}
@media (max-width: 1400px) {
.tui-segment-label.has-abbr .full {
display: none;
}
.tui-segment-label.has-abbr .abbr {
display: inline;
}
} }
.tui-segment-options { .tui-segment-options {
display: flex; display: flex;
border: 1px solid rgba(255, 103, 0, 0.3); border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.02);
border-radius: 2px;
overflow: hidden;
} }
.tui-segment-option { .tui-segment-option {
background: none; background: transparent;
border: none; border: none;
border-right: 1px solid rgba(255, 103, 0, 0.2); border-right: 1px solid rgba(255, 255, 255, 0.05);
color: var(--text-color); color: #fff;
opacity: 0.6;
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;
padding: 2px 8px; padding: 4px 10px;
cursor: pointer; cursor: pointer;
opacity: 0.5; transition: all 0.2s;
transition: all 0.15s;
min-width: 3ch; min-width: 3ch;
text-align: center; text-align: center;
} }
@@ -83,24 +108,26 @@ const {
} }
.tui-segment-option:hover { .tui-segment-option:hover {
opacity: 0.8; color: var(--accent-color);
background: rgba(255, 103, 0, 0.1); opacity: 1;
background: color-mix(in srgb, var(--accent-color), transparent 95%);
} }
.tui-segment-option.active { .tui-segment-option.active {
background: var(--text-color); background: var(--accent-color);
color: #000; color: #fff;
font-weight: 700;
opacity: 1; opacity: 1;
font-weight: bold;
} }
/* Hover the whole group */ /* Hover the whole group */
.tui-segment:hover .tui-segment-label { .tui-segment:hover .tui-segment-label {
opacity: 1; opacity: 1;
color: var(--accent-color);
} }
.tui-segment:hover .tui-segment-options { .tui-segment:hover .tui-segment-options {
border-color: var(--text-color); border-color: var(--accent-color);
} }
</style> </style>

View File

@@ -2,6 +2,7 @@
interface Props { interface Props {
id: string; id: string;
label: string; label: string;
abbr?: string;
min?: number; min?: number;
max?: number; max?: number;
step?: number; step?: number;
@@ -13,6 +14,7 @@ interface Props {
const { const {
id, id,
label, label,
abbr,
min = 0, min = 0,
max = 5, max = 5,
step = 0.1, step = 0.1,
@@ -28,10 +30,18 @@ const segments = 12;
<div <div
class="tui-slider" class="tui-slider"
data-slider-id={id} data-slider-id={id}
data-default-value={value}
data-tooltip-title={title} data-tooltip-title={title}
data-tooltip-desc={description} data-tooltip-desc={description}
> >
<span class="tui-slider-label">{label}</span> <div class="tui-slider-header">
<span class:list={["tui-slider-label", { "has-abbr": !!abbr }]}>
<span class="full">{label}</span>
{abbr && <span class="abbr">{abbr}</span>}
</span>
<span class="tui-slider-value" id={`val-${id}`}>{value.toFixed(2)}</span
>
</div>
<div class="tui-slider-track-wrapper"> <div class="tui-slider-track-wrapper">
<div class="tui-slider-visual"> <div class="tui-slider-visual">
<span class="tui-slider-track" data-for={id}> <span class="tui-slider-track" data-for={id}>
@@ -56,22 +66,50 @@ const segments = 12;
value={value} value={value}
/> />
</div> </div>
<span class="tui-slider-value" id={`val-${id}`}>{value.toFixed(2)}</span>
</div> </div>
<style> <style>
.tui-slider { .tui-slider {
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 6px; gap: 4px;
font-size: 11px; font-size: 11px;
user-select: none; user-select: none;
color: rgba(255, 255, 255, 0.6);
}
.tui-slider-header {
display: flex;
justify-content: space-between;
align-items: baseline;
} }
.tui-slider-label { .tui-slider-label {
min-width: 3ch; font-weight: 700;
font-weight: bold;
opacity: 0.7; opacity: 0.7;
font-family: var(--font-mono);
color: #fff;
transition: all 0.2s;
display: flex;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.tui-slider-label .abbr {
display: none;
}
@media (max-width: 1400px) {
.tui-slider-label.has-abbr .full {
display: none;
}
.tui-slider-label.has-abbr .abbr {
display: inline;
}
} }
.tui-slider-track-wrapper { .tui-slider-track-wrapper {
@@ -89,22 +127,54 @@ const segments = 12;
.tui-slider-track { .tui-slider-track {
display: flex; display: flex;
letter-spacing: -1px; letter-spacing: 2px;
font-family: monospace; font-family: var(--font-mono);
font-size: 10px;
} }
.tui-slider-segment { .tui-slider-segment {
transition: color 0.1s; transition: color 0.1s;
color: rgba(255, 103, 0, 0.25); color: rgba(255, 255, 255, 0.1);
} }
.tui-slider-segment.filled { .tui-slider-segment.filled {
color: var(--text-color); color: rgba(255, 255, 255, 0.6);
} }
.tui-slider-segment.thumb { .tui-slider-segment.thumb {
color: #fff; color: #fff;
text-shadow: 0 0 4px var(--text-color); text-shadow: 0 0 8px rgba(255, 255, 255, 0.5);
scale: 1.2;
}
.tui-slider:hover .tui-slider-segment.filled {
color: var(--accent-color);
opacity: 0.8;
}
.tui-slider:hover .tui-slider-segment.thumb {
color: var(--accent-color);
text-shadow: 0 0 8px var(--accent-color);
}
/* Modified state (moved from default) */
.tui-slider.modified .tui-slider-segment.filled {
color: var(--accent-color);
opacity: 0.8;
}
.tui-slider.modified .tui-slider-segment.thumb {
color: var(--accent-color);
text-shadow: 0 0 8px var(--accent-color);
}
.tui-slider.modified .tui-slider-label {
color: var(--accent-color);
opacity: 0.6;
}
.tui-slider.modified .tui-slider-value {
color: var(--accent-color);
} }
.tui-slider-input { .tui-slider-input {
@@ -120,27 +190,27 @@ const segments = 12;
} }
.tui-slider-value { .tui-slider-value {
min-width: 3ch; font-weight: 400;
text-align: right; opacity: 0.8;
font-weight: bold; font-family: var(--font-mono);
opacity: 0.9; color: #fff;
font-size: 10px;
transition: all 0.2s;
} }
/* Hover effect */ /* Hover effect */
.tui-slider:hover .tui-slider-label { .tui-slider:hover .tui-slider-label {
opacity: 1; opacity: 1;
color: var(--accent-color);
}
.tui-slider:hover .tui-slider-value {
opacity: 1;
color: var(--accent-color);
} }
.tui-slider:hover .tui-slider-segment { .tui-slider:hover .tui-slider-segment {
color: rgba(255, 103, 0, 0.4); color: rgba(255, 255, 255, 0.3);
}
.tui-slider:hover .tui-slider-segment.filled {
color: var(--text-color);
}
.tui-slider:hover .tui-slider-segment.thumb {
color: #fff;
} }
</style> </style>
@@ -157,6 +227,9 @@ const segments = 12;
const valueDisplay = sliderContainer.querySelector( const valueDisplay = sliderContainer.querySelector(
".tui-slider-value", ".tui-slider-value",
) as HTMLElement; ) as HTMLElement;
const defaultValue = parseFloat(
sliderContainer.getAttribute("data-default-value") || "0",
);
if (!input || !track || !valueDisplay) return; if (!input || !track || !valueDisplay) return;
@@ -187,6 +260,10 @@ const segments = 12;
}); });
valueDisplay.textContent = val.toFixed(2); valueDisplay.textContent = val.toFixed(2);
// Add modified class if value differs from default
const isModified = Math.abs(val - defaultValue) > 0.001;
sliderContainer.classList.toggle("modified", isModified);
} }
input.addEventListener("input", updateVisual); input.addEventListener("input", updateVisual);

View File

@@ -2,6 +2,7 @@
interface Props { interface Props {
id: string; id: string;
label: string; label: string;
abbr?: string;
checked?: boolean; checked?: boolean;
title?: string; title?: string;
description?: string; description?: string;
@@ -10,6 +11,7 @@ interface Props {
const { const {
id, id,
label, label,
abbr,
checked = false, checked = false,
title = "", title = "",
description = "", description = "",
@@ -24,7 +26,10 @@ const {
data-tooltip-title={title} data-tooltip-title={title}
data-tooltip-desc={description} data-tooltip-desc={description}
> >
<span class="tui-toggle-label">{label}</span> <span class:list={["tui-toggle-label", { "has-abbr": !!abbr }]}>
<span class="full">{label}</span>
{abbr && <span class="abbr">{abbr}</span>}
</span>
</button> </button>
<style> <style>
@@ -32,35 +37,55 @@ const {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: none; background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 103, 0, 0.3); border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text-color); color: #fff;
opacity: 0.8;
font-family: inherit; font-family: inherit;
font-size: 11px; font-size: 11px;
padding: 2px 8px; padding: 4px 12px;
cursor: pointer; cursor: pointer;
opacity: 0.5; transition: all 0.2s;
transition: all 0.15s;
user-select: none; user-select: none;
min-width: 3ch; min-width: 3ch;
text-align: center; text-align: center;
border-radius: 2px;
} }
.tui-toggle:hover { .tui-toggle:hover {
opacity: 0.8; color: var(--accent-color);
background: rgba(255, 103, 0, 0.1); opacity: 1;
border-color: var(--accent-color);
background: color-mix(in srgb, var(--accent-color), transparent 95%);
} }
.tui-toggle.active { .tui-toggle.active {
background: var(--text-color); background: var(--accent-color);
color: #000; color: #fff;
opacity: 1; opacity: 1;
font-weight: bold; border-color: var(--accent-color);
border-color: var(--text-color); font-weight: 700;
box-shadow: 0 0 15px
color-mix(in srgb, var(--accent-color), transparent 80%);
} }
.tui-toggle-label { .tui-toggle-label {
font-weight: bold; font-weight: 600;
letter-spacing: 0.5px;
display: flex;
}
.tui-toggle-label .abbr {
display: none;
}
@media (max-width: 1400px) {
.tui-toggle-label.has-abbr .full {
display: none;
}
.tui-toggle-label.has-abbr .abbr {
display: inline;
}
} }
</style> </style>

View File

@@ -0,0 +1,20 @@
---
title: "The Future of Syntaxbullet"
description: "A glimpse into what's coming next for this digital garden."
pubDate: "2026-02-10"
heroImage: "/blog-placeholder-1.jpg"
---
# Welcome to my Website
This digital garden is currently sprouting.
I'm working on a series of articles that explore the intersection of **engineering, design, and artificial intelligence**.
Upcoming topics will include:
- Deep dives into the ASCII art generation techniques used on the homepage.
- Modern web performance optimization strategies.
- Thoughts on the evolving role of AI in software development.
- Case studies of successful AI-powered software projects and papers.
Stay tuned for updates. In the meantime, feel free to play with the [generator](/).

View File

@@ -1,24 +0,0 @@
---
title: 'System Initialization'
description: 'Bootstrapping the Neko ASCII Generator.'
pubDate: '2026-02-08'
heroImage: '/blog/boot.png'
---
## Initializing Core Systems...
The Neko ASCII Auto-Generator has been successfully migrated to the Astro framework.
### Features
- Real-time image processing
- CLI-inspired controls
- Dynamic font scaling
- Automatic parameter tuning based on image histogram
### Changelog v2.0
- Migrated from vanilla HTML/JS to Astro
- Added Blog module
- Improved mobile responsiveness
Running diagnostics... **OK**
Systems online.

View File

@@ -1,6 +1,4 @@
--- ---
import Navbar from '../components/Navbar.astro';
interface Props { interface Props {
title: string; title: string;
showScroll?: boolean; showScroll?: boolean;
@@ -14,30 +12,33 @@ const { title, showScroll = false } = Astro.props;
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Syntaxbullet - Full Stack Engineer & Creative Technologist. Building high-performance digital experiences with a focus on engineering, design, and AI."
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@100..800&display=swap" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@100..800&display=swap"
rel="stylesheet"> rel="stylesheet"
/>
<title>{title}</title> <title>{title}</title>
<style is:global> <style is:global>
@import "../styles/global.css"; @import "../styles/global.css";
</style> </style>
<style define:vars={{ overflow: showScroll ? 'auto' : 'hidden' }}> <style define:vars={{ overflow: showScroll ? "auto" : "hidden" }}>
body { body {
overflow: var(--overflow); overflow: var(--overflow);
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
padding-top: 24px; box-sizing: border-box;
box-sizing: border-box; }
} </style>
</style>
</head> </head>
<body> <body>
<Navbar />
<slot /> <slot />
</body> </body>
</html> </html>

362
src/pages/ascii.astro Normal file
View File

@@ -0,0 +1,362 @@
---
import Layout from "../layouts/Layout.astro";
import Tooltip from "../components/Tooltip.astro";
import ControlPanel from "../components/ControlPanel.astro";
---
<Layout title="ASCII Art Generator - Syntaxbullet">
<div class="ascii-layout">
<main class="ascii-workspace">
<div class="canvas-layer">
<div id="loading">Loading...</div>
<pre id="ascii-result"></pre>
<canvas id="ascii-canvas"></canvas>
</div>
<ControlPanel />
<div id="landing-screen" class="landing-overlay">
<div class="landing-content">
<h1>ASCII Art Generator</h1>
<p>
Generate stunning ASCII art from images. Pull a random
image from an anime API or upload your own to get
started.
</p>
<div class="landing-buttons">
<button id="btn-start-api" class="landing-btn"
>Anime API</button
>
<button id="btn-start-upload" class="landing-btn"
>Upload Image</button
>
</div>
<p class="disclaimer">
<b>Disclaimer:</b> Images loaded via the API are not my own
and are not filtered or curated. In rare cases, they might
contain sensitive material.
</p>
</div>
</div>
</main>
</div>
<script>
import { AsciiController } from "../scripts/ascii-controller";
import { ImageQueue } from "../scripts/image-queue";
import { UIBindings } from "../scripts/ui-bindings";
if (window.__ASCII_APP__) {
console.log("♻️ Disposing previous application instance...");
try {
window.__ASCII_APP__.dispose();
} catch (e) {
console.error("Failed to dispose previous instance:", e);
}
}
const canvas = document.getElementById(
"ascii-canvas",
) as HTMLCanvasElement;
const asciiResult = document.getElementById(
"ascii-result",
) as HTMLPreElement;
const loadingIndicator = document.getElementById(
"loading",
) as HTMLDivElement;
if (!canvas || !asciiResult || !loadingIndicator) {
throw new Error("Critical UI elements missing");
}
let controller: AsciiController;
let queue: ImageQueue;
let ui: UIBindings;
controller = new AsciiController(
canvas,
asciiResult,
loadingIndicator,
);
queue = new ImageQueue(2);
ui = new UIBindings(controller, queue, loadNewImage);
window.__ASCII_APP__ = {
controller: controller!,
queue: queue!,
ui: ui!,
dispose: () => {
controller?.dispose();
ui?.dispose();
queue?.dispose();
window.__ASCII_APP__ = undefined;
},
};
controller.onSettingsChanged(() => ui!.updateUI());
let retryCount = 0;
const MAX_RETRIES = 3;
async function loadNewImage(): Promise<void> {
if (!controller || !queue || !ui) return;
try {
let item;
if (queue.getLength() === 0) {
controller.showLoading("FETCHING...");
item = await queue.fetchDirect();
} else {
item = queue.pop()!;
queue.ensureFilled();
}
controller.setCurrentImage(item.url, item.suggestions);
retryCount = 0;
ui.updateUI();
await controller.generate();
controller.hideLoading();
} catch (e) {
console.error(e);
if (retryCount < MAX_RETRIES) {
retryCount++;
asciiResult.textContent = `Connection lost. Retrying (${retryCount}/${MAX_RETRIES})...`;
setTimeout(loadNewImage, 2000);
} else {
asciiResult.textContent =
"Connection failed. Please refresh.";
controller.hideLoading();
}
}
}
ui.init();
const landingScreen = document.getElementById("landing-screen");
const btnStartApi = document.getElementById("btn-start-api");
const btnStartUpload = document.getElementById("btn-start-upload");
const fileInput = document.getElementById(
"file-upload",
) as HTMLInputElement;
const controlPanel = document.querySelector(".control-panel");
const hideLanding = () => {
landingScreen?.classList.add("hidden");
if (controlPanel) {
controlPanel.classList.add("visible");
}
};
btnStartApi?.addEventListener("click", () => {
hideLanding();
loadNewImage().then(() => {
queue?.ensureFilled();
});
});
btnStartUpload?.addEventListener("click", () => {
fileInput?.click();
});
document.addEventListener("ascii-image-imported", () => {
hideLanding();
});
</script>
<Tooltip />
</Layout>
<style>
.ascii-layout {
display: flex;
width: 100vw;
height: 100vh;
overflow: hidden;
background: var(--bg-color);
}
.ascii-workspace {
flex-grow: 1;
height: 100vh;
position: relative;
display: flex;
flex-direction: column;
background: #050505;
overflow: hidden;
}
.canvas-layer {
flex-grow: 1;
position: relative;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
background: radial-gradient(circle at center, #111 0%, #000 100%);
}
#ascii-result {
font-size: 8px;
line-height: 1;
white-space: pre;
color: var(--text-color);
transform-origin: center;
}
#ascii-canvas {
width: 100%;
height: 100%;
object-fit: contain;
display: none;
image-rendering: pixelated;
opacity: 0;
transition: opacity 0.5s ease;
touch-action: none;
}
#loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-family: var(--font-mono);
color: #fff;
font-size: 1.5rem;
display: none;
z-index: 10;
background: rgba(0, 0, 0, 0.8);
padding: 1rem 2rem;
border-radius: 4px;
}
.landing-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(12px);
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
transition:
opacity 0.6s cubic-bezier(0.4, 0, 0.2, 1),
visibility 0.6s;
}
.landing-overlay.hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.landing-content {
max-width: 440px;
padding: 2.5rem;
text-align: center;
background: rgba(15, 15, 15, 0.9);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.6);
animation: landing-in 0.8s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes landing-in {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.landing-content h1 {
font-size: 1.75rem;
margin-bottom: 1rem;
font-weight: 700;
letter-spacing: -0.02em;
color: #fff;
}
.landing-content p {
font-size: 0.95rem;
line-height: 1.6;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 2rem;
}
.landing-buttons {
display: flex;
gap: 0.75rem;
justify-content: center;
margin-bottom: 2rem;
}
.landing-btn {
padding: 0.75rem 1.25rem;
border-radius: 8px;
font-size: 0.85rem;
font-family: var(--font-mono);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.02);
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
flex: 1;
}
.landing-btn:hover {
color: #fff;
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
#btn-start-api {
color: #fff;
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
}
#btn-start-api:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
}
.disclaimer {
font-size: 0.7rem !important;
color: rgba(255, 255, 255, 0.3) !important;
line-height: 1.4 !important;
margin-bottom: 0 !important;
text-align: left;
padding-top: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
:global(.control-panel) {
width: 100%;
transform: translateY(150%);
opacity: 0;
transition:
transform 0.8s cubic-bezier(0.16, 1, 0.3, 1),
opacity 0.6s ease;
}
:global(.control-panel.visible) {
transform: translateY(0);
opacity: 1;
}
@media (max-width: 1200px) {
.ascii-layout {
flex-direction: column;
height: 100dvh;
}
}
</style>

View File

@@ -16,117 +16,240 @@ if (!entry) {
const { Content } = await entry.render(); const { Content } = await entry.render();
--- ---
<Layout title={entry.data.title} showScroll={true}> <Layout title={entry.data.title}>
<main> <div class="split-layout">
<article> <main class="content-workspace">
<section class="h-entry"> <div class="content-container">
<header> <article class="h-entry">
<h1 class="p-name">{entry.data.title}</h1> <header class="post-header">
<div class="metadata"> <a href="/blog" class="back-link">
<time &larr; Back to Blog
class="dt-published" </a>
datetime={entry.data.pubDate.toISOString()}
> <h1 class="p-name">{entry.data.title}</h1>
{entry.data.pubDate.toISOString().slice(0, 10)}
</time> <div class="metadata">
{ <span class="dt-published">
entry.data.updatedDate && ( {entry.data.pubDate.toISOString().slice(0, 10)}
<div class="last-updated"> </span>
Last updated on{" "} {
<time> entry.data.updatedDate && (
<span class="updated-date">
&nbsp;&bull;&nbsp; Updated:{" "}
{entry.data.updatedDate {entry.data.updatedDate
.toISOString() .toISOString()
.slice(0, 10)} .slice(0, 10)}
</time> </span>
</div> )
) }
} </div>
<div class="divider"></div>
</header>
<div class="e-content">
<Content />
</div> </div>
</header> </article>
<div class="e-content"> </div>
<Content /> </main>
</div> </div>
</section>
</article>
</main>
</Layout> </Layout>
<style> <style>
main { /* Split Layout */
width: calc(100% - 2em); .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;
overflow-y: auto;
overflow-x: hidden;
}
.content-container {
max-width: 800px; max-width: 800px;
margin: 0; width: 100%;
padding: 2em; margin: 0 auto;
padding: 4rem 2rem;
box-sizing: border-box;
} }
header { /* Header Styling */
margin-bottom: 2rem; .post-header {
margin-bottom: 3rem;
} }
header a { .back-link {
display: inline-block; display: inline-block;
margin-bottom: 1rem; margin-bottom: 1.5rem;
color: var(--text-color); font-family:
opacity: 0.6; system-ui,
font-family: var(--font-mono); -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 { .back-link:hover {
opacity: 1; color: #fff;
} }
.title { h1 {
font-size: 2em; font-size: 2.5rem;
margin: 0.25em 0 0; font-weight: 800;
} margin: 0 0 1rem 0;
line-height: 1.2;
hr { color: #fff;
border-top: 1px solid var(--text-color); letter-spacing: -0.5px;
opacity: 0.3;
margin: 1rem 0;
} }
.metadata { .metadata {
font-family: var(--font-mono); font-family: var(--font-mono);
color: var(--text-color);
opacity: 0.8;
font-size: 0.9rem; 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 { .e-content {
line-height: 1.6; line-height: 1.8;
font-family: var(--font-mono); /* Keep vibe */ font-size: 1.1rem;
font-size: 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(h1),
.e-content :global(h2), .e-content :global(h2),
.e-content :global(h3), .e-content :global(h3),
.e-content :global(h4) { .e-content :global(h4) {
margin-top: 2rem; font-family:
margin-bottom: 1rem; system-ui,
color: var(--text-color); -apple-system,
font-weight: bold; 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) { .e-content :global(a) {
color: var(--text-color); color: #fff;
text-decoration: underline; 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) { .e-content :global(code) {
background: #111;
padding: 2px 5px;
border-radius: 2px;
font-family: var(--font-mono); 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; color: #fff;
} }
.e-content :global(pre) { .e-content :global(pre code) {
background: #111; background: none;
padding: 1rem; padding: 0;
border: 1px solid #333; color: inherit;
overflow-x: auto; 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> </style>

View File

@@ -8,92 +8,214 @@ const posts = (await getCollection("blog")).sort(
); );
--- ---
<Layout title="System Logs" showScroll={true}> <Layout title="System Logs">
<main> <div class="split-layout">
<section> <main class="content-workspace">
<ul> <div class="content-container">
{ <header class="page-header">
posts.map((post: any) => ( <a href="/" class="back-link"> &larr; Back to Home </a>
<li> <h1>Blog Articles</h1>
<a href={`/blog/${post.slug}/`}> <div class="divider"></div>
<span class="date"> </header>
[
{post.data.pubDate
.toISOString()
.slice(0, 10)}
]
</span>
<span class="title">{post.data.title}</span>
<span class="desc">
// {post.data.description}
</span>
</a>
</li>
))
}
</ul>
</section>
<footer> <ul class="post-list">
<p>END OF STREAM</p> {
</footer> posts.map((post: any) => (
</main> <li>
<a
href={`/blog/${post.slug}/`}
class="post-link"
>
<div class="post-meta">
<span class="date">
{post.data.pubDate
.toISOString()
.slice(0, 10)}
</span>
</div>
<div class="post-info">
<span class="title">
{post.data.title}
</span>
<span class="desc">
{post.data.description}
</span>
</div>
</a>
</li>
))
}
</ul>
<footer>
<p>&copy; {new Date().getFullYear()} Syntaxbullet</p>
</footer>
</div>
</main>
</div>
</Layout> </Layout>
<style> <style>
main { /* Split Layout - Consistent with Homepage */
width: 960px; .split-layout {
max-width: calc(100% - 2em); display: flex;
margin: 0 auto; width: 100vw;
padding: 2em 0; 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;
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;
display: flex;
flex-direction: column;
min-height: 100%;
}
/* Header Styling */
.page-header {
margin-bottom: 3rem;
}
.back-link {
display: inline-block;
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;
}
.back-link:hover {
color: #fff;
}
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; list-style-type: none;
padding: 0; padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 1.5rem;
flex-grow: 1;
} }
li { .post-link {
margin-bottom: 1rem; display: flex;
border-left: 2px solid transparent; flex-direction: column; /* Mobile first */
transition: border-left-color 0.2s; gap: 0.5rem;
}
li:hover {
border-left-color: var(--text-color);
}
a {
display: block;
text-decoration: none; text-decoration: none;
padding: 5px 10px; padding: 1.5rem;
color: var(--text-color); border: 1px solid rgba(255, 255, 255, 0.1);
font-family: var(--font-mono); background: rgba(255, 255, 255, 0.02);
transition: all 0.2s ease;
border-radius: 4px;
} }
.date { .post-link:hover {
color: rgba(255, 103, 0, 0.6); border-color: rgba(255, 255, 255, 0.3);
margin-right: 1rem; 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 { .title {
font-weight: bold; font-size: 1.25rem;
margin-right: 1rem; font-weight: 600;
color: #fff;
} }
.desc { .desc {
color: rgba(255, 103, 0, 0.4); color: rgba(255, 255, 255, 0.7);
font-style: italic; font-family:
} system-ui,
-apple-system,
a:hover .title { sans-serif;
text-decoration: underline; font-size: 1rem;
line-height: 1.5;
} }
footer { footer {
margin-top: 4rem; margin-top: 4rem;
padding-top: 2rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
text-align: center; text-align: center;
opacity: 0.3; opacity: 0.5;
font-size: 0.8rem; 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> </style>

View File

@@ -1,701 +1,227 @@
--- ---
import Layout from "../layouts/Layout.astro"; import Layout from "../layouts/Layout.astro";
import TuiSlider from "../components/TuiSlider.astro"; import { Zap, FileText, Mail } from "@lucide/astro";
import TuiSegment from "../components/TuiSegment.astro";
import TuiToggle from "../components/TuiToggle.astro";
import TuiButton from "../components/TuiButton.astro";
import Tooltip from "../components/Tooltip.astro";
--- ---
<Layout title="Syntaxbullet - Digital Wizard"> <Layout title="Syntaxbullet - Full Stack Engineer">
<div class="hero-wrapper"> <div class="home-container">
<!-- Background Layer: ASCII Art --> <main class="home-content">
<div class="ascii-layer"> <div class="brand-group">
<div id="loading">Loading...</div> <h1 class="brand-title">SYNTAXBULLET</h1>
<pre id="ascii-result">Preparing art...</pre> <div class="brand-subtitle">
<canvas id="ascii-canvas"></canvas> FULL STACK ENGINEER
</div> <span class="muted">|</span>
CREATIVE TECHNOLOGIST
<!-- Foreground Layer: Content --> </div>
<div class="content-layer">
<div class="max-w-container">
<main class="hero-content">
<div class="hero-text">
<h2>SYNTAXBULLET</h2>
<p class="tagline">
Self-taught Munich-based software engineer
passionate about Generative AI, Linux, and the Web.
</p>
</div>
</main>
<footer class="controls-footer">
<div id="tui-controls">
<!-- Sliders Section -->
<div class="control-panel-section sliders-section">
<div class="section-header">ADJUSTMENTS</div>
<div class="sliders-grid">
<TuiSlider
id="exposure"
label="EXP"
min={0}
max={3}
step={0.01}
value={1.0}
title="Exposure / Brightness"
description="Adjusts the overall brightness level of the input image before processing."
/>
<TuiSlider
id="contrast"
label="CON"
min={0}
max={3}
step={0.01}
value={1.0}
title="Contrast"
description="Increases or decreases the difference between light and dark areas."
/>
<TuiSlider
id="saturation"
label="SAT"
min={0}
max={3}
step={0.01}
value={1.2}
title="Saturation"
description="Controls color intensity. Higher values make colors more vibrant in Color Mode."
/>
<TuiSlider
id="gamma"
label="GAM"
min={0}
max={3}
step={0.01}
value={1.0}
title="Gamma Correction"
description="Non-linear brightness adjustment. useful for correcting washed-out or too dark images."
/>
<TuiSlider
id="overlayStrength"
label="OVL"
min={0}
max={1}
step={0.01}
value={0.3}
title="Overlay Blend Strength"
description="Blends the original image over the ASCII output. 0 is pure ASCII, 1 is original image."
/>
<TuiSlider
id="resolution"
label="RES"
min={0.1}
max={2}
step={0.01}
value={1.0}
title="Resolution Scale"
description="Adjusts the density of characters. Higher values give more detail but may reduce performance."
/>
<TuiSlider
id="dither"
label="DTH"
min={0}
max={1}
step={0.01}
value={0}
title="Dither Strength"
description="Applies ordered dithering to simulate shading. Useful for low-contrast areas."
/>
</div>
</div>
<!-- Divider -->
<div class="control-panel-divider"></div>
<!-- Toggles & Segments Section -->
<div class="control-panel-section toggles-section">
<div class="section-header">EFFECTS</div>
<div class="toggles-row">
<TuiToggle
id="toggle-color"
label="CLR"
title="Color Output (HTML)"
description="Toggles between monochrome text and colored HTML spans."
/>
<TuiToggle
id="toggle-denoise"
label="DNZ"
title="Denoise Pre-processing"
description="Applies a bilateral filter to reduce image noise while preserving edges."
/>
</div>
<div
class="section-header"
style="margin-top: 8px;"
>
OUTPUT
</div>
<div class="segments-row">
<TuiSegment
id="segment-invert"
label="INV"
options={["AUTO", "ON", "OFF"]}
value="AUTO"
title="Invert Colors"
description="Inverts brightness mapping. AUTO detects dark/light mode."
/>
<TuiSegment
id="segment-edge"
label="EDG"
options={["OFF", "SPL", "SOB", "CNY"]}
value="OFF"
title="Edge Detection Mode"
description="Algorithm used to detect edges. SPL: Simple, SOB: Sobel, CNY: Canny."
/>
<TuiSegment
id="segment-charset"
label="SET"
options={[
"STD",
"EXT",
"BLK",
"MIN",
"DOT",
"SHP",
]}
value="STD"
title="Character Set"
description="The set of characters used for mapping brightness levels."
/>
</div>
</div>
<!-- Divider -->
<div class="control-panel-divider"></div>
<!-- Actions Section -->
<div class="control-panel-section actions-section">
<div class="section-header">ACTIONS</div>
<div class="actions-row">
<TuiButton
id="btn-reset"
label="RESET"
shortcut="R"
title="Reset to Auto-detected Settings"
description="Resets all sliders and toggles to their default values."
/>
<TuiButton
id="btn-next"
label="NEXT"
shortcut="N"
variant="primary"
title="Load Next Image"
description="Discards current image and loads a new one from the queue."
/>
<div
class="queue-display"
data-tooltip-title="Buffered Images"
data-tooltip-desc="Number of images pre-loaded in background queue."
>
<span class="queue-label">Q:</span>
<span id="val-queue" class="queue-value"
>0</span
>
</div>
</div>
</div>
<!-- Divider -->
<div class="control-panel-divider"></div>
<!-- Export Section -->
<div class="control-panel-section export-section">
<div class="section-header">EXPORT</div>
<div class="actions-row">
<TuiButton
id="btn-save-png"
label="PNG"
title="Save as Image"
description="Download high-res PNG capture of the current view."
/>
<TuiButton
id="btn-copy-text"
label="TXT"
title="Save as Text"
description="Download raw ASCII text file."
/>
<TuiButton
id="btn-copy-html"
label="HTML"
title="Save as HTML"
description="Download colored HTML file."
/>
</div>
</div>
</div>
<!-- Keyboard shortcuts hint -->
<div class="shortcuts-hint">
<span><kbd>N</kbd> Next</span>
<span><kbd>R</kbd> Reset</span>
<span><kbd>I</kbd> Invert</span>
<span><kbd>C</kbd> Color</span>
<span><kbd>D</kbd> Dither</span>
<span><kbd>E</kbd> Edges</span>
<span><kbd>S</kbd> Charset</span>
</div>
</footer>
</div> </div>
</div>
<p class="brand-bio">
Crafting high-performance digital experiences at the intersection of
engineering, design, and artificial intelligence.
</p>
<nav class="nav-links">
<a href="/ascii" class="nav-link">
<span class="icon"><Zap size={20} /></span>
<span class="label">GENERATE</span>
</a>
<a href="/blog" class="nav-link">
<span class="icon"><FileText size={20} /></span>
<span class="label">BLOG</span>
</a>
<a href="mailto:me@syntaxbullet.com" class="nav-link">
<span class="icon"><Mail size={20} /></span>
<span class="label">CONTACT</span>
</a>
</nav>
<div class="social-links">
<a
href="https://git.ayau.me/syntaxbullet"
target="_blank"
rel="noopener noreferrer"
aria-label="Git"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"
></path>
<path d="M9 18c-4.51 2-5-2-7-2"></path>
</svg>
</a>
<a
href="https://www.linkedin.com/in/ivan-jovanovic-51b319187/"
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"
></path>
<rect width="4" height="12" x="2" y="9"></rect>
<circle cx="4" cy="4" r="2"></circle>
</svg>
</a>
</div>
</main>
</div> </div>
<script>
import { AsciiController } from "../scripts/ascii-controller";
import { ImageQueue } from "../scripts/image-queue";
import { UIBindings } from "../scripts/ui-bindings";
// ============= Global Cleanup Protocol =============
// Fix for accumulating event listeners and render loops during HMR/Navigation
if (window.__ASCII_APP__) {
console.log("♻️ Disposing previous application instance...");
try {
window.__ASCII_APP__.dispose();
} catch (e) {
console.error("Failed to dispose previous instance:", e);
}
}
// ============= DOM Elements =============
const canvas = document.getElementById(
"ascii-canvas",
) as HTMLCanvasElement;
const asciiResult = document.getElementById(
"ascii-result",
) as HTMLPreElement;
const loadingIndicator = document.getElementById(
"loading",
) as HTMLDivElement;
if (!canvas || !asciiResult || !loadingIndicator) {
throw new Error("Critical UI elements missing");
}
// ============= Initialize =============
const controller = new AsciiController(
canvas,
asciiResult,
loadingIndicator,
);
const queue = new ImageQueue(2);
const ui = new UIBindings(controller, queue, loadNewImage);
// Store instances globally for cleanup
window.__ASCII_APP__ = {
controller,
queue,
ui,
dispose: () => {
controller.dispose();
ui.dispose();
queue.dispose();
window.__ASCII_APP__ = undefined;
},
};
// Link settings updates to UI sync
controller.onSettingsChanged(() => ui.updateUI());
let retryCount = 0;
const MAX_RETRIES = 3;
// ============= Image Loading =============
async function loadNewImage(): Promise<void> {
try {
let item;
if (queue.getLength() === 0) {
controller.showLoading("FETCHING...");
item = await queue.fetchDirect();
} else {
item = queue.pop()!;
queue.ensureFilled(); // Background refill
}
controller.setCurrentImage(item.url, item.suggestions);
retryCount = 0;
ui.updateUI();
await controller.generate();
controller.hideLoading();
} catch (e) {
console.error(e);
if (retryCount < MAX_RETRIES) {
retryCount++;
asciiResult.textContent = `SIGNAL LOST. RETRYING (${retryCount}/${MAX_RETRIES})...`;
setTimeout(loadNewImage, 2000);
} else {
asciiResult.textContent = "SIGNAL LOST. PLEASE REFRESH.";
controller.hideLoading();
}
}
}
// ============= Initialize UI and Load First Image =============
ui.init();
loadNewImage().then(() => {
queue.ensureFilled();
});
</script>
<Tooltip />
</Layout> </Layout>
<style> <style>
/* Layout Wrapper */ .home-container {
.hero-wrapper { display: flex;
position: relative; justify-content: center;
align-items: center;
width: 100vw; width: 100vw;
height: 100%; height: 100vh;
overflow: hidden; overflow: hidden;
background: var(--bg-color);
} }
/* BACKGROUND LAYER */ .home-content {
.ascii-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0; /* Behind everything */
display: flex; /* Original centering logic */
justify-content: center;
align-items: center;
pointer-events: none; /* Let clicks pass through to body/script */
opacity: 0.6; /* Slight fade to let text pop */
transition: opacity 0.5s ease;
}
#ascii-result {
font-size: 8px; /* Dynamic but starts here */
line-height: 1;
white-space: pre;
color: var(--text-color);
/* If specific alignment needed for hero: */
transform-origin: center;
}
#ascii-canvas {
width: 100%;
height: 100%;
object-fit: contain;
display: none;
image-rendering: pixelated;
opacity: 0;
transition: opacity 0.5s ease;
}
#loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-family: var(--font-mono);
color: #fff;
font-size: 1.5rem;
display: none;
z-index: 10;
}
/* FOREGROUND LAYER */
.content-layer {
position: relative;
z-index: 1; /* Above ASCII */
width: 100%;
height: 100%;
background: radial-gradient(
circle at center,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.4) 80%
); /* Subtle vignette */
pointer-events: none; /* Let clicks pass for image regen */
}
.max-w-container {
width: 100%;
max-width: 1440px;
margin: 0 auto;
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; align-items: center;
} text-align: center;
/* Make interactive elements clickable again */
.site-header,
.controls-footer,
.hero-text {
pointer-events: auto;
}
/* Don't block clicks on pure layout areas if they are empty,
but hero-text might block image regen click if big.
Let's keep hero-text pointer-events auto only on text selection?
Actually user wants click anywhere to regen.
Let's make sure 'a' and 'button' are clickable, anything else triggers regen via body listener. */
.hero-text {
pointer-events: none; /* Let clicks through text area */
}
/* Center Hero Text */
.hero-content {
padding: 2rem; padding: 2rem;
max-width: 560px;
animation: fade-in 0.8s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.brand-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center;
flex-grow: 1;
}
.hero-text h2 {
font-size: clamp(3rem, 8vw, 6rem);
line-height: 0.9;
margin: 0;
font-weight: 900;
opacity: 0.9;
text-shadow: 0 0 20px rgba(0, 0, 0, 0.8);
mix-blend-mode: difference; /* Cool effect over ASCII */
color: #fff; /* Make it pop against the blend */
}
.tagline {
margin-top: 1rem;
font-size: 1.2rem;
opacity: 0.8;
max-width: 400px;
background: rgba(0, 0, 0, 0.6);
padding: 5px;
}
/* Footer / Controls */
.controls-footer {
padding: 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem; gap: 0.75rem;
position: relative; margin-bottom: 1.5rem;
} }
#tui-controls { .brand-title {
font-size: clamp(2rem, 8vw, 4rem);
font-weight: 900;
margin: 0;
line-height: 1;
letter-spacing: -2px;
color: #fff;
}
.brand-subtitle {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
font-family: var(--font-mono);
letter-spacing: 2px;
display: flex; display: flex;
gap: 10px;
align-items: center;
justify-content: center;
flex-wrap: wrap;
}
.muted {
color: rgba(255, 255, 255, 0.2);
}
.brand-bio {
font-size: 1.1rem;
line-height: 1.7;
color: rgba(255, 255, 255, 0.7);
max-width: 420px;
margin: 0 0 2.5rem 0;
}
.nav-links {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
align-items: flex-start;
gap: 0;
background: rgba(0, 0, 0, 0.95);
border: 1px solid var(--text-color);
box-shadow: 0 0 30px rgba(0, 0, 0, 0.8);
max-width: 100%;
} }
.control-panel-section { .nav-link {
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.control-panel-divider {
width: 1px;
background: linear-gradient(
to bottom,
transparent 0%,
rgba(255, 103, 0, 0.3) 20%,
rgba(255, 103, 0, 0.3) 80%,
transparent 100%
);
align-self: stretch;
}
.section-header {
font-size: 9px;
font-weight: bold;
opacity: 0.5;
letter-spacing: 2px;
margin-bottom: 4px;
text-transform: uppercase;
}
.sliders-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px 16px;
}
.toggles-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.segments-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.actions-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
color: rgba(255, 255, 255, 0.6);
text-decoration: none;
font-family: var(--font-mono);
font-size: 0.85rem;
padding: 0.75rem 1.25rem;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.02);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
} }
.queue-display { .nav-link:hover {
color: #fff;
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
.nav-link .icon {
width: 20px;
height: 20px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px;
font-size: 11px;
opacity: 0.6;
padding: 0 8px;
border-left: 1px solid rgba(255, 103, 0, 0.2);
}
.queue-label {
opacity: 0.7;
}
.queue-value {
font-weight: bold;
min-width: 1.5ch;
text-align: center;
}
/* Shortcuts hint */
.shortcuts-hint {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
font-size: 10px;
opacity: 0.4;
transition: opacity 0.2s;
}
.shortcuts-hint:hover {
opacity: 0.7;
}
.shortcuts-hint span {
display: flex;
align-items: center;
gap: 4px;
}
.shortcuts-hint kbd {
display: inline-flex;
align-items: center;
justify-content: center; justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 9px;
font-family: inherit;
border: 1px solid rgba(255, 103, 0, 0.4);
border-radius: 2px;
background: rgba(255, 103, 0, 0.05);
} }
/* Legacy styles kept for compatibility */ .social-links {
.control-group {
display: flex; display: flex;
align-items: center; gap: 1.5rem;
gap: 8px; padding-top: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
} }
.tui-btn { .social-links a {
background: none; color: rgba(255, 255, 255, 0.4);
border: 1px solid #333;
color: var(--text-color);
font-family: inherit;
cursor: pointer;
padding: 2px 6px;
font-weight: bold;
opacity: 0.7;
transition: all 0.2s; transition: all 0.2s;
padding: 0.5rem;
} }
.tui-btn:hover { .social-links a:hover {
opacity: 1; color: #fff;
border-color: var(--text-color); transform: translateY(-2px);
background: rgba(255, 103, 0, 0.1);
} }
.tui-val { @media (max-width: 600px) {
min-width: 3ch; .nav-links {
text-align: center;
display: inline-block;
font-weight: bold;
}
/* Responsive Adjustments */
@media (max-width: 900px) {
.sliders-grid {
grid-template-columns: 1fr;
}
#tui-controls {
flex-direction: column; flex-direction: column;
align-items: stretch;
}
.control-panel-divider {
width: 100%; width: 100%;
height: 1px;
background: linear-gradient(
to right,
transparent 0%,
rgba(255, 103, 0, 0.3) 20%,
rgba(255, 103, 0, 0.3) 80%,
transparent 100%
);
} }
.control-panel-section { .nav-link {
padding: 10px 14px;
}
}
@media (max-width: 768px) {
.hero-text h2 {
font-size: 3rem;
}
.controls-footer {
padding: 1rem;
}
.shortcuts-hint {
display: none; /* Hide on mobile - too cramped */
}
.toggles-row,
.segments-row {
flex-wrap: wrap;
}
.actions-row {
flex-wrap: wrap;
justify-content: center; justify-content: center;
} }
} }
@media (max-width: 480px) {
.control-panel-section {
padding: 8px 10px;
}
.section-header {
font-size: 8px;
}
}
</style> </style>

View File

@@ -59,6 +59,30 @@ export class AsciiController {
showMagnifier: false showMagnifier: false
}; };
// Touch state
private lastTouchDist = 0;
private isDragging = false;
private lastTouchPos = { x: 0, y: 0 };
private resizeObserver: ResizeObserver | null = null;
// Edge panning state
private edgePanState = {
active: false,
directionX: 0, // -1 = left, 1 = right, 0 = none
directionY: 0, // -1 = up, 1 = down, 0 = none
animationId: null as number | null
};
private readonly EDGE_THRESHOLD = 0.08; // 8% of canvas edge triggers pan
private readonly PAN_SPEED = 0.012; // Speed of panning per frame
// Middle mouse panning state
private middleMousePanState = {
isDragging: false,
lastMousePos: { x: 0, y: 0 }
};
private mouseDragHandler: ((e: MouseEvent) => void) | null = null;
private mouseUpHandler: ((e: MouseEvent) => void) | null = null;
// Callbacks // Callbacks
private onSettingsChange?: () => void; private onSettingsChange?: () => void;
@@ -77,6 +101,43 @@ export class AsciiController {
} }
this.startRenderLoop(); 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));
// Middle mouse button panning
this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
this.mouseDragHandler = this.handleMouseDrag.bind(this);
this.mouseUpHandler = this.handleMouseUp.bind(this);
document.addEventListener('mousemove', this.mouseDragHandler);
document.addEventListener('mouseup', this.mouseUpHandler);
// Prevent context menu on right-click to allow all mouse buttons
this.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
}
private handleResize(): void {
// Re-calculate grid based on new dimensions
this.calculateGrid().then(() => {
this.requestRender('all');
});
} }
private getDefaultSettings(): AsciiSettings { private getDefaultSettings(): AsciiSettings {
@@ -92,7 +153,15 @@ export class AsciiController {
edgeMode: 0, edgeMode: 0,
overlayStrength: 0.3, overlayStrength: 0.3,
resolution: 1.0, resolution: 1.0,
charSet: 'standard' charSet: 'standard',
sharpen: 0,
edgeThreshold: 0.5,
shadows: 0,
highlights: 0,
scanlines: 0,
vignette: 0,
monoColor: '#ffffff',
backgroundColor: '#000000'
}; };
} }
@@ -213,7 +282,15 @@ export class AsciiController {
overlayStrength: suggestions.overlayStrength ?? this.settings.overlayStrength, overlayStrength: suggestions.overlayStrength ?? this.settings.overlayStrength,
charSet: validCharSet, charSet: validCharSet,
resolution: this.settings.resolution, 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,
backgroundColor: suggestions.backgroundColor ?? this.settings.backgroundColor
}; };
this.onSettingsChange?.(); this.onSettingsChange?.();
@@ -270,13 +347,20 @@ export class AsciiController {
async calculateGrid(): Promise<GridCache | undefined> { async calculateGrid(): Promise<GridCache | undefined> {
if (!this.currentImgUrl) return; if (!this.currentImgUrl) return;
const parent = this.canvas.parentElement;
if (!parent) return;
const fontAspectRatio = 0.55; const fontAspectRatio = 0.55;
const marginRatio = 0.2; const marginRatio = 0.05; // Reduced margin for container fit
const screenW = window.innerWidth; let screenW = parent.clientWidth;
if (screenW <= 0) screenW = window.innerWidth || 1000;
const availW = screenW * (1 - marginRatio); const availW = screenW * (1 - marginRatio);
let widthCols = Math.floor(availW / 6); let widthCols = Math.floor(availW / 6);
widthCols = Math.floor(widthCols * this.settings.resolution); 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)); widthCols = Math.max(10, Math.min(1000, widthCols));
const imgEl = await this.resolveImage(this.currentImgUrl); const imgEl = await this.resolveImage(this.currentImgUrl);
@@ -337,12 +421,24 @@ export class AsciiController {
} }
private updateCanvasSize(): void { private updateCanvasSize(): void {
const parent = this.canvas.parentElement;
if (!parent) return;
const fontAspectRatio = 0.55; const fontAspectRatio = 0.55;
const gridAspect = (this.cachedGrid.widthCols * fontAspectRatio) / this.cachedGrid.heightRows; // Safeguard against 0 height or NaNs
const screenW = window.innerWidth; const heightRows = Math.max(1, Math.floor(this.cachedGrid.heightRows));
const screenH = window.innerHeight; const widthCols = Math.max(1, this.cachedGrid.widthCols);
const maxW = screenW * 0.95; const gridAspect = (widthCols * fontAspectRatio) / heightRows;
const maxH = screenH * 0.95;
let screenW = parent.clientWidth;
let screenH = parent.clientHeight;
// Fallback for mobile initialization quirks where parent might be 0 initially
if (screenW <= 0) screenW = window.innerWidth;
if (screenH <= 0) screenH = window.innerHeight * 0.5; // Guessing half screen for workspace
const maxW = screenW * 0.98;
const maxH = screenH * 0.98;
let finalW: number, finalH: number; let finalW: number, finalH: number;
if (gridAspect > maxW / maxH) { if (gridAspect > maxW / maxH) {
@@ -353,9 +449,17 @@ export class AsciiController {
finalW = maxH * gridAspect; finalW = maxH * gridAspect;
} }
// Final safeguard against zero or NaN
if (!finalW || !finalH || isNaN(finalW) || isNaN(finalH)) {
finalW = 300;
finalH = 300 / gridAspect;
}
this.canvas.style.width = `${finalW}px`; this.canvas.style.width = `${finalW}px`;
this.canvas.style.height = `${finalH}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.width = finalW * dpr;
this.canvas.height = finalH * dpr; this.canvas.height = finalH * dpr;
} }
@@ -366,9 +470,15 @@ export class AsciiController {
this.canvas.style.display = 'block'; this.canvas.style.display = 'block';
this.canvas.style.opacity = '1'; this.canvas.style.opacity = '1';
this.requestRender('all'); this.requestRender('all');
// Insurance for mobile: trigger a second sizing/render after a short delay
// to catch cases where the layout might still be shifting (keyboard, address bar)
setTimeout(() => {
this.handleResize();
}, 100);
} }
// ============= Zoom ============= // ============= Zoom & Touch =============
handleWheel(e: WheelEvent): void { handleWheel(e: WheelEvent): void {
const rect = this.canvas.getBoundingClientRect(); const rect = this.canvas.getBoundingClientRect();
@@ -394,17 +504,97 @@ export class AsciiController {
} }
handleMouseMove(e: MouseEvent): void { handleMouseMove(e: MouseEvent): void {
if ('ontouchstart' in window && (e as any).sourceCapabilities?.firesTouchEvents) return; // Ignore simulated mouse events
const rect = this.canvas.getBoundingClientRect(); const rect = this.canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) / rect.width; const mx = (e.clientX - rect.left) / rect.width;
const my = (e.clientY - rect.top) / rect.height; const my = (e.clientY - rect.top) / rect.height;
this.zoomState.mousePos = { x: mx, y: my }; this.zoomState.mousePos = { x: mx, y: my };
const wasShowing = this.zoomState.showMagnifier; 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) { if (this.zoomState.showMagnifier || wasShowing) {
this.requestRender('uniforms'); this.requestRender('uniforms');
} }
// Handle edge panning when zoomed in
this.handleEdgePanning(mx, my);
}
private handleEdgePanning(mx: number, my: number): void {
// Only pan when zoomed in
if (this.zoomState.zoom <= 1.0) {
this.stopEdgePanning();
return;
}
// Check if mouse is near edges
const nearLeft = mx < this.EDGE_THRESHOLD && mx >= 0;
const nearRight = mx > (1 - this.EDGE_THRESHOLD) && mx <= 1;
const nearTop = my < this.EDGE_THRESHOLD && my >= 0;
const nearBottom = my > (1 - this.EDGE_THRESHOLD) && my <= 1;
// Determine pan direction
const dirX = nearLeft ? -1 : nearRight ? 1 : 0;
const dirY = nearTop ? -1 : nearBottom ? 1 : 0;
// Start or update panning
if (dirX !== 0 || dirY !== 0) {
this.edgePanState.directionX = dirX;
this.edgePanState.directionY = dirY;
if (!this.edgePanState.active) {
this.startEdgePanning();
}
} else {
this.stopEdgePanning();
}
}
private startEdgePanning(): void {
if (this.edgePanState.active) return;
this.edgePanState.active = true;
this.runEdgePanLoop();
}
private runEdgePanLoop(): void {
if (!this.edgePanState.active) return;
// Calculate pan amount based on zoom level (slower when zoomed in more)
const panAmount = this.PAN_SPEED / this.zoomState.zoom;
// Update zoom center
let newX = this.zoomState.zoomCenter.x + this.edgePanState.directionX * panAmount;
let newY = this.zoomState.zoomCenter.y + this.edgePanState.directionY * panAmount;
// Clamp zoom center to keep image visible
// When zoomed, the visible area is 1/zoom of the total image
const visibleRange = 1.0 / this.zoomState.zoom;
const minCenter = visibleRange / 2;
const maxCenter = 1.0 - visibleRange / 2;
this.zoomState.zoomCenter.x = Math.max(minCenter, Math.min(maxCenter, newX));
this.zoomState.zoomCenter.y = Math.max(minCenter, Math.min(maxCenter, newY));
this.requestRender('uniforms');
// Continue panning
this.edgePanState.animationId = requestAnimationFrame(() => this.runEdgePanLoop());
}
private stopEdgePanning(): void {
if (!this.edgePanState.active) return;
this.edgePanState.active = false;
this.edgePanState.directionX = 0;
this.edgePanState.directionY = 0;
if (this.edgePanState.animationId !== null) {
cancelAnimationFrame(this.edgePanState.animationId);
this.edgePanState.animationId = null;
}
} }
handleMouseLeave(): void { handleMouseLeave(): void {
@@ -412,6 +602,127 @@ export class AsciiController {
this.zoomState.showMagnifier = false; this.zoomState.showMagnifier = false;
this.requestRender('uniforms'); this.requestRender('uniforms');
} }
this.stopEdgePanning();
}
// Middle mouse button panning handlers
handleMouseDown(e: MouseEvent): void {
// Middle mouse button is button 1
if (e.button === 1 && this.zoomState.zoom > 1.0) {
e.preventDefault();
this.middleMousePanState.isDragging = true;
this.middleMousePanState.lastMousePos = { x: e.clientX, y: e.clientY };
// Hide magnifier while dragging
this.zoomState.showMagnifier = false;
// Stop edge panning while manually panning
this.stopEdgePanning();
// Change cursor to closed hand (grabbing)
this.canvas.style.cursor = 'grabbing';
this.requestRender('uniforms');
}
}
handleMouseDrag(e: MouseEvent): void {
if (!this.middleMousePanState.isDragging || this.zoomState.zoom <= 1.0) return;
const curX = e.clientX;
const curY = e.clientY;
// Calculate movement delta in normalized coordinates
const dx = (curX - this.middleMousePanState.lastMousePos.x) / this.canvas.width;
const dy = (curY - this.middleMousePanState.lastMousePos.y) / this.canvas.height;
// Move zoom center opposite to drag direction
// Speed is inversely proportional to zoom (more zoom = slower pan for same mouse movement)
this.zoomState.zoomCenter.x -= dx / this.zoomState.zoom;
this.zoomState.zoomCenter.y -= dy / this.zoomState.zoom;
// Clamp zoom center to keep image visible
const visibleRange = 1.0 / this.zoomState.zoom;
const minCenter = visibleRange / 2;
const maxCenter = 1.0 - visibleRange / 2;
this.zoomState.zoomCenter.x = Math.max(minCenter, Math.min(maxCenter, this.zoomState.zoomCenter.x));
this.zoomState.zoomCenter.y = Math.max(minCenter, Math.min(maxCenter, this.zoomState.zoomCenter.y));
this.middleMousePanState.lastMousePos = { x: curX, y: curY };
this.requestRender('uniforms');
}
handleMouseUp(e: MouseEvent): void {
if (e.button === 1) {
this.middleMousePanState.isDragging = false;
// Reset cursor
this.canvas.style.cursor = '';
}
}
// 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 ============= // ============= Export =============
@@ -492,6 +803,17 @@ export class AsciiController {
if (this.animFrameId !== null) { if (this.animFrameId !== null) {
cancelAnimationFrame(this.animFrameId); cancelAnimationFrame(this.animFrameId);
} }
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
this.stopEdgePanning();
// Clean up middle mouse panning handlers
if (this.mouseDragHandler) {
document.removeEventListener('mousemove', this.mouseDragHandler);
}
if (this.mouseUpHandler) {
document.removeEventListener('mouseup', this.mouseUpHandler);
}
this.renderer?.dispose(); this.renderer?.dispose();
this.renderer = null; this.renderer = null;
} }

View File

@@ -80,7 +80,7 @@ export class AsciiExporter {
heightRows: number heightRows: number
): string { ): string {
const { pixels } = this.getPixels(img, widthCols, heightRows); const { pixels } = this.getPixels(img, widthCols, heightRows);
let output = `<pre style="font-family: monospace; line-height: 1; letter-spacing: 0; background-color: #000; color: #fff; font-size: 8px;">`; let output = `<pre style="font-family: monospace; line-height: 1; letter-spacing: 0; background-color: ${settings.backgroundColor}; color: #fff; font-size: 8px;">`;
const charSet = CHAR_SETS[settings.charSet] || CHAR_SETS.standard; const charSet = CHAR_SETS[settings.charSet] || CHAR_SETS.standard;
const charCount = charSet.length; const charCount = charSet.length;
@@ -92,7 +92,7 @@ export class AsciiExporter {
const b = pixels[i + 2]; const b = pixels[i + 2];
// 1. Calculate Luma // 1. Calculate Luma
let luma = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; // 1. Calculate Luma (skipped, using adjusted color luma below)
// 2. Apply Adjustments // 2. Apply Adjustments
// Note: For color mode, we might want to keep original color // Note: For color mode, we might want to keep original color
@@ -101,13 +101,11 @@ export class AsciiExporter {
// Adjust color for display // Adjust color for display
const adjColor = this.adjustColor(r, g, b, settings); const adjColor = this.adjustColor(r, g, b, settings);
const finalColor = settings.color ? adjColor : this.hexToRgb(settings.monoColor);
// Recalculate luma from adjusted color for char selection // Recalculate luma from adjusted color for char selection
let finalLuma = (0.2126 * adjColor.r + 0.7152 * adjColor.g + 0.0722 * adjColor.b) / 255; let finalLuma = (0.2126 * adjColor.r + 0.7152 * adjColor.g + 0.0722 * adjColor.b) / 255;
// Adjust luma curve for char selection (gamma/contrast/exposure) explicitly?
// Actually `adjustColor` does that.
if (settings.invert) finalLuma = 1.0 - finalLuma; if (settings.invert) finalLuma = 1.0 - finalLuma;
const charIndex = Math.floor(finalLuma * (charCount - 1) + 0.5); const charIndex = Math.floor(finalLuma * (charCount - 1) + 0.5);
@@ -116,7 +114,7 @@ export class AsciiExporter {
// Escape HTML // Escape HTML
const safeChar = char === '<' ? '&lt;' : char === '>' ? '&gt;' : char === '&' ? '&amp;' : char; const safeChar = char === '<' ? '&lt;' : char === '>' ? '&gt;' : char === '&' ? '&amp;' : char;
const colorStyle = `color: rgb(${Math.round(adjColor.r)}, ${Math.round(adjColor.g)}, ${Math.round(adjColor.b)})`; const colorStyle = `color: rgb(${Math.round(finalColor.r)}, ${Math.round(finalColor.g)}, ${Math.round(finalColor.b)})`;
output += `<span style="${colorStyle}">${safeChar}</span>`; output += `<span style="${colorStyle}">${safeChar}</span>`;
} }
output += "<br>"; output += "<br>";
@@ -177,4 +175,11 @@ export class AsciiExporter {
b: Math.max(0, Math.min(255, bNorm * 255)) b: Math.max(0, Math.min(255, bNorm * 255))
}; };
} }
private static hexToRgb(hex: string): { r: number, g: number, b: number } {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return { r, g, b };
}
} }

View File

@@ -23,6 +23,14 @@ export interface AsciiOptions {
denoise?: boolean; denoise?: boolean;
fontAspectRatio?: number; fontAspectRatio?: number;
onProgress?: (progress: number) => void; onProgress?: (progress: number) => void;
sharpen?: number;
edgeThreshold?: number;
shadows?: number;
highlights?: number;
scanlines?: number;
vignette?: number;
monoColor?: string;
backgroundColor?: string;
} }
export interface AsciiResult { export interface AsciiResult {
@@ -55,6 +63,14 @@ export interface AsciiSettings {
overlayStrength: number; overlayStrength: number;
resolution: number; resolution: number;
charSet: CharSetKey; charSet: CharSetKey;
sharpen: number;
edgeThreshold: number;
shadows: number;
highlights: number;
scanlines: number;
vignette: number;
monoColor: string;
backgroundColor: string;
} }
// ============= Constants ============= // ============= Constants =============

View File

@@ -29,6 +29,8 @@ export class UIBindings {
private queue: ImageQueue; private queue: ImageQueue;
private loadNewImageFn: () => Promise<void>; private loadNewImageFn: () => Promise<void>;
private isUpdatingUI = false; private isUpdatingUI = false;
private lastNextTime = 0;
private readonly NEXT_COOLDOWN = 1000; // 1 second cooldown
// Event Handlers implementation references // Event Handlers implementation references
@@ -62,11 +64,13 @@ export class UIBindings {
init(): void { init(): void {
this.setupSliders(); this.setupSliders();
this.setupToggles(); this.setupToggles();
this.setupColorInput();
this.setupSegments(); this.setupSegments();
this.setupButtons(); this.setupButtons();
this.setupKeyboard(); this.setupKeyboard();
this.setupZoom(); this.setupZoom();
this.setupResize(); this.setupResize();
this.setupImport();
// Periodic queue update // Periodic queue update
this.queueInterval = window.setInterval(() => this.updateQueueDisplay(), 1000); this.queueInterval = window.setInterval(() => this.updateQueueDisplay(), 1000);
@@ -80,7 +84,7 @@ export class UIBindings {
} }
// Cleanup Sliders // 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 => { sliderIds.forEach(id => {
const input = document.getElementById(id) as HTMLInputElement | null; const input = document.getElementById(id) as HTMLInputElement | null;
const handler = this.sliderHandlers.get(id); const handler = this.sliderHandlers.get(id);
@@ -126,11 +130,17 @@ export class UIBindings {
} }
// Cleanup Zoom // Cleanup Zoom
const heroWrapper = document.querySelector('.hero-wrapper'); const workspace = document.querySelector('.ascii-workspace') || document.querySelector('.hero-wrapper');
if (heroWrapper) { const canvas = document.getElementById('ascii-canvas');
if (this.zoomHandlers.wheel) heroWrapper.removeEventListener('wheel', this.zoomHandlers.wheel); if (workspace) {
if (this.zoomHandlers.move) heroWrapper.removeEventListener('mousemove', this.zoomHandlers.move); if (this.zoomHandlers.wheel) workspace.removeEventListener('wheel', this.zoomHandlers.wheel);
if (this.zoomHandlers.leave) heroWrapper.removeEventListener('mouseleave', this.zoomHandlers.leave); if (this.zoomHandlers.move) workspace.removeEventListener('mousemove', this.zoomHandlers.move);
if (this.zoomHandlers.leave) workspace.removeEventListener('mouseleave', this.zoomHandlers.leave);
}
if (canvas) {
if (this.zoomHandlers.wheel) canvas.removeEventListener('wheel', this.zoomHandlers.wheel);
if (this.zoomHandlers.move) canvas.removeEventListener('mousemove', this.zoomHandlers.move);
if (this.zoomHandlers.leave) canvas.removeEventListener('mouseleave', this.zoomHandlers.leave);
} }
this.zoomHandlers = {}; this.zoomHandlers = {};
@@ -142,19 +152,26 @@ export class UIBindings {
} }
// Cleanup Export Buttons // Cleanup Export Buttons
['btn-save-png', 'btn-copy-text', 'btn-copy-html'].forEach(id => { ['btn-save-png', 'btn-copy-text', 'btn-copy-html', 'btn-import'].forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
const handler = this.buttonHandlers.get(id); const handler = this.buttonHandlers.get(id);
if (el && handler) { if (el && handler) {
el.removeEventListener('click', handler); el.removeEventListener('click', handler);
} }
}); });
// Cleanup File Input
const fileInput = document.getElementById('file-upload');
const fileHandler = this.buttonHandlers.get('file-upload');
if (fileInput && fileHandler) {
fileInput.removeEventListener('change', fileHandler);
}
} }
// ============= Sliders ============= // ============= Sliders =============
private setupSliders(): void { 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 => { sliderIds.forEach(id => {
const input = document.getElementById(id) as HTMLInputElement | null; const input = document.getElementById(id) as HTMLInputElement | null;
@@ -217,6 +234,36 @@ export class UIBindings {
document.body.addEventListener('toggle-change', this.toggleHandler); 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;
}
});
}
const bgColorInput = document.getElementById('input-bg-color') as HTMLInputElement;
const bgColorSwatch = document.getElementById('bg-color-swatch-display');
if (bgColorInput) {
bgColorInput.addEventListener('input', () => {
if (this.isUpdatingUI) return;
this.controller.setSetting('backgroundColor', bgColorInput.value);
if (bgColorSwatch) {
bgColorSwatch.style.backgroundColor = bgColorInput.value;
}
});
}
}
// ============= Segments ============= // ============= Segments =============
private setupSegments(): void { private setupSegments(): void {
@@ -289,6 +336,11 @@ export class UIBindings {
if (btnNext) { if (btnNext) {
const handler = (e: Event) => { const handler = (e: Event) => {
e.stopPropagation(); e.stopPropagation();
const now = Date.now();
if (now - this.lastNextTime < this.NEXT_COOLDOWN) return;
this.lastNextTime = now;
this.loadNewImageFn(); this.loadNewImageFn();
}; };
this.buttonHandlers.set('btn-next', handler); this.buttonHandlers.set('btn-next', handler);
@@ -339,6 +391,9 @@ export class UIBindings {
switch (e.key.toLowerCase()) { switch (e.key.toLowerCase()) {
case 'n': case 'n':
const now = Date.now();
if (now - this.lastNextTime < this.NEXT_COOLDOWN) break;
this.lastNextTime = now;
this.loadNewImageFn(); this.loadNewImageFn();
break; break;
case 'r': case 'r':
@@ -377,8 +432,11 @@ export class UIBindings {
// ============= Zoom ============= // ============= Zoom =============
private setupZoom(): void { private setupZoom(): void {
const heroWrapper = document.querySelector('.hero-wrapper'); const workspace = document.querySelector('.ascii-workspace') || document.querySelector('.hero-wrapper');
if (!heroWrapper) return; if (!workspace) return;
// Also attach to canvas for direct interaction
const canvas = document.getElementById('ascii-canvas');
this.zoomHandlers.wheel = (e: Event) => { this.zoomHandlers.wheel = (e: Event) => {
const we = e as WheelEvent; const we = e as WheelEvent;
@@ -388,17 +446,24 @@ export class UIBindings {
this.controller.handleWheel(we); this.controller.handleWheel(we);
}; };
// Use passive: false to allow preventDefault // Use passive: false to allow preventDefault
heroWrapper.addEventListener('wheel', this.zoomHandlers.wheel, { passive: false }); workspace.addEventListener('wheel', this.zoomHandlers.wheel, { passive: false });
this.zoomHandlers.move = (e: Event) => { this.zoomHandlers.move = (e: Event) => {
this.controller.handleMouseMove(e as MouseEvent); this.controller.handleMouseMove(e as MouseEvent);
}; };
heroWrapper.addEventListener('mousemove', this.zoomHandlers.move); workspace.addEventListener('mousemove', this.zoomHandlers.move);
this.zoomHandlers.leave = () => { this.zoomHandlers.leave = () => {
this.controller.handleMouseLeave(); this.controller.handleMouseLeave();
}; };
heroWrapper.addEventListener('mouseleave', this.zoomHandlers.leave); workspace.addEventListener('mouseleave', this.zoomHandlers.leave);
// Also attach directly to canvas for better responsiveness
if (canvas) {
canvas.addEventListener('wheel', this.zoomHandlers.wheel, { passive: false });
canvas.addEventListener('mousemove', this.zoomHandlers.move);
canvas.addEventListener('mouseleave', this.zoomHandlers.leave);
}
} }
// ============= Resize ============= // ============= Resize =============
@@ -412,6 +477,54 @@ export class UIBindings {
window.addEventListener('resize', this.resizeHandler); window.addEventListener('resize', this.resizeHandler);
} }
// ============= Import =============
private setupImport(): void {
const btnImport = document.getElementById('btn-import');
const fileInput = document.getElementById('file-upload') as HTMLInputElement;
if (btnImport && fileInput) {
// Button triggers file input
const btnHandler = (e: Event) => {
e.stopPropagation();
fileInput.click();
};
this.buttonHandlers.set('btn-import', btnHandler);
btnImport.addEventListener('click', btnHandler);
// File input change
const fileHandler = async (e: Event) => {
const files = (e.target as HTMLInputElement).files;
if (files && files.length > 0) {
const file = files[0];
const url = URL.createObjectURL(file);
// Reset value so same file can be selected again
fileInput.value = '';
try {
this.controller.showLoading("LOADING IMPORT...");
// Use empty suggestions for user imports unless we want to auto-detect?
// For now keep existing settings or use defaults.
// Let's pass empty object to respect current user settings or controller defaults.
this.controller.setCurrentImage(url, {});
this.updateUI();
await this.controller.generate();
this.controller.hideLoading();
// Notify that an image was successfully imported
document.dispatchEvent(new CustomEvent('ascii-image-imported'));
} catch (err) {
console.error("Import failed:", err);
this.controller.hideLoading();
alert("Failed to load image. Please try another file.");
}
}
};
this.buttonHandlers.set('file-upload', fileHandler);
fileInput.addEventListener('change', fileHandler);
}
}
// ============= UI Sync ============= // ============= UI Sync =============
updateUI(): void { updateUI(): void {
@@ -421,7 +534,7 @@ export class UIBindings {
const settings = this.controller.getSettings(); const settings = this.controller.getSettings();
// Update sliders // 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 => { sliderIds.forEach(id => {
const input = document.getElementById(id) as HTMLInputElement | null; const input = document.getElementById(id) as HTMLInputElement | null;
if (input && settings[id] !== undefined) { if (input && settings[id] !== undefined) {
@@ -455,6 +568,31 @@ export class UIBindings {
} }
window.updateSegmentValue?.('segment-edge', edgeShort); 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;
}
}
const bgColorInput = document.getElementById('input-bg-color') as HTMLInputElement;
const bgColorSwatch = document.getElementById('bg-color-swatch-display');
if (bgColorInput && settings.backgroundColor) {
if (bgColorInput.value !== settings.backgroundColor) {
bgColorInput.value = settings.backgroundColor;
}
if (bgColorSwatch) {
bgColorSwatch.style.backgroundColor = settings.backgroundColor;
}
}
this.updateQueueDisplay(); this.updateQueueDisplay();
this.isUpdatingUI = false; this.isUpdatingUI = false;

View File

@@ -1,5 +1,4 @@
export interface RenderOptions { export interface RenderOptions {
charSetContent: string; charSetContent: string;
fontFamily?: string; fontFamily?: string;
@@ -15,6 +14,14 @@ export interface RenderOptions {
edgeMode?: number; // 0=none, 1=simple, 2=sobel, 3=canny edgeMode?: number; // 0=none, 1=simple, 2=sobel, 3=canny
dither?: number; dither?: number;
denoise?: boolean; denoise?: boolean;
sharpen?: number;
edgeThreshold?: number;
shadows?: number;
highlights?: number;
scanlines?: number;
vignette?: number;
monoColor?: string;
backgroundColor?: string;
zoom?: number; zoom?: number;
zoomCenter?: { x: number; y: number }; zoomCenter?: { x: number; y: number };
mousePos?: { x: number; y: number }; mousePos?: { x: number; y: number };
@@ -52,6 +59,10 @@ export class WebGLAsciiRenderer {
} }
this.gl = gl; this.gl = gl;
// Enable required extensions for advanced rendering
const hasDerivatives = !!gl.getExtension('OES_standard_derivatives');
const hasLod = !!gl.getExtension('EXT_shader_texture_lod');
this.program = null; this.program = null;
this.textures = {}; this.textures = {};
this.buffers = {}; this.buffers = {};
@@ -60,11 +71,11 @@ export class WebGLAsciiRenderer {
this.lastImage = null; this.lastImage = null;
this.fontFamily = "'JetBrains Mono', monospace"; this.fontFamily = "'JetBrains Mono', monospace";
this.init(); this.init(hasDerivatives, hasLod);
this.loadBlueNoiseTexture(); this.loadBlueNoiseTexture();
} }
init() { init(hasDerivatives: boolean, hasLod: boolean) {
const gl = this.gl; const gl = this.gl;
// Vertex Shader // Vertex Shader
@@ -80,6 +91,8 @@ export class WebGLAsciiRenderer {
// Fragment Shader // Fragment Shader
const fsSource = ` const fsSource = `
${hasDerivatives ? '#extension GL_OES_standard_derivatives : enable' : ''}
${hasLod ? '#extension GL_EXT_shader_texture_lod : enable' : ''}
precision mediump float; precision mediump float;
varying vec2 v_texCoord; varying vec2 v_texCoord;
@@ -87,11 +100,10 @@ export class WebGLAsciiRenderer {
uniform sampler2D u_atlas; uniform sampler2D u_atlas;
uniform sampler2D u_blueNoise; uniform sampler2D u_blueNoise;
uniform float u_charCount; uniform float u_charCount;
uniform vec2 u_charSizeUV; // Size of one char in UV space (width/texWidth, height/texHeight) uniform vec2 u_charSizeUV;
uniform vec2 u_gridSize; // cols, rows uniform vec2 u_gridSize;
uniform vec2 u_texSize; // atlas size uniform vec2 u_texSize;
// Adjustments
uniform float u_exposure; uniform float u_exposure;
uniform float u_contrast; uniform float u_contrast;
uniform float u_saturation; uniform float u_saturation;
@@ -99,11 +111,18 @@ export class WebGLAsciiRenderer {
uniform bool u_invert; uniform bool u_invert;
uniform bool u_color; uniform bool u_color;
uniform float u_overlayStrength; uniform float u_overlayStrength;
uniform int u_edgeMode; // 0=none, 1=simple, 2=sobel, 3=canny uniform int u_edgeMode;
uniform float u_dither; // Dither strength 0.0 - 1.0 uniform float u_dither;
uniform bool u_denoise; 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;
uniform vec3 u_backgroundColor;
// Zoom & Magnifier
uniform float u_zoom; uniform float u_zoom;
uniform vec2 u_zoomCenter; uniform vec2 u_zoomCenter;
uniform vec2 u_mousePos; uniform vec2 u_mousePos;
@@ -112,50 +131,41 @@ export class WebGLAsciiRenderer {
uniform bool u_showMagnifier; uniform bool u_showMagnifier;
uniform float u_aspect; uniform float u_aspect;
// Blue Noise Dithering
float blueNoise(vec2 pos) { float blueNoise(vec2 pos) {
// Map screen coordinates to texture coordinates (64x64 texture)
vec2 noiseUV = pos / 64.0; vec2 noiseUV = pos / 64.0;
float noiseVal = texture2D(u_blueNoise, noiseUV).r; float noiseVal = texture2D(u_blueNoise, noiseUV).r;
// Shift range to -0.5 to 0.5 for dither offset
return noiseVal - 0.5; return noiseVal - 0.5;
} }
vec3 adjust(vec3 color) { vec3 adjust(vec3 color) {
// Exposure
color *= u_exposure; color *= u_exposure;
// Contrast
color = (color - 0.5) * u_contrast + 0.5;
// Saturation
float luma = dot(color, vec3(0.2126, 0.7152, 0.0722)); float luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
if (u_shadows > 0.0) {
float shadowFactor = (1.0 - luma) * u_shadows;
color = color + (vec3(1.0) - color) * shadowFactor * 0.5;
}
if (u_highlights > 0.0) {
float highlightFactor = luma * u_highlights;
color = color * (1.0 - highlightFactor * 0.5);
}
color = (color - 0.5) * u_contrast + 0.5;
luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
color = mix(vec3(luma), color, u_saturation); color = mix(vec3(luma), color, u_saturation);
// Gamma
color = pow(max(color, 0.0), vec3(u_gamma)); color = pow(max(color, 0.0), vec3(u_gamma));
return clamp(color, 0.0, 1.0); return clamp(color, 0.0, 1.0);
} }
// Function to get average color from a cell using 5 samples (center + corners)
vec3 getAverageColor(vec2 cellCenterUV, vec2 cellSize) { vec3 getAverageColor(vec2 cellCenterUV, vec2 cellSize) {
vec3 sum = vec3(0.0); vec3 sum = vec3(0.0);
vec2 halfSize = cellSize * 0.25; // Sample halfway to the edge vec2 halfSize = cellSize * 0.25;
// Center
sum += texture2D(u_image, cellCenterUV).rgb; sum += texture2D(u_image, cellCenterUV).rgb;
// Corners
sum += texture2D(u_image, cellCenterUV + vec2(-halfSize.x, -halfSize.y)).rgb; sum += texture2D(u_image, cellCenterUV + vec2(-halfSize.x, -halfSize.y)).rgb;
sum += texture2D(u_image, cellCenterUV + vec2(halfSize.x, -halfSize.y)).rgb; sum += texture2D(u_image, cellCenterUV + vec2(halfSize.x, -halfSize.y)).rgb;
sum += texture2D(u_image, cellCenterUV + vec2(-halfSize.x, halfSize.y)).rgb; sum += texture2D(u_image, cellCenterUV + vec2(-halfSize.x, halfSize.y)).rgb;
sum += texture2D(u_image, cellCenterUV + vec2(halfSize.x, halfSize.y)).rgb; sum += texture2D(u_image, cellCenterUV + vec2(halfSize.x, halfSize.y)).rgb;
return sum / 5.0; return sum / 5.0;
} }
// Sobel Filter - returns gradient magnitude and direction (approx)
vec2 sobelFilter(vec2 uv, vec2 cellSize) { vec2 sobelFilter(vec2 uv, vec2 cellSize) {
vec3 t = texture2D(u_image, uv + vec2(0.0, -cellSize.y)).rgb; vec3 t = texture2D(u_image, uv + vec2(0.0, -cellSize.y)).rgb;
vec3 b = texture2D(u_image, uv + vec2(0.0, cellSize.y)).rgb; vec3 b = texture2D(u_image, uv + vec2(0.0, cellSize.y)).rgb;
@@ -165,8 +175,6 @@ export class WebGLAsciiRenderer {
vec3 tr = texture2D(u_image, uv + vec2(cellSize.x, -cellSize.y)).rgb; vec3 tr = texture2D(u_image, uv + vec2(cellSize.x, -cellSize.y)).rgb;
vec3 bl = texture2D(u_image, uv + vec2(-cellSize.x, cellSize.y)).rgb; vec3 bl = texture2D(u_image, uv + vec2(-cellSize.x, cellSize.y)).rgb;
vec3 br = texture2D(u_image, uv + vec2(cellSize.x, cellSize.y)).rgb; vec3 br = texture2D(u_image, uv + vec2(cellSize.x, cellSize.y)).rgb;
// Convert to luma
float lt = dot(t, vec3(0.299, 0.587, 0.114)); float lt = dot(t, vec3(0.299, 0.587, 0.114));
float lb = dot(b, vec3(0.299, 0.587, 0.114)); float lb = dot(b, vec3(0.299, 0.587, 0.114));
float ll = dot(l, vec3(0.299, 0.587, 0.114)); float ll = dot(l, vec3(0.299, 0.587, 0.114));
@@ -175,111 +183,82 @@ export class WebGLAsciiRenderer {
float ltr = dot(tr, vec3(0.299, 0.587, 0.114)); float ltr = dot(tr, vec3(0.299, 0.587, 0.114));
float lbl = dot(bl, vec3(0.299, 0.587, 0.114)); float lbl = dot(bl, vec3(0.299, 0.587, 0.114));
float lbr = dot(br, vec3(0.299, 0.587, 0.114)); float lbr = dot(br, vec3(0.299, 0.587, 0.114));
// Sobel kernels
// Gx: -1 0 1
// -2 0 2
// -1 0 1
float gx = (ltr + 2.0*lr + lbr) - (ltl + 2.0*ll + lbl); float gx = (ltr + 2.0*lr + lbr) - (ltl + 2.0*ll + lbl);
// Gy: -1 -2 -1
// 0 0 0
// 1 2 1
float gy = (lbl + 2.0*lb + lbr) - (ltl + 2.0*lt + ltr); float gy = (lbl + 2.0*lb + lbr) - (ltl + 2.0*lt + ltr);
float mag = sqrt(gx*gx + gy*gy); float mag = sqrt(gx*gx + gy*gy);
return vec2(mag, atan(gy, gx)); return vec2(mag, atan(gy, gx));
} }
void main() { void main() {
vec2 uv = v_texCoord; vec2 uv = v_texCoord;
// Apply global zoom
uv = (uv - u_zoomCenter) / u_zoom + u_zoomCenter; uv = (uv - u_zoomCenter) / u_zoom + u_zoomCenter;
// Magnifier logic
vec2 diff = (v_texCoord - u_mousePos); vec2 diff = (v_texCoord - u_mousePos);
diff.x *= u_aspect; diff.x *= u_aspect;
float dist = length(diff); float dist = length(diff);
bool inMagnifier = u_showMagnifier && dist < u_magnifierRadius; bool inMagnifier = u_showMagnifier && dist < u_magnifierRadius;
if (inMagnifier) { if (inMagnifier) {
// Zoom towards mouse position inside the magnifier
uv = (v_texCoord - u_mousePos) / u_magnifierZoom + u_mousePos; uv = (v_texCoord - u_mousePos) / u_magnifierZoom + u_mousePos;
// Also account for the global zoom background
uv = (uv - u_zoomCenter) / u_zoom + u_zoomCenter; uv = (uv - u_zoomCenter) / u_zoom + u_zoomCenter;
} }
// Calculate which cell we are in
vec2 cellCoords = floor(uv * u_gridSize); vec2 cellCoords = floor(uv * u_gridSize);
vec2 uvInCell = fract(uv * u_gridSize); vec2 uvInCell = fract(uv * u_gridSize);
// Sample image at the center of the cell
vec2 cellSize = 1.0 / u_gridSize; vec2 cellSize = 1.0 / u_gridSize;
vec2 sampleUV = (cellCoords + 0.5) * cellSize; vec2 sampleUV = (cellCoords + 0.5) * cellSize;
// Out of bounds check for zoomed UV
if (sampleUV.x < 0.0 || sampleUV.x > 1.0 || sampleUV.y < 0.0 || sampleUV.y > 1.0) { if (sampleUV.x < 0.0 || sampleUV.x > 1.0 || sampleUV.y < 0.0 || sampleUV.y > 1.0) {
discard; discard;
} }
vec3 color; vec3 color;
// Denoise: 3x3 box blur (applied to the base sampling if enabled)
if (u_denoise) { if (u_denoise) {
color = getAverageColor(sampleUV, cellSize * 2.0); color = getAverageColor(sampleUV, cellSize * 2.0);
} else { } else {
color = getAverageColor(sampleUV, cellSize); color = getAverageColor(sampleUV, cellSize);
} }
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) { if (u_edgeMode == 1) {
// Simple Laplacian-like
vec2 texel = cellSize; vec2 texel = cellSize;
vec3 center = color; vec3 center = color;
vec3 top = getAverageColor(sampleUV + vec2(0.0, -texel.y), cellSize); vec3 top = getAverageColor(sampleUV + vec2(0.0, -texel.y), cellSize);
vec3 bottom = getAverageColor(sampleUV + vec2(0.0, texel.y), cellSize); vec3 bottom = getAverageColor(sampleUV + vec2(0.0, texel.y), cellSize);
vec3 left = getAverageColor(sampleUV + vec2(-texel.x, 0.0), cellSize); vec3 left = getAverageColor(sampleUV + vec2(-texel.x, 0.0), cellSize);
vec3 right = getAverageColor(sampleUV + vec2(texel.x, 0.0), cellSize); vec3 right = getAverageColor(sampleUV + vec2(texel.x, 0.0), cellSize);
vec3 edges = abs(center - top) + abs(center - bottom) + abs(center - left) + abs(center - right); 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)); float edgeLum = dot(edges, vec3(0.2126, 0.7152, 0.0722));
color = mix(color, color * (1.0 - edgeLum * 2.0), 0.5); if (edgeLum > u_edgeThreshold * 0.1) {
color = mix(color, color * (1.0 - edgeLum * 2.0), 0.5);
}
} else if (u_edgeMode == 2) { } else if (u_edgeMode == 2) {
// Sobel Gradient
vec2 sobel = sobelFilter(sampleUV, cellSize); vec2 sobel = sobelFilter(sampleUV, cellSize);
float edgeStr = clamp(sobel.x * 2.0, 0.0, 1.0); 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); color = mix(color, vec3(0.0), edgeStr * 0.8);
}
} else if (u_edgeMode == 3) { } else if (u_edgeMode == 3) {
// "Canny-like" (Sobel + gradient suppression)
vec2 sobel = sobelFilter(sampleUV, cellSize); vec2 sobel = sobelFilter(sampleUV, cellSize);
float mag = sobel.x; float mag = sobel.x;
float angle = sobel.y; float angle = sobel.y;
// Non-maximum suppression (simplified)
// Check neighbors in gradient direction
vec2 dir = vec2(cos(angle), sin(angle)) * cellSize; vec2 dir = vec2(cos(angle), sin(angle)) * cellSize;
vec2 s1 = sobelFilter(sampleUV + dir, cellSize); vec2 s1 = sobelFilter(sampleUV + dir, cellSize);
vec2 s2 = sobelFilter(sampleUV - dir, cellSize); vec2 s2 = sobelFilter(sampleUV - dir, cellSize);
if (mag < s1.x || mag < s2.x || mag < u_edgeThreshold * 0.3) {
if (mag < s1.x || mag < s2.x || mag < 0.15) {
mag = 0.0; mag = 0.0;
} else { } else {
mag = 1.0; // Strong edge mag = 1.0;
} }
// Apply strong crisp edges
color = mix(color, vec3(0.0), mag); color = mix(color, vec3(0.0), mag);
} }
// Apply adjustments
color = adjust(color); color = adjust(color);
// Overlay blend-like effect (boost mid-contrast)
if (u_overlayStrength > 0.0) { if (u_overlayStrength > 0.0) {
vec3 overlay = color; vec3 overlay = color;
vec3 result; vec3 result;
@@ -291,15 +270,10 @@ export class WebGLAsciiRenderer {
color = mix(color, result, u_overlayStrength); color = mix(color, result, u_overlayStrength);
} }
// Calculate luminance
float luma = dot(color, vec3(0.2126, 0.7152, 0.0722)); float luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
// Apply Blue Noise dithering before character mapping
if (u_dither > 0.0) { if (u_dither > 0.0) {
// Use cell coordinates for stable dithering patterns
float noise = blueNoise(cellCoords); float noise = blueNoise(cellCoords);
// Scale noise by dither strength and 1/charCount
luma = luma + noise * (1.0 / u_charCount) * u_dither; luma = luma + noise * (1.0 / u_charCount) * u_dither;
luma = clamp(luma, 0.0, 1.0); luma = clamp(luma, 0.0, 1.0);
} }
@@ -308,31 +282,50 @@ export class WebGLAsciiRenderer {
luma = 1.0 - luma; luma = 1.0 - luma;
} }
// Map luma to character index
float charIndex = floor(luma * (u_charCount - 1.0) + 0.5); float charIndex = floor(luma * (u_charCount - 1.0) + 0.5);
// Sample character atlas
// Use u_charSizeUV to scale, instead of just 1.0/u_charCount
// x = charIndex * charWidthUV + uvInCell.x * charWidthUV
vec2 atlasUV = vec2( vec2 atlasUV = vec2(
(charIndex + uvInCell.x) * u_charSizeUV.x, (charIndex + uvInCell.x) * u_charSizeUV.x,
uvInCell.y * u_charSizeUV.y uvInCell.y * u_charSizeUV.y
); );
float charAlpha = texture2D(u_atlas, atlasUV).r; float charAlpha;
${hasDerivatives && hasLod ? `
vec2 gradX = dFdx(uv) * u_gridSize * u_charSizeUV * 0.5;
vec2 gradY = dFdy(uv) * u_gridSize * u_charSizeUV * 0.5;
charAlpha = texture2DGradEXT(u_atlas, atlasUV, gradX, gradY).r;
` : `
charAlpha = texture2D(u_atlas, atlasUV).r;
`}
// Loup border effect
if (u_showMagnifier) { if (u_showMagnifier) {
float edgeWidth = 0.005; float edgeWidth = 0.005;
if (dist > u_magnifierRadius - edgeWidth && dist < u_magnifierRadius) { if (dist > u_magnifierRadius - edgeWidth && dist < u_magnifierRadius) {
charAlpha = 1.0; 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);
} }
} }
vec3 finalColor = u_color ? color : vec3(1.0, 0.4039, 0.0); if (u_vignette > 0.0) {
float d = distance(uv, vec2(0.5));
float vig = smoothstep(0.8 + (1.0 - u_vignette) * 0.5, 0.2, d);
charAlpha *= vig;
}
if (u_scanlines > 0.0) {
float scan = 0.5 + 0.5 * sin(uv.y * u_gridSize.y * 3.14159 * 2.0);
float scanEffect = mix(1.0, scan, u_scanlines * 0.5);
charAlpha *= scanEffect;
}
vec3 finalColor;
if (u_color) {
finalColor = color;
} else {
finalColor = u_monoColor;
}
gl_FragColor = vec4(finalColor * charAlpha, charAlpha); gl_FragColor = vec4(mix(u_backgroundColor, finalColor, charAlpha), 1.0);
} }
`; `;
@@ -366,6 +359,8 @@ export class WebGLAsciiRenderer {
'u_exposure', 'u_contrast', 'u_saturation', 'u_gamma', 'u_exposure', 'u_contrast', 'u_saturation', 'u_gamma',
'u_invert', 'u_color', 'u_overlayStrength', 'u_edgeMode', 'u_invert', 'u_color', 'u_overlayStrength', 'u_edgeMode',
'u_dither', 'u_denoise', 'u_dither', 'u_denoise',
'u_sharpen', 'u_edgeThreshold', 'u_shadows', 'u_highlights',
'u_scanlines', 'u_vignette', 'u_monoColor', 'u_backgroundColor',
'u_zoom', 'u_zoomCenter', 'u_mousePos', 'u_zoom', 'u_zoomCenter', 'u_mousePos',
'u_magnifierRadius', 'u_magnifierZoom', 'u_showMagnifier', 'u_aspect' 'u_magnifierRadius', 'u_magnifierZoom', 'u_showMagnifier', 'u_aspect'
]; ];
@@ -416,28 +411,24 @@ export class WebGLAsciiRenderer {
this.fontFamily = fontName; this.fontFamily = fontName;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return; if (!ctx) return;
const fontSize = 32; // Higher resolution for atlas const fontSize = 32;
// Add padding to prevent bleeding
const padding = 4; const padding = 4;
ctx.font = `${fontSize}px ${fontName}`; ctx.font = `${fontSize}px ${fontName}`;
// Measure first char to get dimensions
const metrics = ctx.measureText('W'); const metrics = ctx.measureText('W');
const charContentWidth = Math.ceil(metrics.width); const charContentWidth = Math.ceil(metrics.width);
const charContentHeight = Math.ceil(fontSize * 1.2); const charContentHeight = Math.ceil(fontSize * 1.2);
// Full cell size including padding
const charWidth = charContentWidth + padding * 2; const charWidth = charContentWidth + padding * 2;
const charHeight = charContentHeight + padding * 2; const charHeight = charContentHeight + padding * 2;
const neededWidth = charWidth * charSet.length; const neededWidth = charWidth * charSet.length;
const neededHeight = charHeight; const neededHeight = charHeight;
// Calculate Next Power of Two
const nextPowerOfTwo = (v: number) => Math.pow(2, Math.ceil(Math.log(v) / Math.log(2))); const nextPowerOfTwo = (v: number) => Math.pow(2, Math.ceil(Math.log(v) / Math.log(2)));
const texWidth = nextPowerOfTwo(neededWidth); const texWidth = nextPowerOfTwo(neededWidth);
const texHeight = nextPowerOfTwo(neededHeight); const texHeight = nextPowerOfTwo(neededHeight);
@@ -451,9 +442,6 @@ export class WebGLAsciiRenderer {
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < charSet.length; i++) { for (let i = 0; i < charSet.length; i++) {
// Draw character centered in its padded cell
// x position: start of cell (i * charWidth) + padding
// y position: padding
ctx.fillText(charSet[i], i * charWidth + padding, padding); ctx.fillText(charSet[i], i * charWidth + padding, padding);
} }
@@ -467,7 +455,6 @@ export class WebGLAsciiRenderer {
gl.bindTexture(gl.TEXTURE_2D, this.textures.atlas); gl.bindTexture(gl.TEXTURE_2D, this.textures.atlas);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
// Use Mipmaps for smoother downscaling (fixes shimmering/aliasing)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -494,13 +481,12 @@ export class WebGLAsciiRenderer {
const gl = this.gl; const gl = this.gl;
const u = this.uniformLocations; const u = this.uniformLocations;
if (!this.program) return;
gl.useProgram(this.program); gl.useProgram(this.program);
// Update Atlas if needed (expensive check inside)
this.updateAtlas(options.charSetContent, options.fontFamily || 'monospace'); this.updateAtlas(options.charSetContent, options.fontFamily || 'monospace');
if (this.charAtlas) { if (this.charAtlas) {
gl.uniform1f(u['u_charCount'], this.charAtlas.count); gl.uniform1f(u['u_charCount'], this.charAtlas.count);
// Pass the normalized size of one character cell for UV mapping
gl.uniform2f(u['u_charSizeUV'], gl.uniform2f(u['u_charSizeUV'],
this.charAtlas.charWidth / this.charAtlas.width, this.charAtlas.charWidth / this.charAtlas.width,
this.charAtlas.charHeight / this.charAtlas.height this.charAtlas.charHeight / this.charAtlas.height
@@ -518,8 +504,25 @@ export class WebGLAsciiRenderer {
gl.uniform1i(u['u_edgeMode'], options.edgeMode || 0); gl.uniform1i(u['u_edgeMode'], options.edgeMode || 0);
gl.uniform1f(u['u_dither'], options.dither || 0.0); gl.uniform1f(u['u_dither'], options.dither || 0.0);
gl.uniform1i(u['u_denoise'], options.denoise ? 1 : 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);
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);
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);
const bgHex = options.backgroundColor || '#000000';
const br = parseInt(bgHex.slice(1, 3), 16) / 255.0;
const bg = parseInt(bgHex.slice(3, 5), 16) / 255.0;
const bb = parseInt(bgHex.slice(5, 7), 16) / 255.0;
gl.uniform3f(u['u_backgroundColor'], br, bg, bb);
// Zoom & Magnifier
gl.uniform1f(u['u_zoom'], options.zoom || 1.0); gl.uniform1f(u['u_zoom'], options.zoom || 1.0);
gl.uniform2f(u['u_zoomCenter'], options.zoomCenter?.x ?? 0.5, options.zoomCenter?.y ?? 0.5); gl.uniform2f(u['u_zoomCenter'], options.zoomCenter?.x ?? 0.5, options.zoomCenter?.y ?? 0.5);
gl.uniform2f(u['u_mousePos'], options.mousePos?.x ?? -1.0, options.mousePos?.y ?? -1.0); gl.uniform2f(u['u_mousePos'], options.mousePos?.x ?? -1.0, options.mousePos?.y ?? -1.0);
@@ -533,7 +536,6 @@ export class WebGLAsciiRenderer {
const gl = this.gl; const gl = this.gl;
const texture = gl.createTexture(); const texture = gl.createTexture();
if (!texture) return; if (!texture) return;
this.textures.blueNoise = texture; this.textures.blueNoise = texture;
const image = new Image(); const image = new Image();
@@ -545,27 +547,16 @@ export class WebGLAsciiRenderer {
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
this.requestRender();
}; };
} }
// Helper to trigger a redraw if we have a controller reference, otherwise just rely on next loop
private requestRender() {
// Since we don't have a direct reference to the controller here,
// and we are in a render loop managed by the controller,
// the texture will just appear on the next frame.
}
updateTexture(image: HTMLImageElement) { updateTexture(image: HTMLImageElement) {
if (this.lastImage === image && this.textures.image) return; if (this.lastImage === image && this.textures.image) return;
const gl = this.gl; const gl = this.gl;
if (this.textures.image) gl.deleteTexture(this.textures.image); if (this.textures.image) gl.deleteTexture(this.textures.image);
const texture = gl.createTexture(); const texture = gl.createTexture();
if (!texture) throw new Error('Failed to create texture'); if (!texture) throw new Error('Failed to create texture');
this.textures.image = texture; this.textures.image = texture;
gl.bindTexture(gl.TEXTURE_2D, this.textures.image); gl.bindTexture(gl.TEXTURE_2D, this.textures.image);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
@@ -578,16 +569,13 @@ export class WebGLAsciiRenderer {
draw() { draw() {
const gl = this.gl; const gl = this.gl;
const program = this.program; const program = this.program;
if (!program || !this.textures.image || !this.textures.atlas || !this.buffers.position || !this.buffers.texCoord) return; if (!program || !this.textures.image || !this.textures.atlas || !this.buffers.position || !this.buffers.texCoord) return;
gl.useProgram(program); gl.useProgram(program);
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.clearColor(0, 0, 0, 0); gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT); gl.clear(gl.COLOR_BUFFER_BIT);
// Attributes
const posLoc = gl.getAttribLocation(program, 'a_position'); const posLoc = gl.getAttribLocation(program, 'a_position');
gl.enableVertexAttribArray(posLoc); gl.enableVertexAttribArray(posLoc);
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.position); gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.position);
@@ -598,7 +586,6 @@ export class WebGLAsciiRenderer {
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.texCoord); gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.texCoord);
gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0); gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0);
// Bind Textures
const u = this.uniformLocations; const u = this.uniformLocations;
gl.uniform1i(u['u_image'], 0); gl.uniform1i(u['u_image'], 0);
gl.activeTexture(gl.TEXTURE0); gl.activeTexture(gl.TEXTURE0);
@@ -624,32 +611,14 @@ export class WebGLAsciiRenderer {
this.draw(); this.draw();
} }
/**
* Dispose of all WebGL resources.
* Call this when the renderer is no longer needed.
*/
dispose(): void { dispose(): void {
const gl = this.gl; const gl = this.gl;
if (this.textures.image) gl.deleteTexture(this.textures.image);
if (this.textures.image) { if (this.textures.atlas) gl.deleteTexture(this.textures.atlas);
gl.deleteTexture(this.textures.image); if (this.textures.blueNoise) gl.deleteTexture(this.textures.blueNoise);
} if (this.buffers.position) gl.deleteBuffer(this.buffers.position);
if (this.textures.atlas) { if (this.buffers.texCoord) gl.deleteBuffer(this.buffers.texCoord);
gl.deleteTexture(this.textures.atlas); if (this.program) gl.deleteProgram(this.program);
}
if (this.textures.blueNoise) {
gl.deleteTexture(this.textures.blueNoise);
}
if (this.buffers.position) {
gl.deleteBuffer(this.buffers.position);
}
if (this.buffers.texCoord) {
gl.deleteBuffer(this.buffers.texCoord);
}
if (this.program) {
gl.deleteProgram(this.program);
}
this.textures = {}; this.textures = {};
this.buffers = {}; this.buffers = {};
this.program = null; this.program = null;
@@ -657,29 +626,21 @@ export class WebGLAsciiRenderer {
this.lastImage = null; this.lastImage = null;
} }
// Kept for backward compatibility or specialized updates
updateMagnifier(options: MagnifierOptions) { updateMagnifier(options: MagnifierOptions) {
const gl = this.gl; const gl = this.gl;
const program = this.program; const program = this.program;
if (!program) return; if (!program) return;
gl.useProgram(program); gl.useProgram(program);
// Only update magnifier-related uniforms (using cached locations)
const u = this.uniformLocations; const u = this.uniformLocations;
const mousePos = options.mousePos ?? { x: -1, y: -1 }; const mousePos = options.mousePos ?? { x: -1, y: -1 };
gl.uniform2f(u['u_mousePos'], mousePos.x, mousePos.y); gl.uniform2f(u['u_mousePos'], mousePos.x, mousePos.y);
gl.uniform1f(u['u_magnifierRadius'], options.magnifierRadius || 0.03); gl.uniform1f(u['u_magnifierRadius'], options.magnifierRadius || 0.03);
gl.uniform1f(u['u_magnifierZoom'], options.magnifierZoom || 2.0); gl.uniform1f(u['u_magnifierZoom'], options.magnifierZoom || 2.0);
gl.uniform1i(u['u_showMagnifier'], options.showMagnifier ? 1 : 0); gl.uniform1i(u['u_showMagnifier'], options.showMagnifier ? 1 : 0);
if (options.zoom !== undefined) { if (options.zoom !== undefined) {
gl.uniform1f(u['u_zoom'], options.zoom || 1.0); gl.uniform1f(u['u_zoom'], options.zoom || 1.0);
gl.uniform2f(u['u_zoomCenter'], options.zoomCenter?.x ?? 0.5, options.zoomCenter?.y ?? 0.5); gl.uniform2f(u['u_zoomCenter'], options.zoomCenter?.x ?? 0.5, options.zoomCenter?.y ?? 0.5);
} }
// We can just call draw here as it's lightweight
this.draw(); this.draw();
} }
} }

View File

@@ -1,6 +1,8 @@
:root { :root {
--bg-color: #000000; --bg-color: #000000;
--text-color: #FF6700; --text-color: #FFFFFF;
--accent-color: #FF6700;
/* Safety Orange */
--font-mono: 'JetBrains Mono', monospace; --font-mono: 'JetBrains Mono', monospace;
} }
@@ -28,7 +30,7 @@ button {
button:hover { button:hover {
opacity: 1; opacity: 1;
background: rgba(255, 103, 0, 0.1); background: color-mix(in srgb, var(--accent-color), transparent 90%);
} }
a { a {

View File

@@ -2,8 +2,11 @@
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2020",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "node", "moduleResolution": "bundler",
"lib": ["ES2020", "DOM"], "lib": [
"ES2020",
"DOM"
],
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
@@ -16,6 +19,11 @@
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src" "rootDir": "./src"
}, },
"include": ["src/**/*"], "include": [
"exclude": ["node_modules", "dist"] "src/**/*"
} ],
"exclude": [
"node_modules",
"dist"
]
}