Skip to content

Commit f0eaaa7

Browse files
committed
feat: add interactive ASCII art studio example
1 parent 096ef1f commit f0eaaa7

File tree

1 file changed

+371
-0
lines changed

1 file changed

+371
-0
lines changed
Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
import { Component, signal, computed } from '@angular/core';
2+
import { FormsModule } from '@angular/forms';
3+
import { AsciiArtPipe, CharsetPreset } from '@ngx-transforms';
4+
5+
interface CharsetOption {
6+
label: string;
7+
value: CharsetPreset | string;
8+
description: string;
9+
preview: string;
10+
}
11+
12+
interface PresetBanner {
13+
text: string;
14+
charset: CharsetPreset | string;
15+
width: number;
16+
fontSize: number;
17+
inverted: boolean;
18+
}
19+
20+
@Component({
21+
selector: 'app-ascii-art-studio',
22+
standalone: true,
23+
imports: [FormsModule, AsciiArtPipe],
24+
template: `
25+
<div class="max-w-6xl mx-auto p-6 space-y-6">
26+
<!-- Header -->
27+
<div class="text-center space-y-2">
28+
<h2 class="text-2xl font-bold">ASCII Art Studio</h2>
29+
<p class="text-muted-foreground">Create stunning text banners with customizable ASCII art</p>
30+
</div>
31+
32+
<!-- Input Section -->
33+
<div class="grid gap-4 md:grid-cols-2">
34+
<!-- Text Input -->
35+
<div class="space-y-2">
36+
<label class="text-sm font-medium">Your Text</label>
37+
<input
38+
type="text"
39+
[(ngModel)]="text"
40+
(ngModelChange)="onTextChange()"
41+
[maxlength]="maxLength"
42+
placeholder="Enter text (max {{maxLength}} chars)"
43+
class="w-full px-3 py-2 border border-border rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-primary"
44+
/>
45+
<div class="text-xs text-muted-foreground">
46+
{{text().length}} / {{maxLength}} characters
47+
</div>
48+
</div>
49+
50+
<!-- Width Control -->
51+
<div class="space-y-2">
52+
<label class="text-sm font-medium">Width (characters)</label>
53+
<input
54+
type="range"
55+
[(ngModel)]="width"
56+
min="40"
57+
max="120"
58+
step="10"
59+
class="w-full"
60+
/>
61+
<div class="text-xs text-muted-foreground text-center">{{width()}}</div>
62+
</div>
63+
</div>
64+
65+
<!-- Charset Selection -->
66+
<div class="space-y-2">
67+
<label class="text-sm font-medium">Character Set</label>
68+
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
69+
@for (option of charsetOptions; track option.value) {
70+
<button
71+
(click)="charset.set(option.value)"
72+
[class.ring-2]="charset() === option.value"
73+
[class.ring-primary]="charset() === option.value"
74+
class="p-3 border border-border rounded-lg hover:bg-accent transition-colors text-left"
75+
>
76+
<div class="font-medium text-sm">{{option.label}}</div>
77+
<div class="text-xs text-muted-foreground mt-1">{{option.description}}</div>
78+
<div class="font-mono text-xs mt-2 overflow-hidden">{{option.preview}}</div>
79+
</button>
80+
}
81+
</div>
82+
</div>
83+
84+
<!-- Advanced Options -->
85+
<div class="border border-border rounded-lg p-4 space-y-4">
86+
<h3 class="font-semibold text-sm">Advanced Options</h3>
87+
<div class="grid gap-4 md:grid-cols-3">
88+
<!-- Font Size -->
89+
<div class="space-y-2">
90+
<label class="text-sm font-medium">Font Size</label>
91+
<input
92+
type="range"
93+
[(ngModel)]="fontSize"
94+
min="24"
95+
max="96"
96+
step="12"
97+
class="w-full"
98+
/>
99+
<div class="text-xs text-muted-foreground text-center">{{fontSize()}}px</div>
100+
</div>
101+
102+
<!-- Inverted -->
103+
<div class="space-y-2">
104+
<label class="text-sm font-medium">Invert Colors</label>
105+
<label class="flex items-center space-x-2 cursor-pointer">
106+
<input
107+
type="checkbox"
108+
[(ngModel)]="inverted"
109+
class="w-4 h-4 rounded border-border"
110+
/>
111+
<span class="text-sm">Enable inversion</span>
112+
</label>
113+
</div>
114+
115+
<!-- Font Weight -->
116+
<div class="space-y-2">
117+
<label class="text-sm font-medium">Font Weight</label>
118+
<select
119+
[(ngModel)]="fontWeight"
120+
class="w-full px-3 py-2 border border-border rounded-md bg-background"
121+
>
122+
<option value="normal">Normal</option>
123+
<option value="bold">Bold</option>
124+
<option value="lighter">Light</option>
125+
</select>
126+
</div>
127+
</div>
128+
</div>
129+
130+
<!-- Preset Banners -->
131+
<div class="space-y-2">
132+
<h3 class="font-semibold">Quick Presets</h3>
133+
<div class="flex flex-wrap gap-2">
134+
@for (preset of presets; track preset.text) {
135+
<button
136+
(click)="applyPreset(preset)"
137+
class="px-4 py-2 border border-border rounded-md hover:bg-accent transition-colors text-sm"
138+
>
139+
{{preset.text}}
140+
</button>
141+
}
142+
</div>
143+
</div>
144+
145+
<!-- Output Preview -->
146+
<div class="space-y-2">
147+
<div class="flex items-center justify-between">
148+
<h3 class="font-semibold">Preview</h3>
149+
<div class="flex gap-2">
150+
<button
151+
(click)="copyToClipboard()"
152+
class="px-3 py-1 text-xs border border-border rounded-md hover:bg-accent transition-colors"
153+
>
154+
{{copyButtonText()}}
155+
</button>
156+
<button
157+
(click)="downloadAsText()"
158+
class="px-3 py-1 text-xs border border-border rounded-md hover:bg-accent transition-colors"
159+
>
160+
Download
161+
</button>
162+
</div>
163+
</div>
164+
<div class="border border-border rounded-lg p-4 bg-muted/30 overflow-x-auto">
165+
<div
166+
class="font-mono text-xs leading-tight"
167+
[innerHTML]="asciiOutput()"
168+
></div>
169+
</div>
170+
<div class="text-xs text-muted-foreground">
171+
Performance: {{processingTime()}}ms | Characters: {{characterCount()}}
172+
</div>
173+
</div>
174+
175+
<!-- Usage Example -->
176+
<div class="border border-border rounded-lg p-4 space-y-2">
177+
<h3 class="font-semibold text-sm">Usage in Your Code</h3>
178+
<pre class="text-xs bg-muted p-3 rounded overflow-x-auto"><code>{{ usageExample() }}</code></pre>
179+
</div>
180+
</div>
181+
`,
182+
styles: [`
183+
:host {
184+
display: block;
185+
}
186+
187+
input[type="range"] {
188+
-webkit-appearance: none;
189+
appearance: none;
190+
background: transparent;
191+
cursor: pointer;
192+
}
193+
194+
input[type="range"]::-webkit-slider-track {
195+
background: hsl(var(--border));
196+
height: 0.5rem;
197+
border-radius: 0.25rem;
198+
}
199+
200+
input[type="range"]::-webkit-slider-thumb {
201+
-webkit-appearance: none;
202+
appearance: none;
203+
background: hsl(var(--primary));
204+
height: 1.25rem;
205+
width: 1.25rem;
206+
border-radius: 50%;
207+
margin-top: -0.375rem;
208+
}
209+
210+
input[type="range"]::-moz-range-track {
211+
background: hsl(var(--border));
212+
height: 0.5rem;
213+
border-radius: 0.25rem;
214+
}
215+
216+
input[type="range"]::-moz-range-thumb {
217+
background: hsl(var(--primary));
218+
height: 1.25rem;
219+
width: 1.25rem;
220+
border-radius: 50%;
221+
border: none;
222+
}
223+
`],
224+
})
225+
export class AsciiArtStudio {
226+
// Signals for reactive state
227+
text = signal('HELLO');
228+
charset = signal<CharsetPreset | string>(CharsetPreset.STANDARD);
229+
width = signal(80);
230+
fontSize = signal(48);
231+
inverted = signal(false);
232+
fontWeight = signal<'normal' | 'bold' | 'lighter'>('normal');
233+
copyButtonText = signal('Copy');
234+
processingTime = signal(0);
235+
characterCount = signal(0);
236+
237+
maxLength = 50; // Security: Limit input length
238+
239+
// Charset options with previews
240+
charsetOptions: CharsetOption[] = [
241+
{
242+
label: 'Standard',
243+
value: CharsetPreset.STANDARD,
244+
description: 'Classic ASCII',
245+
preview: '@%#*+=-:.',
246+
},
247+
{
248+
label: 'Block',
249+
value: CharsetPreset.BLOCK,
250+
description: 'Solid blocks',
251+
preview: '██▓▒░',
252+
},
253+
{
254+
label: 'Minimal',
255+
value: CharsetPreset.MINIMAL,
256+
description: 'Simple & clean',
257+
preview: '@+.',
258+
},
259+
{
260+
label: 'Extended',
261+
value: CharsetPreset.EXTENDED,
262+
description: 'Full character set',
263+
preview: '$@B%8&WM#...',
264+
},
265+
];
266+
267+
// Quick presets for instant demos
268+
presets: PresetBanner[] = [
269+
{ text: 'WELCOME', charset: CharsetPreset.BLOCK, width: 80, fontSize: 60, inverted: false },
270+
{ text: 'ERROR', charset: CharsetPreset.STANDARD, width: 60, fontSize: 72, inverted: true },
271+
{ text: 'SUCCESS', charset: CharsetPreset.MINIMAL, width: 70, fontSize: 48, inverted: false },
272+
{ text: 'RETRO', charset: CharsetPreset.EXTENDED, width: 90, fontSize: 54, inverted: false },
273+
];
274+
275+
// Computed ASCII output with performance tracking
276+
asciiOutput = computed(() => {
277+
const start = performance.now();
278+
279+
const pipe = new AsciiArtPipe();
280+
const result = pipe.transform(this.text(), {
281+
charset: this.charset(),
282+
width: this.width(),
283+
inverted: this.inverted(),
284+
textOptions: {
285+
fontSize: this.fontSize(),
286+
fontWeight: this.fontWeight(),
287+
},
288+
});
289+
290+
const end = performance.now();
291+
this.processingTime.set(Math.round((end - start) * 100) / 100);
292+
293+
// Estimate character count (rough approximation)
294+
const textLength = result.replace(/<[^>]*>/g, '').length;
295+
this.characterCount.set(textLength);
296+
297+
return result;
298+
});
299+
300+
// Generate usage example code
301+
usageExample = computed(() => {
302+
const options: string[] = [];
303+
304+
if (this.charset() !== CharsetPreset.STANDARD) {
305+
options.push(`charset: CharsetPreset.${this.charset()}`);
306+
}
307+
if (this.width() !== 80) {
308+
options.push(`width: ${this.width()}`);
309+
}
310+
if (this.inverted()) {
311+
options.push(`inverted: true`);
312+
}
313+
if (this.fontSize() !== 48 || this.fontWeight() !== 'normal') {
314+
const textOpts: string[] = [];
315+
if (this.fontSize() !== 48) textOpts.push(`fontSize: ${this.fontSize()}`);
316+
if (this.fontWeight() !== 'normal') textOpts.push(`fontWeight: '${this.fontWeight()}'`);
317+
options.push(`textOptions: { ${textOpts.join(', ')} }`);
318+
}
319+
320+
const optionsStr = options.length > 0 ? `:{ ${options.join(', ')} }` : '';
321+
return `{{ '${this.text()}' | asciiArt${optionsStr} }}`;
322+
});
323+
324+
onTextChange() {
325+
// Security: Enforce max length
326+
if (this.text().length > this.maxLength) {
327+
this.text.set(this.text().substring(0, this.maxLength));
328+
}
329+
}
330+
331+
applyPreset(preset: PresetBanner) {
332+
this.text.set(preset.text);
333+
this.charset.set(preset.charset);
334+
this.width.set(preset.width);
335+
this.fontSize.set(preset.fontSize);
336+
this.inverted.set(preset.inverted);
337+
this.fontWeight.set('normal');
338+
}
339+
340+
copyToClipboard() {
341+
const output = this.asciiOutput();
342+
const textContent = output.replace(/<[^>]*>/g, ''); // Strip HTML tags
343+
344+
navigator.clipboard.writeText(textContent).then(
345+
() => {
346+
this.copyButtonText.set('Copied!');
347+
setTimeout(() => this.copyButtonText.set('Copy'), 2000);
348+
},
349+
(err) => {
350+
console.error('Failed to copy:', err);
351+
this.copyButtonText.set('Error');
352+
setTimeout(() => this.copyButtonText.set('Copy'), 2000);
353+
}
354+
);
355+
}
356+
357+
downloadAsText() {
358+
const output = this.asciiOutput();
359+
const textContent = output.replace(/<[^>]*>/g, ''); // Strip HTML tags
360+
361+
const blob = new Blob([textContent], { type: 'text/plain' });
362+
const url = URL.createObjectURL(blob);
363+
const a = document.createElement('a');
364+
a.href = url;
365+
a.download = `ascii-art-${this.text()}.txt`;
366+
document.body.appendChild(a);
367+
a.click();
368+
document.body.removeChild(a);
369+
URL.revokeObjectURL(url);
370+
}
371+
}

0 commit comments

Comments
 (0)