為圖片產生替代文字是一項基本的可存取性(Accessibility,簡稱 a11y)需求。AI 可以分析上傳的圖片並產生描述性的替代文字,螢幕閱讀器(Screen readers)可將其朗讀出來,向使用者傳達視覺畫面。
DashboardComponent 目前非常簡單,但我們很快就會新增子元件來實現圖片分析與替代文字產生功能。
@Component({
selector: 'app-dashboard',
imports: [AnalyzerPanelComponent],
templateUrl: './dashboard.component.html',
styleUrl: './dashboard.component.css',
})
export default class DashboardComponent {
analysis = signal<ImageAnalysisResponse | undefined>(undefined);
}
<section class="dashboard-main">
<app-analyzer-panel [(analysis)]="analysis" />
</section>
DashboardComponent 由 AnalyzerPanelComponent 組成,用於挑選圖片、進行圖片分析並顯示描述性替代文字。
讓我們來建構包含這些複雜面板的 AnalyzerPanelComponent。
@Component({
selector: 'app-analyzer-panel',
imports: [PhotoPanel, AltTextPanel],
templateUrl: './analyzer-panel.component.html',
styleUrl: './analyzer-panel.component.css',
})
export class AnalyzerPanelComponent {
visionService = inject(VisionService);
analysis = model<ImageAnalysisResponse | undefined>(undefined);
isLoading = signal(false);
async handleGenerateClick(file: File | undefined) {
if (!file) {
return;
}
this.isLoading.set(true);
this.analysis.set(undefined);
try {
const results = await this.visionService.generateAltText(file);
this.analysis.set(results);
} finally {
this.isLoading.set(false);
}
}
}
AnalyzerPanelComponent 插入(inject)了 VisionService 並叫用 generateAltText 方法,以產生圖片分析結果並更新 analysis 訊號(Signal)。
<div class="panel-grid">
<!-- Left Side: Uploader and Image Preview -->
<app-photo-panel
[(analysis)]="analysis"
[(error)]="error"
[isLoading]="isLoading()"
(emitFile)="handleGenerateClick($event)"
/>
<!-- Right Side: Results -->
<app-alt-text-panel [analysis]="analysis()" />
</div>
此範本由兩個面板組成:左側的相片面板與右側的替代文字面板。
讓我們首先說明相片面板的架構,接著再說明替代文字面板。
相片面板包含相片選擇器、標籤清單與冷知識面板,用以顯示 Gemini 透過 Google 搜尋工具找到的冷知識。當使用者點擊 Generate Description 按鈕時,會叫用 VisionService 來分析圖片,並產生標籤、替代文字、建議和冷知識。
<div>
@let imageAnalysis = analysis();
@let parsed = imageAnalysis?.parsed;
<app-photo-picker
[previewUrl]="previewUrl()"
[isLoading]="isLoading()"
[acceptedFileTypes]="acceptedTypes"
(fileChange)="handleFileChange($event)"
(generate)="handleGenerateClick()"
(removeFile)="handleFileChange(undefined)"
/>
<app-tags-display [tags]="parsed?.tags || []" />
<app-obscure-fact [interestingFact]="parsed?.fact" />
</div>
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/jpg', 'image/webp'];
@Component({
selector: 'app-photo-panel',
imports: [PhotoPickerComponent, TagsDisplayComponent, ObscureFactComponent],
providers: [AssetRegistry],
templateUrl: './photo-panel.html',
styleUrl: './photo-panel.css',
})
export class PhotoPanel {
readonly #assetRegistry = inject(AssetRegistry);
isLoading = input(false);
analysis = model<ImageAnalysisResponse | undefined>(undefined);
error = model<string | undefined>(undefined);
selectedFile = this.#assetRegistry.file;
previewUrl = this.#assetRegistry.previewUrl;
readonly acceptedTypes = ACCEPTED_IMAGE_TYPES;
emitFile = output<File | undefined>();
handleGenerateClick() {
this.emitFile.emit(this.selectedFile());
}
handleFileChange(file: File | undefined) {
if (file && !this.acceptedTypes.includes(file.type)) {
this.error.set('Invalid file type. Please select a JPG, JPEG, or PNG image.');
return;
}
this.#assetRegistry.register(file);
this.analysis.set(undefined);
this.error.set(undefined);
}
}
handleGenerateClick 方法將圖片分析的職責委派給 AnalyzerPanelComponent。
handleFileChange 接收有效的圖片,並將 analysis 模型(Model)設定為 undefined,以重設上一次的結果。
<div class="upload-wrapper">
<input type="file" #fileInput (change)="onFileChange()" [attr.accept]="accepted()" class="hidden" />
@if (!previewUrl()) {
<button type="button" (click)="triggerFileSelect()">
<p class="upload-title">Click to upload an image</p>
<p class="upload-subtitle">PNG, JPG, JPEG or WEBP</p>
</button>
} @else {
<div class="preview-container group">
<img [src]="previewUrl()!" alt="Selected preview" class="preview-image" />
<div class="action-overlay">
<button type="button" (click)="triggerFileSelect()">Change Image</button>
<button type="button" (click)="clearSelectedFile()">Remove Image</button>
</div>
</div>
}
<button
type="button"
(click)="generate.emit()"
[disabled]="!previewUrl() || isLoading()"
class="btn-generate btn-audio"
>
{{ isLoading() ? 'Analyzing...' : 'Generate Description' }}
</button>
</div>
HTML 範本提供了一個 Generate Description 按鈕,用於觸發呼叫 Firebase AI Logic 的程序,以產生標籤、替代文字和建議。
@Component({
selector: 'app-photo-picker',
templateUrl: './photo-picker.component.html',
styleUrl: './photo-picker.component.css',
})
export class PhotoPickerComponent {
previewUrl = input<string | undefined>(undefined);
acceptedFileTypes = input.required<string[]>();
accepted = computed(() => this.acceptedFileTypes().join(', '));
fileChange = output<File>();
generate = output();
removeFile = output();
invalidFile = output<string>();
fileInputRef = viewChild.required<ElementRef<HTMLInputElement>>('fileInput');
fileInputElement = computed(() => this.fileInputRef().nativeElement);
onFileChange() {
const file = this.fileInputElement().files?.[0];
if (file) {
this.validateAndProcessFile(file);
}
}
triggerFileSelect() {
this.fileInputElement().click();
}
clearSelectedFile() {
this.removeFile.emit();
}
/* ... event listener of dragover, dragleave and drop events ... */
private validateAndProcessFile(file: File) {
if (!this.acceptedFileTypes().includes(file.type)) {
this.invalidFile.emit('Invalid file type. Please select a JPG, JPEG, or PNG image.');
return;
}
this.fileChange.emit(file);
}
}
相片選擇器具有一個拖曳區域(Drop zone),供使用者拖放 JPG、JPEG、PNG 或 WEBP 圖片。嘗試拖放其他檔案類型時,不會顯示 any 預覽,且會發送(emit)自訂 of invalidFile 輸出。
該元件具有自訂的 removeFile 輸出,用以通知相片面板清除圖片。點擊 Change Image 按鈕會開啟一個 HTML 檔案輸入元素(File input element)以挑選其他圖片。
當圖片有效且可以預覽時,會透過 fileChange 輸出將其發送到相片面板。
圖片標籤清單是一個用來顯示標籤輸入的展示型元件(Presentational component)。
import { Listbox, Option } from '@angular/aria/listbox';
import { Component, computed, input } from '@angular/core';
@Component({
selector: 'app-tags-display',
templateUrl: './tags-display.component.html',
styleUrl: './tags-display.component.css',
imports: [Listbox, Option],
})
export class TagsDisplayComponent {
tags = input<string[]>([]);
tagAriaLabel = computed(() => {
const numItems = this.tags().length;
const items = `item${numItems === 1 ? '' : 's'}`;
return `Suggested tags, ${numItems} ${items}`;
});
}
<div class="obscure-fact-container">
<h3 class="obscure-fact-title">A surprising or obscure fact about the tags</h3>
@if (interestingFact()) {
<p class="obscure-fact-text">{{ interestingFact() }}</p>
}
</div>
@Component({
selector: 'app-obscure-fact',
templateUrl: './obscure-fact.component.html',
styleUrl: './obscure-fact.component.css',
imports: [],
})
export class ObscureFactComponent {
interestingFact = input<string | undefined>(undefined);
}
ObscureFactComponent 在冷知識已被定義且不為空字串時,將其顯示出來。
以上是 PhotoPanel 的主要元件。接下來,我們將說明 AltTextPanel 的主要元件。
替代文字面板顯示替代文字,以及有助於使圖片更具吸引力的建議清單。
<div class="panel-container">
@let imageAnalysis = analysis();
@if (!isLoading() && imageAnalysis) {
<div class="results-wrapper">
<app-alt-text-display [altText]="analysis()?.parsed?.alternativeText || 'Default alternative text'" />
<app-recommendations-display [recommendations]="analysis()?.parsed?.recommendations || []" />
</div>
}
</div>
@Component({
selector: 'app-alt-text-panel',
imports: [AltTextDisplayComponent, RecommendationsDisplayComponent],
templateUrl: './alt-text-panel.html',
styleUrl: './alt-text-panel.css',
})
export class AltTextPanel {
isLoading = input(false);
analysis = input<ImageAnalysisResponse | undefined>(undefined);
}
@if (altText()) {
<div class="display-wrapper">
<h3 class="display-title">Generated Alternative Text</h3>
<div class="display-card">
<p class="display-text">"{{ altText() }}"</p>
</div>
</div>
}
@Component({
selector: 'app-alt-text-display',
templateUrl: './alt-text-display.component.html',
styleUrl: './alt-text-display.component.css',
})
export class AltTextDisplayComponent {
altText = input<string>('');
}
AltTextDisplayComponent 是用於顯示產生的替代文字的展示型元件。
@Component({
selector: 'app-recommendations-display',
templateUrl: './recommendations.component.html',
styleUrl: './recommendations.component.css',
imports: [AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent],
})
export class RecommendationsDisplayComponent {
recommendations = input<Recommendation[]>([]);
}
RecommendationsDisplayComponent 匯入了 angular/aria 程式庫來實現無障礙(a11y)功能。建議清單是一個多重可展開的摺疊手風琴群組(Accordion group),在手風琴標頭中顯示建議文字,並在手風琴內容中顯示建議原因。
<div class="recommendations-wrapper">
<h3 class="recommendations-title">Recommendations</h3>
<div class="recommendations-list" ngAccordionGroup [multiExpandable]="true">
@for (item of recommendations(); track item.id) {
<div class="recommendation-card">
<h4 class="m-0">
<p ngAccordionTrigger [panel]="panelRef" #triggerRef="ngAccordionTrigger" class="recommendation-trigger">
<span class="recommendation-text-group">
<span class="recommendation-id">{{ item.id }}: </span>
<span class="recommendation-text">{{ item.text }}</span>
</span>
</p>
</h4>
<p ngAccordionPanel #panelRef="ngAccordionPanel">
<ng-template ngAccordionContent>
<span class="recommendation-reason-label">Reason:</span>
<span class="recommendation-reason-text">{{ item.reason }}</span>
</ng-template>
</p>
</div>
}
</div>
</div>
今天就到此為止。明天,我們將向 ObscureFactComponent 新增子元件,以實現從文字產生語音。
Tailwind CSS
Material Icons Outlined