Add per-image alt text editor to the post composer
This commit is contained in:
parent
f12f525d54
commit
8d7948cb2e
18 changed files with 249 additions and 1 deletions
|
|
@ -378,6 +378,15 @@
|
|||
'add_count' => 'إضافة :count',
|
||||
],
|
||||
|
||||
'alt_text' => [
|
||||
'label' => 'نص بديل',
|
||||
'edit' => 'تحرير النص البديل',
|
||||
'placeholder' => 'صف هذه الصورة للأشخاص الذين يستخدمون برامج قراءة الشاشة',
|
||||
'hint' => 'يُنشر على المنصات التي تدعم أوصاف الصور.',
|
||||
'badge' => 'ALT',
|
||||
'save' => 'حفظ',
|
||||
],
|
||||
|
||||
'emoji_picker' => [
|
||||
'search' => 'البحث عن رمز تعبيري',
|
||||
'empty' => 'لم يتم العثور على رموز تعبيرية',
|
||||
|
|
|
|||
|
|
@ -380,6 +380,15 @@
|
|||
'add_count' => ':count hinzufügen',
|
||||
],
|
||||
|
||||
'alt_text' => [
|
||||
'label' => 'Alternativtext',
|
||||
'edit' => 'Alternativtext bearbeiten',
|
||||
'placeholder' => 'Beschreibe dieses Bild für Menschen, die Screenreader verwenden',
|
||||
'hint' => 'Wird auf Plattformen veröffentlicht, die Bildbeschreibungen unterstützen.',
|
||||
'badge' => 'ALT',
|
||||
'save' => 'Speichern',
|
||||
],
|
||||
|
||||
'emoji_picker' => [
|
||||
'search' => 'Emoji suchen',
|
||||
'empty' => 'Keine Emojis gefunden',
|
||||
|
|
|
|||
|
|
@ -378,6 +378,15 @@
|
|||
'add_count' => 'Προσθήκη :count',
|
||||
],
|
||||
|
||||
'alt_text' => [
|
||||
'label' => 'Εναλλακτικό κείμενο',
|
||||
'edit' => 'Επεξεργασία εναλλακτικού κειμένου',
|
||||
'placeholder' => 'Περιγράψτε αυτήν την εικόνα για άτομα που χρησιμοποιούν προγράμματα ανάγνωσης οθόνης',
|
||||
'hint' => 'Δημοσιεύεται σε πλατφόρμες που υποστηρίζουν περιγραφές εικόνων.',
|
||||
'badge' => 'ALT',
|
||||
'save' => 'Αποθήκευση',
|
||||
],
|
||||
|
||||
'emoji_picker' => [
|
||||
'search' => 'Αναζήτηση emoji',
|
||||
'empty' => 'Δεν βρέθηκαν emoji',
|
||||
|
|
|
|||
|
|
@ -378,6 +378,15 @@
|
|||
'add_count' => 'Add :count',
|
||||
],
|
||||
|
||||
'alt_text' => [
|
||||
'label' => 'Alt text',
|
||||
'edit' => 'Edit alt text',
|
||||
'placeholder' => 'Describe this image for people using screen readers',
|
||||
'hint' => 'Published to platforms that support image descriptions.',
|
||||
'badge' => 'ALT',
|
||||
'save' => 'Save',
|
||||
],
|
||||
|
||||
'emoji_picker' => [
|
||||
'search' => 'Search emoji',
|
||||
'empty' => 'No emojis found',
|
||||
|
|
|
|||
|
|
@ -378,6 +378,15 @@
|
|||
'add_count' => 'Agregar :count',
|
||||
],
|
||||
|
||||
'alt_text' => [
|
||||
'label' => 'Texto alternativo',
|
||||
'edit' => 'Editar texto alternativo',
|
||||
'placeholder' => 'Describe esta imagen para las personas que usan lectores de pantalla',
|
||||
'hint' => 'Se publica en las plataformas que admiten descripciones de imágenes.',
|
||||
'badge' => 'ALT',
|
||||
'save' => 'Guardar',
|
||||
],
|
||||
|
||||
'emoji_picker' => [
|
||||
'search' => 'Buscar emoji',
|
||||
'empty' => 'No se encontraron emojis',
|
||||
|
|
|
|||
|
|
@ -378,6 +378,15 @@
|
|||
'add_count' => 'Ajouter :count',
|
||||
],
|
||||
|
||||
'alt_text' => [
|
||||
'label' => 'Texte alternatif',
|
||||
'edit' => 'Modifier le texte alternatif',
|
||||
'placeholder' => 'Décrivez cette image pour les personnes utilisant un lecteur d\'écran',
|
||||
'hint' => 'Publié sur les plateformes qui prennent en charge les descriptions d\'images.',
|
||||
'badge' => 'ALT',
|
||||
'save' => 'Enregistrer',
|
||||
],
|
||||
|
||||
'emoji_picker' => [
|
||||
'search' => 'Rechercher un émoji',
|
||||
'empty' => 'Aucun émoji trouvé',
|
||||
|
|
|
|||
|
|
@ -378,6 +378,15 @@
|
|||
'add_count' => 'Aggiungi :count',
|
||||
],
|
||||
|
||||
'alt_text' => [
|
||||
'label' => 'Testo alternativo',
|
||||
'edit' => 'Modifica testo alternativo',
|
||||
'placeholder' => 'Descrivi questa immagine per le persone che usano screen reader',
|
||||
'hint' => 'Pubblicato sulle piattaforme che supportano le descrizioni delle immagini.',
|
||||
'badge' => 'ALT',
|
||||
'save' => 'Salva',
|
||||
],
|
||||
|
||||
'emoji_picker' => [
|
||||
'search' => 'Cerca emoji',
|
||||
'empty' => 'Nessuna emoji trovata',
|
||||
|
|
|
|||
|
|
@ -378,6 +378,15 @@
|
|||
'add_count' => ':count 件を追加',
|
||||
],
|
||||
|
||||
'alt_text' => [
|
||||
'label' => '代替テキスト',
|
||||
'edit' => '代替テキストを編集',
|
||||
'placeholder' => 'スクリーンリーダーを使用する人のためにこの画像を説明してください',
|
||||
'hint' => '画像の説明に対応しているプラットフォームに公開されます。',
|
||||
'badge' => 'ALT',
|
||||
'save' => '保存',
|
||||
],
|
||||
|
||||
'emoji_picker' => [
|
||||
'search' => '絵文字を検索',
|
||||
'empty' => '絵文字が見つかりません',
|
||||
|
|
|
|||
|
|
@ -378,6 +378,15 @@
|
|||
'add_count' => ':count개 추가',
|
||||
],
|
||||
|
||||
'alt_text' => [
|
||||
'label' => '대체 텍스트',
|
||||
'edit' => '대체 텍스트 편집',
|
||||
'placeholder' => '화면 낭독 프로그램을 사용하는 사람들을 위해 이 이미지를 설명하세요',
|
||||
'hint' => '이미지 설명을 지원하는 플랫폼에 게시됩니다.',
|
||||
'badge' => 'ALT',
|
||||
'save' => '저장',
|
||||
],
|
||||
|
||||
'emoji_picker' => [
|
||||
'search' => '이모지 검색',
|
||||
'empty' => '이모지를 찾을 수 없습니다',
|
||||
|
|
|
|||
|
|
@ -378,6 +378,15 @@
|
|||
'add_count' => ':count toevoegen',
|
||||
],
|
||||
|
||||
'alt_text' => [
|
||||
'label' => 'Alt-tekst',
|
||||
'edit' => 'Alt-tekst bewerken',
|
||||
'placeholder' => 'Beschrijf deze afbeelding voor mensen die schermlezers gebruiken',
|
||||
'hint' => 'Wordt gepubliceerd op platforms die afbeeldingsbeschrijvingen ondersteunen.',
|
||||
'badge' => 'ALT',
|
||||
'save' => 'Opslaan',
|
||||
],
|
||||
|
||||
'emoji_picker' => [
|
||||
'search' => 'Emoji zoeken',
|
||||
'empty' => 'Geen emoji\'s gevonden',
|
||||
|
|
|
|||
|
|
@ -378,6 +378,15 @@
|
|||
'add_count' => 'Dodaj :count',
|
||||
],
|
||||
|
||||
'alt_text' => [
|
||||
'label' => 'Tekst alternatywny',
|
||||
'edit' => 'Edytuj tekst alternatywny',
|
||||
'placeholder' => 'Opisz ten obraz dla osób korzystających z czytników ekranu',
|
||||
'hint' => 'Publikowany na platformach obsługujących opisy obrazów.',
|
||||
'badge' => 'ALT',
|
||||
'save' => 'Zapisz',
|
||||
],
|
||||
|
||||
'emoji_picker' => [
|
||||
'search' => 'Szukaj emoji',
|
||||
'empty' => 'Nie znaleziono emoji',
|
||||
|
|
|
|||
|
|
@ -378,6 +378,15 @@
|
|||
'add_count' => 'Adicionar :count',
|
||||
],
|
||||
|
||||
'alt_text' => [
|
||||
'label' => 'Texto alternativo',
|
||||
'edit' => 'Editar texto alternativo',
|
||||
'placeholder' => 'Descreva esta imagem para pessoas que usam leitores de tela',
|
||||
'hint' => 'Publicado nas plataformas que oferecem suporte a descrições de imagem.',
|
||||
'badge' => 'ALT',
|
||||
'save' => 'Salvar',
|
||||
],
|
||||
|
||||
'emoji_picker' => [
|
||||
'search' => 'Buscar emoji',
|
||||
'empty' => 'Nenhum emoji encontrado',
|
||||
|
|
|
|||
|
|
@ -378,6 +378,15 @@
|
|||
'add_count' => 'Добавить :count',
|
||||
],
|
||||
|
||||
'alt_text' => [
|
||||
'label' => 'Альтернативный текст',
|
||||
'edit' => 'Изменить альтернативный текст',
|
||||
'placeholder' => 'Опишите это изображение для людей, использующих программы чтения с экрана',
|
||||
'hint' => 'Публикуется на платформах, поддерживающих описания изображений.',
|
||||
'badge' => 'ALT',
|
||||
'save' => 'Сохранить',
|
||||
],
|
||||
|
||||
'emoji_picker' => [
|
||||
'search' => 'Поиск эмодзи',
|
||||
'empty' => 'Эмодзи не найдены',
|
||||
|
|
|
|||
|
|
@ -380,6 +380,15 @@
|
|||
'add_count' => ':count tane ekle',
|
||||
],
|
||||
|
||||
'alt_text' => [
|
||||
'label' => 'Alternatif metin',
|
||||
'edit' => 'Alternatif metni düzenle',
|
||||
'placeholder' => 'Ekran okuyucu kullanan kişiler için bu görseli açıklayın',
|
||||
'hint' => 'Görsel açıklamalarını destekleyen platformlarda yayınlanır.',
|
||||
'badge' => 'ALT',
|
||||
'save' => 'Kaydet',
|
||||
],
|
||||
|
||||
'emoji_picker' => [
|
||||
'search' => 'Emoji ara',
|
||||
'empty' => 'Emoji bulunamadı',
|
||||
|
|
|
|||
|
|
@ -378,6 +378,15 @@
|
|||
'add_count' => '添加 :count 个',
|
||||
],
|
||||
|
||||
'alt_text' => [
|
||||
'label' => '替代文本',
|
||||
'edit' => '编辑替代文本',
|
||||
'placeholder' => '为使用屏幕阅读器的用户描述此图片',
|
||||
'hint' => '发布到支持图片描述的平台。',
|
||||
'badge' => 'ALT',
|
||||
'save' => '保存',
|
||||
],
|
||||
|
||||
'emoji_picker' => [
|
||||
'search' => '搜索表情',
|
||||
'empty' => '未找到表情',
|
||||
|
|
|
|||
64
resources/js/components/posts/editor/AltTextDialog.vue
Normal file
64
resources/js/components/posts/editor/AltTextDialog.vue
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
const props = defineProps<{
|
||||
mediaItem: MediaItem | null;
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true });
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'save', value: string): void;
|
||||
}>();
|
||||
|
||||
const value = ref('');
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
value.value = props.mediaItem?.meta?.alt_text ?? '';
|
||||
}
|
||||
});
|
||||
|
||||
const save = () => {
|
||||
emit('save', value.value);
|
||||
open.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-xl" data-testid="alt-text-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t('posts.edit.alt_text.edit') }}</DialogTitle>
|
||||
<DialogDescription>{{ $t('posts.edit.alt_text.hint') }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="alt-text-input">{{ $t('posts.edit.alt_text.label') }}</Label>
|
||||
<Textarea
|
||||
id="alt-text-input"
|
||||
v-model="value"
|
||||
:placeholder="$t('posts.edit.alt_text.placeholder')"
|
||||
rows="4"
|
||||
data-testid="alt-text-input"
|
||||
/>
|
||||
<p class="text-right text-xs tabular-nums text-foreground/60">{{ value.length }}</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button data-testid="alt-text-save" @click="save">
|
||||
{{ $t('posts.edit.alt_text.save') }}
|
||||
</Button>
|
||||
<Button variant="outline" @click="open = false">
|
||||
{{ $t('posts.edit.cancel') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconAlt,
|
||||
IconFileTypePdf,
|
||||
IconGripVertical,
|
||||
IconHash,
|
||||
|
|
@ -16,6 +17,7 @@ import { trans } from 'laravel-vue-i18n';
|
|||
import { computed, nextTick, ref } from 'vue';
|
||||
|
||||
import ImagePreviewDialog from '@/components/ImagePreviewDialog.vue';
|
||||
import AltTextDialog from '@/components/posts/editor/AltTextDialog.vue';
|
||||
import EmojiPicker from '@/components/posts/EmojiPicker.vue';
|
||||
import MediaPickerDialog from '@/components/posts/MediaPickerDialog.vue';
|
||||
import SignaturesModal from '@/components/posts/SignaturesModal.vue';
|
||||
|
|
@ -24,7 +26,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
|||
import { formatBytes } from '@/composables/useMedia';
|
||||
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
import date from '@/date';
|
||||
import { classify, isDocument, isVideo, MediaType } from '@/lib/mediaType';
|
||||
import { classify, isDocument, isImage, isVideo, MediaType } from '@/lib/mediaType';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
interface Signature {
|
||||
|
|
@ -205,6 +207,32 @@ const onMediaKeydown = async (event: KeyboardEvent, index: number) => {
|
|||
|
||||
const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reason}`);
|
||||
const canRegenerateWithAi = (item: MediaItem): boolean => props.allowAiRegenerate && item.source === 'ai';
|
||||
|
||||
const altDialogOpen = ref(false);
|
||||
const altDialogIndex = ref<number | null>(null);
|
||||
const altDialogItem = computed<MediaItem | null>(() =>
|
||||
altDialogIndex.value !== null ? (media.value[altDialogIndex.value] ?? null) : null,
|
||||
);
|
||||
|
||||
const openAltText = (index: number) => {
|
||||
altDialogIndex.value = index;
|
||||
altDialogOpen.value = true;
|
||||
};
|
||||
|
||||
const setAltText = (index: number, alt: string): void => {
|
||||
const next = [...media.value];
|
||||
const trimmed = alt.trim();
|
||||
next[index] = {
|
||||
...next[index],
|
||||
meta: { ...(next[index].meta ?? {}), alt_text: trimmed || undefined },
|
||||
};
|
||||
media.value = next;
|
||||
};
|
||||
|
||||
const onAltTextSave = (value: string): void => {
|
||||
if (altDialogIndex.value === null) return;
|
||||
setAltText(altDialogIndex.value, value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -269,6 +297,13 @@ const canRegenerateWithAi = (item: MediaItem): boolean => props.allowAiRegenerat
|
|||
>
|
||||
{{ formatBytes(item.size) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isImage(item) && item.meta?.alt_text"
|
||||
class="inline-flex rounded-md bg-black/65 px-1.5 py-0.5 font-bold tracking-wider backdrop-blur-sm"
|
||||
data-testid="alt-text-badge"
|
||||
>
|
||||
{{ $t('posts.edit.alt_text.badge') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TooltipProvider v-if="mediaIssues[item.id]" :delay-duration="100">
|
||||
|
|
@ -308,6 +343,18 @@ const canRegenerateWithAi = (item: MediaItem): boolean => props.allowAiRegenerat
|
|||
{{ $t('posts.ai.image_regenerate.button') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="!readOnly && isImage(item)"
|
||||
type="button"
|
||||
:title="$t('posts.edit.alt_text.edit')"
|
||||
:aria-label="$t('posts.edit.alt_text.edit')"
|
||||
class="absolute right-9 top-1.5 inline-flex size-6 cursor-pointer items-center justify-center rounded-md border-2 border-foreground bg-card text-foreground opacity-0 shadow-2xs transition-all hover:bg-violet-100 group-hover:opacity-100 group-focus:opacity-100"
|
||||
data-testid="alt-text-button"
|
||||
@click.stop="openAltText(index)"
|
||||
>
|
||||
<IconAlt class="size-3.5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="!readOnly"
|
||||
type="button"
|
||||
|
|
@ -449,5 +496,6 @@ const canRegenerateWithAi = (item: MediaItem): boolean => props.allowAiRegenerat
|
|||
<SignaturesModal ref="signaturesModal" :signatures="signatures" @select="appendSignature" />
|
||||
<MediaPickerDialog ref="mediaPickerDialog" @select="addMediaFromGallery" />
|
||||
<ImagePreviewDialog ref="lightbox" />
|
||||
<AltTextDialog v-model:open="altDialogOpen" :media-item="altDialogItem" @save="onAltTextSave" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -16,5 +16,6 @@ export interface MediaItem {
|
|||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
alt_text?: string;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue