Compare commits
15 Commits
8dae3578b1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d014e4f1b | ||
|
|
55ec01e3cd | ||
|
|
d5bac98b76 | ||
|
|
8cfa39a4d4 | ||
|
|
a79f05c043 | ||
|
|
f4a0e2a82b | ||
|
|
cabf963e94 | ||
|
|
2cdc9bd0b6 | ||
|
|
5cd52f2785 | ||
|
|
a9d2c43bfd | ||
|
|
73a6681ceb | ||
|
|
faa9609254 | ||
|
|
bb4ca0610d | ||
|
|
28bde53707 | ||
|
|
36cb793048 |
15
Caddyfile
Normal file
15
Caddyfile
Normal 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
|
||||
}
|
||||
}
|
||||
17
Dockerfile
17
Dockerfile
@@ -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
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/package*.json ./
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "4321:4321"
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
command: npm run dev -- --host
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
|
||||
caddy:
|
||||
image: hello-world
|
||||
entrypoint: ["true"]
|
||||
restart: "no"
|
||||
ports: []
|
||||
volumes: []
|
||||
depends_on: []
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "4321:4321"
|
||||
restart: always
|
||||
environment:
|
||||
- PORT=4321
|
||||
- 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
10
package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.6",
|
||||
"@astrojs/node": "^9.5.2",
|
||||
"@lucide/astro": "^0.563.0",
|
||||
"astro": "^5.17.1",
|
||||
"gifuct-js": "^2.1.2",
|
||||
"pngjs": "^7.0.0",
|
||||
@@ -1181,6 +1182,15 @@
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.6",
|
||||
"@astrojs/node": "^9.5.2",
|
||||
"@lucide/astro": "^0.563.0",
|
||||
"astro": "^5.17.1",
|
||||
"gifuct-js": "^2.1.2",
|
||||
"pngjs": "^7.0.0",
|
||||
|
||||
4
public/favicon.svg
Normal file
4
public/favicon.svg
Normal 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
4
public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://syntaxbullet.com/sitemap-index.xml
|
||||
2145
src/components/ControlPanel.astro
Normal file
2145
src/components/ControlPanel.astro
Normal file
File diff suppressed because it is too large
Load Diff
@@ -55,8 +55,11 @@ const { pathname } = Astro.url;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
background: #000;
|
||||
border-bottom: 1px solid var(--text-color);
|
||||
background: rgba(10, 10, 10, 0.8);
|
||||
-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;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@@ -81,30 +84,32 @@ const { pathname } = Astro.url;
|
||||
align-items: center;
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
border-right: 1px solid rgba(255, 103, 0, 0.2);
|
||||
transition: all 0.1s;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: var(--text-color);
|
||||
color: #000;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-link:hover .nav-index {
|
||||
color: #000;
|
||||
color: #fff;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.status-item.active {
|
||||
background: var(--text-color);
|
||||
color: #000;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
box-shadow: inset 0 -2px 0 var(--text-color);
|
||||
}
|
||||
|
||||
.status-item.brand {
|
||||
background: rgba(255, 103, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
font-weight: 900;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.nav-index {
|
||||
@@ -122,7 +127,7 @@ const { pathname } = Astro.url;
|
||||
|
||||
.status-right .status-item {
|
||||
border-right: none;
|
||||
border-left: 1px solid rgba(255, 103, 0, 0.2);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.prefix {
|
||||
@@ -134,6 +139,7 @@ const { pathname } = Astro.url;
|
||||
#system-status {
|
||||
color: #0f0;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
};
|
||||
|
||||
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 desc = target.getAttribute("data-tooltip-desc");
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
abbr?: string;
|
||||
shortcut?: string;
|
||||
variant?: "default" | "primary" | "subtle";
|
||||
title?: string;
|
||||
@@ -11,6 +12,7 @@ interface Props {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
abbr,
|
||||
shortcut,
|
||||
variant = "default",
|
||||
title = "",
|
||||
@@ -26,74 +28,103 @@ const {
|
||||
data-tooltip-desc={description}
|
||||
>
|
||||
{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>
|
||||
|
||||
<style>
|
||||
.tui-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: none;
|
||||
border: 1px solid rgba(255, 103, 0, 0.4);
|
||||
color: var(--text-color);
|
||||
gap: 6px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
opacity: 0.8;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
padding: 3px 10px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
transition: all 0.15s;
|
||||
transition: all 0.2s;
|
||||
user-select: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.tui-button:hover {
|
||||
color: var(--accent-color);
|
||||
opacity: 1;
|
||||
border-color: var(--text-color);
|
||||
background: rgba(255, 103, 0, 0.1);
|
||||
border-color: var(--accent-color);
|
||||
background: color-mix(in srgb, var(--accent-color), transparent 95%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.tui-button:active {
|
||||
background: rgba(255, 103, 0, 0.2);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.tui-button--primary {
|
||||
border-color: var(--text-color);
|
||||
background: rgba(255, 103, 0, 0.1);
|
||||
background: var(--accent-color);
|
||||
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 {
|
||||
background: var(--text-color);
|
||||
color: #000;
|
||||
background: color-mix(in srgb, var(--accent-color), black 10%);
|
||||
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 {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.tui-button-shortcut {
|
||||
font-size: 9px;
|
||||
opacity: 0.6;
|
||||
padding: 0 3px;
|
||||
border: 1px solid currentColor;
|
||||
line-height: 1.2;
|
||||
border-radius: 2px;
|
||||
opacity: 0.5;
|
||||
padding: 1px 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
line-height: 1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.tui-button:hover .tui-button-shortcut {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tui-button--primary:hover .tui-button-shortcut {
|
||||
border-color: #000;
|
||||
opacity: 0.8;
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
abbr?: string;
|
||||
options: string[];
|
||||
value?: string;
|
||||
title?: string;
|
||||
@@ -11,6 +12,7 @@ interface Props {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
abbr,
|
||||
options,
|
||||
value = options[0],
|
||||
title = "",
|
||||
@@ -24,7 +26,10 @@ const {
|
||||
data-tooltip-title={title}
|
||||
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}>
|
||||
{
|
||||
options.map((opt) => (
|
||||
@@ -47,33 +52,53 @@ const {
|
||||
.tui-segment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tui-segment-label {
|
||||
min-width: 3ch;
|
||||
font-weight: bold;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
color: #fff;
|
||||
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 {
|
||||
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 {
|
||||
background: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid rgba(255, 103, 0, 0.2);
|
||||
color: var(--text-color);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
opacity: 0.6;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
padding: 2px 8px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition: all 0.15s;
|
||||
transition: all 0.2s;
|
||||
min-width: 3ch;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -83,24 +108,26 @@ const {
|
||||
}
|
||||
|
||||
.tui-segment-option:hover {
|
||||
opacity: 0.8;
|
||||
background: rgba(255, 103, 0, 0.1);
|
||||
color: var(--accent-color);
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, var(--accent-color), transparent 95%);
|
||||
}
|
||||
|
||||
.tui-segment-option.active {
|
||||
background: var(--text-color);
|
||||
color: #000;
|
||||
background: var(--accent-color);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
opacity: 1;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Hover the whole group */
|
||||
.tui-segment:hover .tui-segment-label {
|
||||
opacity: 1;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.tui-segment:hover .tui-segment-options {
|
||||
border-color: var(--text-color);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
abbr?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
@@ -13,6 +14,7 @@ interface Props {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
abbr,
|
||||
min = 0,
|
||||
max = 5,
|
||||
step = 0.1,
|
||||
@@ -28,10 +30,18 @@ const segments = 12;
|
||||
<div
|
||||
class="tui-slider"
|
||||
data-slider-id={id}
|
||||
data-default-value={value}
|
||||
data-tooltip-title={title}
|
||||
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-visual">
|
||||
<span class="tui-slider-track" data-for={id}>
|
||||
@@ -56,22 +66,50 @@ const segments = 12;
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
<span class="tui-slider-value" id={`val-${id}`}>{value.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tui-slider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
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 {
|
||||
min-width: 3ch;
|
||||
font-weight: bold;
|
||||
font-weight: 700;
|
||||
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 {
|
||||
@@ -89,22 +127,54 @@ const segments = 12;
|
||||
|
||||
.tui-slider-track {
|
||||
display: flex;
|
||||
letter-spacing: -1px;
|
||||
font-family: monospace;
|
||||
letter-spacing: 2px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.tui-slider-segment {
|
||||
transition: color 0.1s;
|
||||
color: rgba(255, 103, 0, 0.25);
|
||||
color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.tui-slider-segment.filled {
|
||||
color: var(--text-color);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.tui-slider-segment.thumb {
|
||||
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 {
|
||||
@@ -120,27 +190,27 @@ const segments = 12;
|
||||
}
|
||||
|
||||
.tui-slider-value {
|
||||
min-width: 3ch;
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
opacity: 0.9;
|
||||
font-weight: 400;
|
||||
opacity: 0.8;
|
||||
font-family: var(--font-mono);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
/* Hover effect */
|
||||
.tui-slider:hover .tui-slider-label {
|
||||
opacity: 1;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.tui-slider:hover .tui-slider-value {
|
||||
opacity: 1;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.tui-slider:hover .tui-slider-segment {
|
||||
color: rgba(255, 103, 0, 0.4);
|
||||
}
|
||||
|
||||
.tui-slider:hover .tui-slider-segment.filled {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.tui-slider:hover .tui-slider-segment.thumb {
|
||||
color: #fff;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -157,6 +227,9 @@ const segments = 12;
|
||||
const valueDisplay = sliderContainer.querySelector(
|
||||
".tui-slider-value",
|
||||
) as HTMLElement;
|
||||
const defaultValue = parseFloat(
|
||||
sliderContainer.getAttribute("data-default-value") || "0",
|
||||
);
|
||||
|
||||
if (!input || !track || !valueDisplay) return;
|
||||
|
||||
@@ -187,6 +260,10 @@ const segments = 12;
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
abbr?: string;
|
||||
checked?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
@@ -10,6 +11,7 @@ interface Props {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
abbr,
|
||||
checked = false,
|
||||
title = "",
|
||||
description = "",
|
||||
@@ -24,7 +26,10 @@ const {
|
||||
data-tooltip-title={title}
|
||||
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>
|
||||
|
||||
<style>
|
||||
@@ -32,35 +37,55 @@ const {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: 1px solid rgba(255, 103, 0, 0.3);
|
||||
color: var(--text-color);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
opacity: 0.8;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
padding: 4px 12px;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition: all 0.15s;
|
||||
transition: all 0.2s;
|
||||
user-select: none;
|
||||
min-width: 3ch;
|
||||
text-align: center;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.tui-toggle:hover {
|
||||
opacity: 0.8;
|
||||
background: rgba(255, 103, 0, 0.1);
|
||||
color: var(--accent-color);
|
||||
opacity: 1;
|
||||
border-color: var(--accent-color);
|
||||
background: color-mix(in srgb, var(--accent-color), transparent 95%);
|
||||
}
|
||||
|
||||
.tui-toggle.active {
|
||||
background: var(--text-color);
|
||||
color: #000;
|
||||
background: var(--accent-color);
|
||||
color: #fff;
|
||||
opacity: 1;
|
||||
font-weight: bold;
|
||||
border-color: var(--text-color);
|
||||
border-color: var(--accent-color);
|
||||
font-weight: 700;
|
||||
box-shadow: 0 0 15px
|
||||
color-mix(in srgb, var(--accent-color), transparent 80%);
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
|
||||
20
src/content/blog/coming-soon.md
Normal file
20
src/content/blog/coming-soon.md
Normal 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](/).
|
||||
@@ -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.
|
||||
@@ -1,6 +1,4 @@
|
||||
---
|
||||
import Navbar from '../components/Navbar.astro';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
showScroll?: boolean;
|
||||
@@ -14,30 +12,33 @@ const { title, showScroll = false } = Astro.props;
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@100..800&display=swap"
|
||||
rel="stylesheet">
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>{title}</title>
|
||||
|
||||
<style is:global>
|
||||
@import "../styles/global.css";
|
||||
</style>
|
||||
|
||||
<style define:vars={{ overflow: showScroll ? 'auto' : 'hidden' }}>
|
||||
<style define:vars={{ overflow: showScroll ? "auto" : "hidden" }}>
|
||||
body {
|
||||
overflow: var(--overflow);
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
padding-top: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<Navbar />
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
362
src/pages/ascii.astro
Normal file
362
src/pages/ascii.astro
Normal 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>
|
||||
@@ -16,117 +16,240 @@ if (!entry) {
|
||||
const { Content } = await entry.render();
|
||||
---
|
||||
|
||||
<Layout title={entry.data.title} showScroll={true}>
|
||||
<main>
|
||||
<article>
|
||||
<section class="h-entry">
|
||||
<header>
|
||||
<Layout title={entry.data.title}>
|
||||
<div class="split-layout">
|
||||
<main class="content-workspace">
|
||||
<div class="content-container">
|
||||
<article class="h-entry">
|
||||
<header class="post-header">
|
||||
<a href="/blog" class="back-link">
|
||||
← Back to Blog
|
||||
</a>
|
||||
|
||||
<h1 class="p-name">{entry.data.title}</h1>
|
||||
|
||||
<div class="metadata">
|
||||
<time
|
||||
class="dt-published"
|
||||
datetime={entry.data.pubDate.toISOString()}
|
||||
>
|
||||
<span class="dt-published">
|
||||
{entry.data.pubDate.toISOString().slice(0, 10)}
|
||||
</time>
|
||||
</span>
|
||||
{
|
||||
entry.data.updatedDate && (
|
||||
<div class="last-updated">
|
||||
Last updated on{" "}
|
||||
<time>
|
||||
<span class="updated-date">
|
||||
• Updated:{" "}
|
||||
{entry.data.updatedDate
|
||||
.toISOString()
|
||||
.slice(0, 10)}
|
||||
</time>
|
||||
</div>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
</header>
|
||||
|
||||
<div class="e-content">
|
||||
<Content />
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
main {
|
||||
width: calc(100% - 2em);
|
||||
/* Split Layout */
|
||||
.split-layout {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
/* Content Workspace */
|
||||
.content-workspace {
|
||||
flex-grow: 1;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.content-container {
|
||||
max-width: 800px;
|
||||
margin: 0;
|
||||
padding: 2em;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 4rem 2rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 2rem;
|
||||
/* Header Styling */
|
||||
.post-header {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
header a {
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
font-family: var(--font-mono);
|
||||
margin-bottom: 1.5rem;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
header a:hover {
|
||||
opacity: 1;
|
||||
.back-link:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2em;
|
||||
margin: 0.25em 0 0;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-top: 1px solid var(--text-color);
|
||||
opacity: 0.3;
|
||||
margin: 1rem 0;
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1.2;
|
||||
color: #fff;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.metadata {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Markdown Styles */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Content Styling (Markdown) */
|
||||
.e-content {
|
||||
line-height: 1.6;
|
||||
font-family: var(--font-mono); /* Keep vibe */
|
||||
font-size: 1rem;
|
||||
line-height: 1.8;
|
||||
font-size: 1.1rem;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif; /* Clean reading font */
|
||||
}
|
||||
|
||||
/* Typography Overrides */
|
||||
.e-content :global(h1),
|
||||
.e-content :global(h2),
|
||||
.e-content :global(h3),
|
||||
.e-content :global(h4) {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-color);
|
||||
font-weight: bold;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
color: #fff;
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.3;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.e-content :global(h2) {
|
||||
font-size: 1.8rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
.e-content :global(h3) {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.e-content :global(p) {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.e-content :global(a) {
|
||||
color: var(--text-color);
|
||||
text-decoration: underline;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.5);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.e-content :global(a:hover) {
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
|
||||
.e-content :global(ul),
|
||||
.e-content :global(ol) {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.e-content :global(li) {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.e-content :global(blockquote) {
|
||||
border-left: 4px solid rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
margin: 2rem 0;
|
||||
padding: 1rem 1.5rem;
|
||||
font-style: italic;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.e-content :global(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px; /* Softer corners */
|
||||
margin: 2rem 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Code Blocks */
|
||||
.e-content :global(pre) {
|
||||
background: #111 !important; /* Force override */
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px; /* Softer corners */
|
||||
padding: 1.5rem;
|
||||
margin: 2rem 0;
|
||||
overflow-x: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.e-content :global(code) {
|
||||
background: #111;
|
||||
padding: 2px 5px;
|
||||
border-radius: 2px;
|
||||
font-family: var(--font-mono);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.e-content :global(pre) {
|
||||
background: #111;
|
||||
padding: 1rem;
|
||||
border: 1px solid #333;
|
||||
overflow-x: auto;
|
||||
.e-content :global(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.split-layout {
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.content-workspace {
|
||||
height: auto;
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,92 +8,214 @@ const posts = (await getCollection("blog")).sort(
|
||||
);
|
||||
---
|
||||
|
||||
<Layout title="System Logs" showScroll={true}>
|
||||
<main>
|
||||
<section>
|
||||
<ul>
|
||||
<Layout title="System Logs">
|
||||
<div class="split-layout">
|
||||
<main class="content-workspace">
|
||||
<div class="content-container">
|
||||
<header class="page-header">
|
||||
<a href="/" class="back-link"> ← Back to Home </a>
|
||||
<h1>Blog Articles</h1>
|
||||
<div class="divider"></div>
|
||||
</header>
|
||||
|
||||
<ul class="post-list">
|
||||
{
|
||||
posts.map((post: any) => (
|
||||
<li>
|
||||
<a href={`/blog/${post.slug}/`}>
|
||||
<a
|
||||
href={`/blog/${post.slug}/`}
|
||||
class="post-link"
|
||||
>
|
||||
<div class="post-meta">
|
||||
<span class="date">
|
||||
[
|
||||
{post.data.pubDate
|
||||
.toISOString()
|
||||
.slice(0, 10)}
|
||||
]
|
||||
</span>
|
||||
<span class="title">{post.data.title}</span>
|
||||
</div>
|
||||
<div class="post-info">
|
||||
<span class="title">
|
||||
{post.data.title}
|
||||
</span>
|
||||
<span class="desc">
|
||||
// {post.data.description}
|
||||
{post.data.description}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<p>END OF STREAM</p>
|
||||
<p>© {new Date().getFullYear()} Syntaxbullet</p>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
main {
|
||||
width: 960px;
|
||||
max-width: calc(100% - 2em);
|
||||
margin: 0 auto;
|
||||
padding: 2em 0;
|
||||
/* Split Layout - Consistent with Homepage */
|
||||
.split-layout {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
ul {
|
||||
/* Content Workspace (Right Side) */
|
||||
.content-workspace {
|
||||
flex-grow: 1;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 1rem;
|
||||
border-left: 2px solid transparent;
|
||||
transition: border-left-color 0.2s;
|
||||
}
|
||||
|
||||
li:hover {
|
||||
border-left-color: var(--text-color);
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
.post-link {
|
||||
display: flex;
|
||||
flex-direction: column; /* Mobile first */
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
padding: 5px 10px;
|
||||
color: var(--text-color);
|
||||
font-family: var(--font-mono);
|
||||
padding: 1.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: rgba(255, 103, 0, 0.6);
|
||||
margin-right: 1rem;
|
||||
.post-link:hover {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.post-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
margin-right: 1rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: rgba(255, 103, 0, 0.4);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
a:hover .title {
|
||||
text-decoration: underline;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 4rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
text-align: center;
|
||||
opacity: 0.3;
|
||||
opacity: 0.5;
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--font-mono);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (min-width: 768px) {
|
||||
.post-link {
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
min-width: 120px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.split-layout {
|
||||
flex-direction: column;
|
||||
overflow: hidden; /* Prevent body scroll, use inner scrolling */
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.content-workspace {
|
||||
height: auto;
|
||||
flex-grow: 1; /* Fill remaining space */
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,701 +1,227 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import TuiSlider from "../components/TuiSlider.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";
|
||||
import { Zap, FileText, Mail } from "@lucide/astro";
|
||||
---
|
||||
|
||||
<Layout title="Syntaxbullet - Digital Wizard">
|
||||
<div class="hero-wrapper">
|
||||
<!-- Background Layer: ASCII Art -->
|
||||
<div class="ascii-layer">
|
||||
<div id="loading">Loading...</div>
|
||||
<pre id="ascii-result">Preparing art...</pre>
|
||||
<canvas id="ascii-canvas"></canvas>
|
||||
<Layout title="Syntaxbullet - Full Stack Engineer">
|
||||
<div class="home-container">
|
||||
<main class="home-content">
|
||||
<div class="brand-group">
|
||||
<h1 class="brand-title">SYNTAXBULLET</h1>
|
||||
<div class="brand-subtitle">
|
||||
FULL STACK ENGINEER
|
||||
<span class="muted">|</span>
|
||||
CREATIVE TECHNOLOGIST
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Foreground Layer: Content -->
|
||||
<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 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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<style>
|
||||
/* Layout Wrapper */
|
||||
.hero-wrapper {
|
||||
position: relative;
|
||||
.home-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
/* BACKGROUND LAYER */
|
||||
.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%;
|
||||
.home-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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 {
|
||||
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 {
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
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;
|
||||
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 */
|
||||
.control-group {
|
||||
.social-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.tui-btn {
|
||||
background: none;
|
||||
border: 1px solid #333;
|
||||
color: var(--text-color);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
font-weight: bold;
|
||||
opacity: 0.7;
|
||||
.social-links a {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
transition: all 0.2s;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.tui-btn:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--text-color);
|
||||
background: rgba(255, 103, 0, 0.1);
|
||||
.social-links a:hover {
|
||||
color: #fff;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.tui-val {
|
||||
min-width: 3ch;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 900px) {
|
||||
.sliders-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#tui-controls {
|
||||
@media (max-width: 600px) {
|
||||
.nav-links {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.control-panel-divider {
|
||||
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 {
|
||||
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;
|
||||
.nav-link {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.control-panel-section {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font-size: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -59,6 +59,30 @@ export class AsciiController {
|
||||
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
|
||||
private onSettingsChange?: () => void;
|
||||
|
||||
@@ -77,6 +101,43 @@ export class AsciiController {
|
||||
}
|
||||
|
||||
this.startRenderLoop();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
// Resize handling
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
// Debounce resize
|
||||
if (this.animFrameId) {
|
||||
// We are in a loop, just toggle a flag or call safely
|
||||
this.handleResize();
|
||||
}
|
||||
});
|
||||
if (this.canvas.parentElement) {
|
||||
this.resizeObserver.observe(this.canvas.parentElement);
|
||||
}
|
||||
|
||||
// Touch events
|
||||
this.canvas.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: false });
|
||||
this.canvas.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false });
|
||||
this.canvas.addEventListener('touchend', this.handleTouchEnd.bind(this));
|
||||
|
||||
// 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 {
|
||||
@@ -92,7 +153,15 @@ export class AsciiController {
|
||||
edgeMode: 0,
|
||||
overlayStrength: 0.3,
|
||||
resolution: 1.0,
|
||||
charSet: 'standard'
|
||||
charSet: 'standard',
|
||||
sharpen: 0,
|
||||
edgeThreshold: 0.5,
|
||||
shadows: 0,
|
||||
highlights: 0,
|
||||
scanlines: 0,
|
||||
vignette: 0,
|
||||
monoColor: '#ffffff',
|
||||
backgroundColor: '#000000'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -213,7 +282,15 @@ export class AsciiController {
|
||||
overlayStrength: suggestions.overlayStrength ?? this.settings.overlayStrength,
|
||||
charSet: validCharSet,
|
||||
resolution: this.settings.resolution,
|
||||
color: this.settings.color
|
||||
color: this.settings.color,
|
||||
sharpen: suggestions.sharpen ?? this.settings.sharpen,
|
||||
edgeThreshold: suggestions.edgeThreshold ?? this.settings.edgeThreshold,
|
||||
shadows: suggestions.shadows ?? this.settings.shadows,
|
||||
highlights: suggestions.highlights ?? this.settings.highlights,
|
||||
scanlines: suggestions.scanlines ?? this.settings.scanlines,
|
||||
vignette: suggestions.vignette ?? this.settings.vignette,
|
||||
monoColor: suggestions.monoColor ?? this.settings.monoColor,
|
||||
backgroundColor: suggestions.backgroundColor ?? this.settings.backgroundColor
|
||||
};
|
||||
|
||||
this.onSettingsChange?.();
|
||||
@@ -270,13 +347,20 @@ export class AsciiController {
|
||||
async calculateGrid(): Promise<GridCache | undefined> {
|
||||
if (!this.currentImgUrl) return;
|
||||
|
||||
const parent = this.canvas.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
const fontAspectRatio = 0.55;
|
||||
const marginRatio = 0.2;
|
||||
const screenW = window.innerWidth;
|
||||
const marginRatio = 0.05; // Reduced margin for container fit
|
||||
let screenW = parent.clientWidth;
|
||||
if (screenW <= 0) screenW = window.innerWidth || 1000;
|
||||
const availW = screenW * (1 - marginRatio);
|
||||
|
||||
let widthCols = Math.floor(availW / 6);
|
||||
widthCols = Math.floor(widthCols * this.settings.resolution);
|
||||
|
||||
// Cap grid resolution on mobile specifically if needed,
|
||||
// but current logic is mostly fine as long as resolution slider is manageable.
|
||||
widthCols = Math.max(10, Math.min(1000, widthCols));
|
||||
|
||||
const imgEl = await this.resolveImage(this.currentImgUrl);
|
||||
@@ -337,12 +421,24 @@ export class AsciiController {
|
||||
}
|
||||
|
||||
private updateCanvasSize(): void {
|
||||
const parent = this.canvas.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
const fontAspectRatio = 0.55;
|
||||
const gridAspect = (this.cachedGrid.widthCols * fontAspectRatio) / this.cachedGrid.heightRows;
|
||||
const screenW = window.innerWidth;
|
||||
const screenH = window.innerHeight;
|
||||
const maxW = screenW * 0.95;
|
||||
const maxH = screenH * 0.95;
|
||||
// Safeguard against 0 height or NaNs
|
||||
const heightRows = Math.max(1, Math.floor(this.cachedGrid.heightRows));
|
||||
const widthCols = Math.max(1, this.cachedGrid.widthCols);
|
||||
const gridAspect = (widthCols * fontAspectRatio) / heightRows;
|
||||
|
||||
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;
|
||||
if (gridAspect > maxW / maxH) {
|
||||
@@ -353,9 +449,17 @@ export class AsciiController {
|
||||
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.height = `${finalH}px`;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// Cap DPR to improve mobile performance
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2.0);
|
||||
this.canvas.width = finalW * dpr;
|
||||
this.canvas.height = finalH * dpr;
|
||||
}
|
||||
@@ -366,9 +470,15 @@ export class AsciiController {
|
||||
this.canvas.style.display = 'block';
|
||||
this.canvas.style.opacity = '1';
|
||||
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 {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
@@ -394,17 +504,97 @@ export class AsciiController {
|
||||
}
|
||||
|
||||
handleMouseMove(e: MouseEvent): void {
|
||||
if ('ontouchstart' in window && (e as any).sourceCapabilities?.firesTouchEvents) return; // Ignore simulated mouse events
|
||||
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const mx = (e.clientX - rect.left) / rect.width;
|
||||
const my = (e.clientY - rect.top) / rect.height;
|
||||
|
||||
this.zoomState.mousePos = { x: mx, y: my };
|
||||
const wasShowing = this.zoomState.showMagnifier;
|
||||
this.zoomState.showMagnifier = mx >= 0 && mx <= 1 && my >= 0 && my <= 1;
|
||||
// Only show magnifier if not zoomed out completely
|
||||
this.zoomState.showMagnifier = mx >= 0 && mx <= 1 && my >= 0 && my <= 1 && !('ontouchstart' in window);
|
||||
|
||||
if (this.zoomState.showMagnifier || wasShowing) {
|
||||
this.requestRender('uniforms');
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -412,6 +602,127 @@ export class AsciiController {
|
||||
this.zoomState.showMagnifier = false;
|
||||
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 =============
|
||||
@@ -492,6 +803,17 @@ export class AsciiController {
|
||||
if (this.animFrameId !== null) {
|
||||
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 = null;
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export class AsciiExporter {
|
||||
heightRows: number
|
||||
): string {
|
||||
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 charCount = charSet.length;
|
||||
|
||||
@@ -92,7 +92,7 @@ export class AsciiExporter {
|
||||
const b = pixels[i + 2];
|
||||
|
||||
// 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
|
||||
// Note: For color mode, we might want to keep original color
|
||||
@@ -101,13 +101,11 @@ export class AsciiExporter {
|
||||
|
||||
// Adjust color for display
|
||||
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
|
||||
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;
|
||||
|
||||
const charIndex = Math.floor(finalLuma * (charCount - 1) + 0.5);
|
||||
@@ -116,7 +114,7 @@ export class AsciiExporter {
|
||||
// Escape HTML
|
||||
const safeChar = char === '<' ? '<' : char === '>' ? '>' : char === '&' ? '&' : 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 += "<br>";
|
||||
@@ -177,4 +175,11 @@ export class AsciiExporter {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,14 @@ export interface AsciiOptions {
|
||||
denoise?: boolean;
|
||||
fontAspectRatio?: number;
|
||||
onProgress?: (progress: number) => void;
|
||||
sharpen?: number;
|
||||
edgeThreshold?: number;
|
||||
shadows?: number;
|
||||
highlights?: number;
|
||||
scanlines?: number;
|
||||
vignette?: number;
|
||||
monoColor?: string;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export interface AsciiResult {
|
||||
@@ -55,6 +63,14 @@ export interface AsciiSettings {
|
||||
overlayStrength: number;
|
||||
resolution: number;
|
||||
charSet: CharSetKey;
|
||||
sharpen: number;
|
||||
edgeThreshold: number;
|
||||
shadows: number;
|
||||
highlights: number;
|
||||
scanlines: number;
|
||||
vignette: number;
|
||||
monoColor: string;
|
||||
backgroundColor: string;
|
||||
}
|
||||
|
||||
// ============= Constants =============
|
||||
|
||||
@@ -29,6 +29,8 @@ export class UIBindings {
|
||||
private queue: ImageQueue;
|
||||
private loadNewImageFn: () => Promise<void>;
|
||||
private isUpdatingUI = false;
|
||||
private lastNextTime = 0;
|
||||
private readonly NEXT_COOLDOWN = 1000; // 1 second cooldown
|
||||
|
||||
|
||||
// Event Handlers implementation references
|
||||
@@ -62,11 +64,13 @@ export class UIBindings {
|
||||
init(): void {
|
||||
this.setupSliders();
|
||||
this.setupToggles();
|
||||
this.setupColorInput();
|
||||
this.setupSegments();
|
||||
this.setupButtons();
|
||||
this.setupKeyboard();
|
||||
this.setupZoom();
|
||||
this.setupResize();
|
||||
this.setupImport();
|
||||
|
||||
// Periodic queue update
|
||||
this.queueInterval = window.setInterval(() => this.updateQueueDisplay(), 1000);
|
||||
@@ -80,7 +84,7 @@ export class UIBindings {
|
||||
}
|
||||
|
||||
// Cleanup Sliders
|
||||
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither'] as const;
|
||||
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither', 'sharpen', 'edgeThreshold', 'shadows', 'highlights'] as const;
|
||||
sliderIds.forEach(id => {
|
||||
const input = document.getElementById(id) as HTMLInputElement | null;
|
||||
const handler = this.sliderHandlers.get(id);
|
||||
@@ -126,11 +130,17 @@ export class UIBindings {
|
||||
}
|
||||
|
||||
// Cleanup Zoom
|
||||
const heroWrapper = document.querySelector('.hero-wrapper');
|
||||
if (heroWrapper) {
|
||||
if (this.zoomHandlers.wheel) heroWrapper.removeEventListener('wheel', this.zoomHandlers.wheel);
|
||||
if (this.zoomHandlers.move) heroWrapper.removeEventListener('mousemove', this.zoomHandlers.move);
|
||||
if (this.zoomHandlers.leave) heroWrapper.removeEventListener('mouseleave', this.zoomHandlers.leave);
|
||||
const workspace = document.querySelector('.ascii-workspace') || document.querySelector('.hero-wrapper');
|
||||
const canvas = document.getElementById('ascii-canvas');
|
||||
if (workspace) {
|
||||
if (this.zoomHandlers.wheel) workspace.removeEventListener('wheel', this.zoomHandlers.wheel);
|
||||
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 = {};
|
||||
|
||||
@@ -142,19 +152,26 @@ export class UIBindings {
|
||||
}
|
||||
|
||||
// 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 handler = this.buttonHandlers.get(id);
|
||||
if (el && 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 =============
|
||||
|
||||
private setupSliders(): void {
|
||||
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither'] as const;
|
||||
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither', 'sharpen', 'edgeThreshold', 'shadows', 'highlights', 'scanlines', 'vignette'] as const;
|
||||
|
||||
sliderIds.forEach(id => {
|
||||
const input = document.getElementById(id) as HTMLInputElement | null;
|
||||
@@ -217,6 +234,36 @@ export class UIBindings {
|
||||
document.body.addEventListener('toggle-change', this.toggleHandler);
|
||||
}
|
||||
|
||||
// ============= Color Input =============
|
||||
|
||||
private setupColorInput(): void {
|
||||
const colorInput = document.getElementById('input-mono-color') as HTMLInputElement;
|
||||
const colorSwatch = document.getElementById('color-swatch-display');
|
||||
|
||||
if (colorInput) {
|
||||
colorInput.addEventListener('input', () => {
|
||||
if (this.isUpdatingUI) return;
|
||||
this.controller.setSetting('monoColor', colorInput.value);
|
||||
if (colorSwatch) {
|
||||
colorSwatch.style.backgroundColor = colorInput.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 =============
|
||||
|
||||
private setupSegments(): void {
|
||||
@@ -289,6 +336,11 @@ export class UIBindings {
|
||||
if (btnNext) {
|
||||
const handler = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const now = Date.now();
|
||||
if (now - this.lastNextTime < this.NEXT_COOLDOWN) return;
|
||||
this.lastNextTime = now;
|
||||
|
||||
this.loadNewImageFn();
|
||||
};
|
||||
this.buttonHandlers.set('btn-next', handler);
|
||||
@@ -339,6 +391,9 @@ export class UIBindings {
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'n':
|
||||
const now = Date.now();
|
||||
if (now - this.lastNextTime < this.NEXT_COOLDOWN) break;
|
||||
this.lastNextTime = now;
|
||||
this.loadNewImageFn();
|
||||
break;
|
||||
case 'r':
|
||||
@@ -377,8 +432,11 @@ export class UIBindings {
|
||||
// ============= Zoom =============
|
||||
|
||||
private setupZoom(): void {
|
||||
const heroWrapper = document.querySelector('.hero-wrapper');
|
||||
if (!heroWrapper) return;
|
||||
const workspace = document.querySelector('.ascii-workspace') || document.querySelector('.hero-wrapper');
|
||||
if (!workspace) return;
|
||||
|
||||
// Also attach to canvas for direct interaction
|
||||
const canvas = document.getElementById('ascii-canvas');
|
||||
|
||||
this.zoomHandlers.wheel = (e: Event) => {
|
||||
const we = e as WheelEvent;
|
||||
@@ -388,17 +446,24 @@ export class UIBindings {
|
||||
this.controller.handleWheel(we);
|
||||
};
|
||||
// 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.controller.handleMouseMove(e as MouseEvent);
|
||||
};
|
||||
heroWrapper.addEventListener('mousemove', this.zoomHandlers.move);
|
||||
workspace.addEventListener('mousemove', this.zoomHandlers.move);
|
||||
|
||||
this.zoomHandlers.leave = () => {
|
||||
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 =============
|
||||
@@ -412,6 +477,54 @@ export class UIBindings {
|
||||
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 =============
|
||||
|
||||
updateUI(): void {
|
||||
@@ -421,7 +534,7 @@ export class UIBindings {
|
||||
const settings = this.controller.getSettings();
|
||||
|
||||
// Update sliders
|
||||
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither'] as const;
|
||||
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither', 'sharpen', 'edgeThreshold', 'shadows', 'highlights', 'scanlines', 'vignette'] as const;
|
||||
sliderIds.forEach(id => {
|
||||
const input = document.getElementById(id) as HTMLInputElement | null;
|
||||
if (input && settings[id] !== undefined) {
|
||||
@@ -455,6 +568,31 @@ export class UIBindings {
|
||||
}
|
||||
window.updateSegmentValue?.('segment-edge', edgeShort);
|
||||
|
||||
// Update color input
|
||||
const colorInput = document.getElementById('input-mono-color') as HTMLInputElement;
|
||||
const colorSwatch = document.getElementById('color-swatch-display');
|
||||
|
||||
if (colorInput && settings.monoColor) {
|
||||
if (colorInput.value !== settings.monoColor) {
|
||||
colorInput.value = settings.monoColor;
|
||||
}
|
||||
if (colorSwatch) {
|
||||
colorSwatch.style.backgroundColor = settings.monoColor;
|
||||
}
|
||||
}
|
||||
|
||||
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.isUpdatingUI = false;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
|
||||
export interface RenderOptions {
|
||||
charSetContent: string;
|
||||
fontFamily?: string;
|
||||
@@ -15,6 +14,14 @@ export interface RenderOptions {
|
||||
edgeMode?: number; // 0=none, 1=simple, 2=sobel, 3=canny
|
||||
dither?: number;
|
||||
denoise?: boolean;
|
||||
sharpen?: number;
|
||||
edgeThreshold?: number;
|
||||
shadows?: number;
|
||||
highlights?: number;
|
||||
scanlines?: number;
|
||||
vignette?: number;
|
||||
monoColor?: string;
|
||||
backgroundColor?: string;
|
||||
zoom?: number;
|
||||
zoomCenter?: { x: number; y: number };
|
||||
mousePos?: { x: number; y: number };
|
||||
@@ -52,6 +59,10 @@ export class WebGLAsciiRenderer {
|
||||
}
|
||||
this.gl = gl;
|
||||
|
||||
// Enable required extensions for advanced rendering
|
||||
const hasDerivatives = !!gl.getExtension('OES_standard_derivatives');
|
||||
const hasLod = !!gl.getExtension('EXT_shader_texture_lod');
|
||||
|
||||
this.program = null;
|
||||
this.textures = {};
|
||||
this.buffers = {};
|
||||
@@ -60,11 +71,11 @@ export class WebGLAsciiRenderer {
|
||||
this.lastImage = null;
|
||||
this.fontFamily = "'JetBrains Mono', monospace";
|
||||
|
||||
this.init();
|
||||
this.init(hasDerivatives, hasLod);
|
||||
this.loadBlueNoiseTexture();
|
||||
}
|
||||
|
||||
init() {
|
||||
init(hasDerivatives: boolean, hasLod: boolean) {
|
||||
const gl = this.gl;
|
||||
|
||||
// Vertex Shader
|
||||
@@ -80,6 +91,8 @@ export class WebGLAsciiRenderer {
|
||||
|
||||
// Fragment Shader
|
||||
const fsSource = `
|
||||
${hasDerivatives ? '#extension GL_OES_standard_derivatives : enable' : ''}
|
||||
${hasLod ? '#extension GL_EXT_shader_texture_lod : enable' : ''}
|
||||
precision mediump float;
|
||||
varying vec2 v_texCoord;
|
||||
|
||||
@@ -87,11 +100,10 @@ export class WebGLAsciiRenderer {
|
||||
uniform sampler2D u_atlas;
|
||||
uniform sampler2D u_blueNoise;
|
||||
uniform float u_charCount;
|
||||
uniform vec2 u_charSizeUV; // Size of one char in UV space (width/texWidth, height/texHeight)
|
||||
uniform vec2 u_gridSize; // cols, rows
|
||||
uniform vec2 u_texSize; // atlas size
|
||||
uniform vec2 u_charSizeUV;
|
||||
uniform vec2 u_gridSize;
|
||||
uniform vec2 u_texSize;
|
||||
|
||||
// Adjustments
|
||||
uniform float u_exposure;
|
||||
uniform float u_contrast;
|
||||
uniform float u_saturation;
|
||||
@@ -99,11 +111,18 @@ export class WebGLAsciiRenderer {
|
||||
uniform bool u_invert;
|
||||
uniform bool u_color;
|
||||
uniform float u_overlayStrength;
|
||||
uniform int u_edgeMode; // 0=none, 1=simple, 2=sobel, 3=canny
|
||||
uniform float u_dither; // Dither strength 0.0 - 1.0
|
||||
uniform int u_edgeMode;
|
||||
uniform float u_dither;
|
||||
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 vec2 u_zoomCenter;
|
||||
uniform vec2 u_mousePos;
|
||||
@@ -112,50 +131,41 @@ export class WebGLAsciiRenderer {
|
||||
uniform bool u_showMagnifier;
|
||||
uniform float u_aspect;
|
||||
|
||||
// Blue Noise Dithering
|
||||
float blueNoise(vec2 pos) {
|
||||
// Map screen coordinates to texture coordinates (64x64 texture)
|
||||
vec2 noiseUV = pos / 64.0;
|
||||
float noiseVal = texture2D(u_blueNoise, noiseUV).r;
|
||||
// Shift range to -0.5 to 0.5 for dither offset
|
||||
return noiseVal - 0.5;
|
||||
}
|
||||
|
||||
vec3 adjust(vec3 color) {
|
||||
// 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));
|
||||
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);
|
||||
|
||||
// Gamma
|
||||
color = pow(max(color, 0.0), vec3(u_gamma));
|
||||
|
||||
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 sum = vec3(0.0);
|
||||
vec2 halfSize = cellSize * 0.25; // Sample halfway to the edge
|
||||
|
||||
// Center
|
||||
vec2 halfSize = cellSize * 0.25;
|
||||
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;
|
||||
|
||||
return sum / 5.0;
|
||||
}
|
||||
|
||||
// Sobel Filter - returns gradient magnitude and direction (approx)
|
||||
vec2 sobelFilter(vec2 uv, vec2 cellSize) {
|
||||
vec3 t = 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 bl = 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 lb = dot(b, 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 lbl = dot(bl, 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);
|
||||
|
||||
// Gy: -1 -2 -1
|
||||
// 0 0 0
|
||||
// 1 2 1
|
||||
float gy = (lbl + 2.0*lb + lbr) - (ltl + 2.0*lt + ltr);
|
||||
|
||||
float mag = sqrt(gx*gx + gy*gy);
|
||||
return vec2(mag, atan(gy, gx));
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = v_texCoord;
|
||||
|
||||
// Apply global zoom
|
||||
uv = (uv - u_zoomCenter) / u_zoom + u_zoomCenter;
|
||||
|
||||
// Magnifier logic
|
||||
vec2 diff = (v_texCoord - u_mousePos);
|
||||
diff.x *= u_aspect;
|
||||
float dist = length(diff);
|
||||
bool inMagnifier = u_showMagnifier && dist < u_magnifierRadius;
|
||||
|
||||
if (inMagnifier) {
|
||||
// Zoom towards mouse position inside the magnifier
|
||||
uv = (v_texCoord - u_mousePos) / u_magnifierZoom + u_mousePos;
|
||||
// Also account for the global zoom background
|
||||
uv = (uv - u_zoomCenter) / u_zoom + u_zoomCenter;
|
||||
}
|
||||
|
||||
// Calculate which cell we are in
|
||||
vec2 cellCoords = floor(uv * u_gridSize);
|
||||
vec2 uvInCell = fract(uv * u_gridSize);
|
||||
|
||||
// Sample image at the center of the cell
|
||||
vec2 cellSize = 1.0 / u_gridSize;
|
||||
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) {
|
||||
discard;
|
||||
}
|
||||
|
||||
vec3 color;
|
||||
|
||||
// Denoise: 3x3 box blur (applied to the base sampling if enabled)
|
||||
if (u_denoise) {
|
||||
color = getAverageColor(sampleUV, cellSize * 2.0);
|
||||
} else {
|
||||
color = getAverageColor(sampleUV, cellSize);
|
||||
}
|
||||
|
||||
// Edge Detection Logic
|
||||
if (u_sharpen > 0.0) {
|
||||
vec3 blurred = getAverageColor(sampleUV, cellSize * 2.0);
|
||||
color = color + (color - blurred) * u_sharpen;
|
||||
}
|
||||
|
||||
if (u_edgeMode == 1) {
|
||||
// Simple Laplacian-like
|
||||
vec2 texel = cellSize;
|
||||
vec3 center = color;
|
||||
vec3 top = 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 right = getAverageColor(sampleUV + vec2(texel.x, 0.0), cellSize);
|
||||
|
||||
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));
|
||||
if (edgeLum > u_edgeThreshold * 0.1) {
|
||||
color = mix(color, color * (1.0 - edgeLum * 2.0), 0.5);
|
||||
|
||||
}
|
||||
} else if (u_edgeMode == 2) {
|
||||
// Sobel Gradient
|
||||
vec2 sobel = sobelFilter(sampleUV, cellSize);
|
||||
float edgeStr = clamp(sobel.x * 2.0, 0.0, 1.0);
|
||||
// Darken edges
|
||||
if (edgeStr > u_edgeThreshold * 0.2) {
|
||||
color = mix(color, vec3(0.0), edgeStr * 0.8);
|
||||
|
||||
}
|
||||
} else if (u_edgeMode == 3) {
|
||||
// "Canny-like" (Sobel + gradient suppression)
|
||||
vec2 sobel = sobelFilter(sampleUV, cellSize);
|
||||
float mag = sobel.x;
|
||||
float angle = sobel.y;
|
||||
|
||||
// Non-maximum suppression (simplified)
|
||||
// Check neighbors in gradient direction
|
||||
vec2 dir = vec2(cos(angle), sin(angle)) * cellSize;
|
||||
|
||||
vec2 s1 = sobelFilter(sampleUV + dir, cellSize);
|
||||
vec2 s2 = sobelFilter(sampleUV - dir, cellSize);
|
||||
|
||||
if (mag < s1.x || mag < s2.x || mag < 0.15) {
|
||||
if (mag < s1.x || mag < s2.x || mag < u_edgeThreshold * 0.3) {
|
||||
mag = 0.0;
|
||||
} else {
|
||||
mag = 1.0; // Strong edge
|
||||
mag = 1.0;
|
||||
}
|
||||
|
||||
// Apply strong crisp edges
|
||||
color = mix(color, vec3(0.0), mag);
|
||||
}
|
||||
|
||||
// Apply adjustments
|
||||
color = adjust(color);
|
||||
|
||||
// Overlay blend-like effect (boost mid-contrast)
|
||||
if (u_overlayStrength > 0.0) {
|
||||
vec3 overlay = color;
|
||||
vec3 result;
|
||||
@@ -291,15 +270,10 @@ export class WebGLAsciiRenderer {
|
||||
color = mix(color, result, u_overlayStrength);
|
||||
}
|
||||
|
||||
// Calculate luminance
|
||||
float luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
|
||||
|
||||
// Apply Blue Noise dithering before character mapping
|
||||
if (u_dither > 0.0) {
|
||||
// Use cell coordinates for stable dithering patterns
|
||||
float noise = blueNoise(cellCoords);
|
||||
|
||||
// Scale noise by dither strength and 1/charCount
|
||||
luma = luma + noise * (1.0 / u_charCount) * u_dither;
|
||||
luma = clamp(luma, 0.0, 1.0);
|
||||
}
|
||||
@@ -308,31 +282,50 @@ export class WebGLAsciiRenderer {
|
||||
luma = 1.0 - luma;
|
||||
}
|
||||
|
||||
// Map luma to character index
|
||||
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(
|
||||
(charIndex + uvInCell.x) * u_charSizeUV.x,
|
||||
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) {
|
||||
float edgeWidth = 0.005;
|
||||
if (dist > u_magnifierRadius - edgeWidth && dist < u_magnifierRadius) {
|
||||
charAlpha = 1.0;
|
||||
color = vec3(1.0, 0.4039, 0.0); // Safety Orange border for the loupe
|
||||
color = vec3(1.0, 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
gl_FragColor = vec4(finalColor * charAlpha, charAlpha);
|
||||
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(mix(u_backgroundColor, finalColor, charAlpha), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -366,6 +359,8 @@ export class WebGLAsciiRenderer {
|
||||
'u_exposure', 'u_contrast', 'u_saturation', 'u_gamma',
|
||||
'u_invert', 'u_color', 'u_overlayStrength', 'u_edgeMode',
|
||||
'u_dither', 'u_denoise',
|
||||
'u_sharpen', 'u_edgeThreshold', 'u_shadows', 'u_highlights',
|
||||
'u_scanlines', 'u_vignette', 'u_monoColor', 'u_backgroundColor',
|
||||
'u_zoom', 'u_zoomCenter', 'u_mousePos',
|
||||
'u_magnifierRadius', 'u_magnifierZoom', 'u_showMagnifier', 'u_aspect'
|
||||
];
|
||||
@@ -416,28 +411,24 @@ export class WebGLAsciiRenderer {
|
||||
this.fontFamily = fontName;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
if (!ctx) return;
|
||||
|
||||
const fontSize = 32; // Higher resolution for atlas
|
||||
// Add padding to prevent bleeding
|
||||
const fontSize = 32;
|
||||
const padding = 4;
|
||||
|
||||
ctx.font = `${fontSize}px ${fontName}`;
|
||||
|
||||
// Measure first char to get dimensions
|
||||
const metrics = ctx.measureText('W');
|
||||
const charContentWidth = Math.ceil(metrics.width);
|
||||
const charContentHeight = Math.ceil(fontSize * 1.2);
|
||||
|
||||
// Full cell size including padding
|
||||
const charWidth = charContentWidth + padding * 2;
|
||||
const charHeight = charContentHeight + padding * 2;
|
||||
|
||||
const neededWidth = charWidth * charSet.length;
|
||||
const neededHeight = charHeight;
|
||||
|
||||
// Calculate Next Power of Two
|
||||
const nextPowerOfTwo = (v: number) => Math.pow(2, Math.ceil(Math.log(v) / Math.log(2)));
|
||||
const texWidth = nextPowerOfTwo(neededWidth);
|
||||
const texHeight = nextPowerOfTwo(neededHeight);
|
||||
@@ -451,9 +442,6 @@ export class WebGLAsciiRenderer {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -467,7 +455,6 @@ export class WebGLAsciiRenderer {
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.textures.atlas);
|
||||
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_MAG_FILTER, gl.LINEAR);
|
||||
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 u = this.uniformLocations;
|
||||
|
||||
if (!this.program) return;
|
||||
gl.useProgram(this.program);
|
||||
|
||||
// Update Atlas if needed (expensive check inside)
|
||||
this.updateAtlas(options.charSetContent, options.fontFamily || 'monospace');
|
||||
if (this.charAtlas) {
|
||||
gl.uniform1f(u['u_charCount'], this.charAtlas.count);
|
||||
// Pass the normalized size of one character cell for UV mapping
|
||||
gl.uniform2f(u['u_charSizeUV'],
|
||||
this.charAtlas.charWidth / this.charAtlas.width,
|
||||
this.charAtlas.charHeight / this.charAtlas.height
|
||||
@@ -518,8 +504,25 @@ export class WebGLAsciiRenderer {
|
||||
gl.uniform1i(u['u_edgeMode'], options.edgeMode || 0);
|
||||
gl.uniform1f(u['u_dither'], options.dither || 0.0);
|
||||
gl.uniform1i(u['u_denoise'], options.denoise ? 1 : 0);
|
||||
gl.uniform1f(u['u_sharpen'], options.sharpen || 0.0);
|
||||
gl.uniform1f(u['u_edgeThreshold'], options.edgeThreshold || 0.5);
|
||||
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.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);
|
||||
@@ -533,7 +536,6 @@ export class WebGLAsciiRenderer {
|
||||
const gl = this.gl;
|
||||
const texture = gl.createTexture();
|
||||
if (!texture) return;
|
||||
|
||||
this.textures.blueNoise = texture;
|
||||
|
||||
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_WRAP_S, 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) {
|
||||
if (this.lastImage === image && this.textures.image) return;
|
||||
|
||||
const gl = this.gl;
|
||||
|
||||
if (this.textures.image) gl.deleteTexture(this.textures.image);
|
||||
const texture = gl.createTexture();
|
||||
if (!texture) throw new Error('Failed to create texture');
|
||||
this.textures.image = texture;
|
||||
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.textures.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);
|
||||
@@ -578,16 +569,13 @@ export class WebGLAsciiRenderer {
|
||||
draw() {
|
||||
const gl = this.gl;
|
||||
const program = this.program;
|
||||
|
||||
if (!program || !this.textures.image || !this.textures.atlas || !this.buffers.position || !this.buffers.texCoord) return;
|
||||
|
||||
gl.useProgram(program);
|
||||
|
||||
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
// Attributes
|
||||
const posLoc = gl.getAttribLocation(program, 'a_position');
|
||||
gl.enableVertexAttribArray(posLoc);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.position);
|
||||
@@ -598,7 +586,6 @@ export class WebGLAsciiRenderer {
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.texCoord);
|
||||
gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
// Bind Textures
|
||||
const u = this.uniformLocations;
|
||||
gl.uniform1i(u['u_image'], 0);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
@@ -624,32 +611,14 @@ export class WebGLAsciiRenderer {
|
||||
this.draw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of all WebGL resources.
|
||||
* Call this when the renderer is no longer needed.
|
||||
*/
|
||||
dispose(): void {
|
||||
const gl = this.gl;
|
||||
|
||||
if (this.textures.image) {
|
||||
gl.deleteTexture(this.textures.image);
|
||||
}
|
||||
if (this.textures.atlas) {
|
||||
gl.deleteTexture(this.textures.atlas);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
if (this.textures.image) gl.deleteTexture(this.textures.image);
|
||||
if (this.textures.atlas) gl.deleteTexture(this.textures.atlas);
|
||||
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.buffers = {};
|
||||
this.program = null;
|
||||
@@ -657,29 +626,21 @@ export class WebGLAsciiRenderer {
|
||||
this.lastImage = null;
|
||||
}
|
||||
|
||||
// Kept for backward compatibility or specialized updates
|
||||
updateMagnifier(options: MagnifierOptions) {
|
||||
const gl = this.gl;
|
||||
const program = this.program;
|
||||
|
||||
if (!program) return;
|
||||
|
||||
gl.useProgram(program);
|
||||
|
||||
// Only update magnifier-related uniforms (using cached locations)
|
||||
const u = this.uniformLocations;
|
||||
const mousePos = options.mousePos ?? { x: -1, y: -1 };
|
||||
gl.uniform2f(u['u_mousePos'], mousePos.x, mousePos.y);
|
||||
gl.uniform1f(u['u_magnifierRadius'], options.magnifierRadius || 0.03);
|
||||
gl.uniform1f(u['u_magnifierZoom'], options.magnifierZoom || 2.0);
|
||||
gl.uniform1i(u['u_showMagnifier'], options.showMagnifier ? 1 : 0);
|
||||
|
||||
if (options.zoom !== undefined) {
|
||||
gl.uniform1f(u['u_zoom'], options.zoom || 1.0);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
:root {
|
||||
--bg-color: #000000;
|
||||
--text-color: #FF6700;
|
||||
--text-color: #FFFFFF;
|
||||
--accent-color: #FF6700;
|
||||
/* Safety Orange */
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
}
|
||||
@@ -28,7 +30,7 @@ button {
|
||||
|
||||
button:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 103, 0, 0.1);
|
||||
background: color-mix(in srgb, var(--accent-color), transparent 90%);
|
||||
}
|
||||
|
||||
a {
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"moduleResolution": "bundler",
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM"
|
||||
],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
@@ -16,6 +19,11 @@
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user