iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0

為圖片產生替代文字是一項基本的可存取性(Accessibility,簡稱 a11y)需求。AI 可以分析上傳的圖片並產生描述性的替代文字,螢幕閱讀器(Screen readers)可將其朗讀出來,向使用者傳達視覺畫面。

DashboardComponent 目前非常簡單,但我們很快就會新增子元件來實現圖片分析與替代文字產生功能。

儀表板元件 (Dashboard Component)

@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>

DashboardComponentAnalyzerPanelComponent 組成,用於挑選圖片、進行圖片分析並顯示描述性替代文字。

讓我們來建構包含這些複雜面板的 AnalyzerPanelComponent

剖析器面板元件 (Analyzer Panel Component)

@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>

此範本由兩個面板組成:左側的相片面板與右側的替代文字面板。

讓我們首先說明相片面板的架構,接著再說明替代文字面板。

反應式相片面板 (Reactive Photo Panel)

相片面板包含相片選擇器、標籤清單與冷知識面板,用以顯示 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,以重設上一次的結果。

相片選擇器元件 (Photo Picker Component)

<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 輸出將其發送到相片面板。

圖片標籤清單 (Image Tag List)

圖片標籤清單是一個用來顯示標籤輸入的展示型元件(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}`;
  });
}

冷知識顯示元件 (Obscure Fact Display)

<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 的主要元件。

替代文字面板 (Alternative Texts Panel)

替代文字面板顯示替代文字,以及有助於使圖片更具吸引力的建議清單。

<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);
}

替代文字元件 (Alternative Texts Component)

@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 是用於顯示產生的替代文字的展示型元件。

建議清單元件 (Recommendation List Component)

@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 新增子元件,以實現從文字產生語音。

資源 (Resources)

Tailwind CSS
Material Icons Outlined


上一篇
Day 19 - 定義 App Shell 與應用程式路由
系列文
2026年,如何利用 Antigravity CLI、Gemini、各項技能及 MCP Server 建構基於 Firebase 的 Angular 應用20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言