From add8161c77bd6dc444fab9f4cf178fade20fd1f4 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Sat, 18 Jul 2026 17:11:59 +0700 Subject: [PATCH 01/32] Add a three-dots actions menu to sense pictures Replace the pencil affordance on a picture with a reusable three-dots menu offering Edit, Download, and Delete, each wired to the existing behaviors (edit dialog, download, delete-with-confirmation). The menu is a new PictureActionsMenu component built on the responsive-menu primitive (dropdown on desktop, drawer on mobile) and is also opened by a long-press of the image so touch users don't have to hit the small target. Download logic is extracted into a shared picture-actions.ts (used by the menu and the edit dialog); PicturesEditor's delete-with-confirm is generalized so the menu and the dialog share it. The picture click still opens the edit dialog for now. Test infra: EntryViewComponent.waitForEntryLoaded() keyed off `.i-mdi-dots-vertical`, which is no longer unique now that each picture renders a three-dots menu; switch it to the lexeme-form field, a stable per-entry "loaded" signal. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../field-editors/EditPictureDialog.svelte | 16 +-- .../field-editors/PictureActionsMenu.svelte | 27 ++++++ .../field-editors/PictureImage.svelte | 97 +++++++++++++++---- .../field-editors/PicturesEditor.svelte | 28 ++++-- .../field-editors/picture-actions.ts | 25 +++++ frontend/viewer/src/locales/en.po | 9 ++ frontend/viewer/src/locales/es.po | 8 ++ frontend/viewer/src/locales/fr.po | 8 ++ frontend/viewer/src/locales/id.po | 8 ++ frontend/viewer/src/locales/ko.po | 8 ++ frontend/viewer/src/locales/ms.po | 8 ++ frontend/viewer/src/locales/sw.po | 8 ++ frontend/viewer/src/locales/vi.po | 8 ++ .../tests/pages/entry-view.component.ts | 4 +- .../viewer/tests/ui/sense-pictures.test.ts | 63 +++++++++--- 15 files changed, 272 insertions(+), 53 deletions(-) create mode 100644 frontend/viewer/src/lib/entry-editor/field-editors/PictureActionsMenu.svelte create mode 100644 frontend/viewer/src/lib/entry-editor/field-editors/picture-actions.ts diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte index d552634233..d10025acf4 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte @@ -6,6 +6,7 @@ import {Button} from '$lib/components/ui/button'; import PictureImage from './PictureImage.svelte'; import {ACCEPTED_PICTURE_TYPES} from './picture-formats'; + import {downloadPicture as downloadPictureFile} from './picture-actions'; import {t} from 'svelte-i18n-lingui'; import {useLexboxApi} from '$lib/services/service-provider'; import {AppNotification} from '$lib/notifications/notifications'; @@ -69,19 +70,10 @@ if (downloading) return; downloading = true; try { - const file = await api.getFileStream(mediaUri); - if (!file.stream) { - AppNotification.display(file.errorMessage ?? $t`Unable to download the picture`, {type: 'error'}); - return; + const result = await downloadPictureFile(api, mediaUri); + if (!result.success) { + AppNotification.display(result.errorMessage ?? $t`Unable to download the picture`, {type: 'error'}); } - const blob = await new Response(await file.stream.stream()).blob(); - const url = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = url; - anchor.download = file.fileName ?? 'picture'; - anchor.click(); - // Release the object URL on the next tick, once the browser has captured the blob. - setTimeout(() => URL.revokeObjectURL(url), 0); } finally { downloading = false; } diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureActionsMenu.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureActionsMenu.svelte new file mode 100644 index 0000000000..6b7fe0a649 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureActionsMenu.svelte @@ -0,0 +1,27 @@ + + + + + + + {$t`Edit`} + {$t`Download`} + {$t`Delete`} + + diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte index c84e77a12b..d9c6142432 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte @@ -4,22 +4,62 @@ import {useProjectContext} from '$project/project-context.svelte'; import {useWritingSystemService} from '$project/data'; import {t} from 'svelte-i18n-lingui'; + import {onDestroy} from 'svelte'; + import PictureActionsMenu from './PictureActionsMenu.svelte'; type Props = { picture: IPicture; - /** When provided, the whole picture is clickable (with a pencil hint) to open the edit dialog. */ + /** When provided (and not readonly), clicking the picture opens the edit dialog and a three-dots + actions menu is shown; the menu also opens on a long-press of the picture. */ onEdit?: () => void; - /** Disables the edit affordance while an operation is in flight. */ + /** Downloads the picture; wired into the actions menu (and long-press menu). */ + onDownload?: () => void; + /** Deletes the picture (with confirmation); wired into the actions menu. */ + onDelete?: () => void; + /** Disables the actions affordance while an operation is in flight. */ busy?: boolean; /** Whether to render the caption beneath the picture (hidden inside the edit dialog). */ showCaption?: boolean; readonly?: boolean; }; - const {picture, onEdit, busy = false, showCaption = true, readonly = false}: Props = $props(); + const {picture, onEdit, onDownload, onDelete, busy = false, showCaption = true, readonly = false}: Props = $props(); - // When an edit handler is wired the picture becomes a button that opens the edit dialog. The - // whole picture is clickable; the pencil is only a hover-independent hint (works on touch). - const editable = $derived(!readonly); + // With an edit handler wired (and not readonly) the picture is interactive: clicking opens the + // edit dialog and a three-dots menu offers the actions. + const interactive = $derived(!readonly && !!onEdit); + + // Long-press opens the actions menu, so touch users don't have to hit the small three-dots target. + let menuOpen = $state(false); + let longPressTimer: ReturnType | undefined; + let longPressed = false; + + function startLongPress() { + cancelLongPress(); + longPressed = false; + longPressTimer = setTimeout(() => { + longPressed = true; + menuOpen = true; + }, 500); + } + + function cancelLongPress() { + if (longPressTimer !== undefined) { + clearTimeout(longPressTimer); + longPressTimer = undefined; + } + } + + function handleImageClick(event: MouseEvent) { + // A long-press already opened the menu; swallow the click it would otherwise fire. + if (longPressed) { + longPressed = false; + event.preventDefault(); + return; + } + onEdit?.(); + } + + onDestroy(cancelLongPress); const projectContext = useProjectContext(); const api = $derived(projectContext?.maybeApi); @@ -53,11 +93,11 @@ return {status: 'loaded', url: URL.createObjectURL(blob)}; } - let state = $state({status: 'loading'}); + let loadState = $state({status: 'loading'}); const mediaUri = $derived(picture.mediaUri); $effect(() => { - state = {status: 'loading'}; + loadState = {status: 'loading'}; let revoked = false; let createdUrl: string | undefined; void loadImage(mediaUri).then((result) => { @@ -67,7 +107,7 @@ return; } if (result.status === 'loaded') createdUrl = result.url; - state = result; + loadState = result; }); return () => { revoked = true; @@ -77,38 +117,53 @@ {#snippet imageContent()} - {#if state.status === 'loaded'} + {#if loadState.status === 'loaded'} - {caption - {:else if state.status === 'loading'} + {caption + {:else if loadState.status === 'loading'}
{:else}
- {state.message} + {loadState.message}
{/if} {/snippet}
- +
- {#if editable} + {#if interactive} + {#if onDownload && onDelete} + +
+ onEdit?.()} + onDownload={() => onDownload?.()} + onDelete={() => onDelete?.()} + /> +
+ {/if} {:else} {@render imageContent()} {/if} diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte index 7a24935b7a..f6f61e7b5d 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -5,6 +5,7 @@ import PictureImage from './PictureImage.svelte'; import EditPictureDialog from './EditPictureDialog.svelte'; import {ACCEPTED_PICTURE_TYPES, isLosslessImage} from './picture-formats'; + import {downloadPicture as downloadPictureFile} from './picture-actions'; import {t} from 'svelte-i18n-lingui'; import {useLexboxApi} from '$lib/services/service-provider'; import {useDialogsService} from '$lib/services/dialogs-service'; @@ -112,20 +113,31 @@ } } - async function deleteEditingPicture(): Promise { - const targetId = editingPicture?.id; - if (!targetId) return; + async function deletePicture(pictureId: string): Promise { if (!(await dialogsService.promptDelete($t`Picture`))) return; busyAction = 'edit'; try { - // Close dialog *before* deleting picture so that dialog's close animation has time to play + // Close the edit dialog (if open on this picture) *before* deleting so its close animation + // has time to play; harmless when the delete came from the field/menu instead. editDialogOpen = false; - await api.deletePicture(entryId, senseId, targetId); + await api.deletePicture(entryId, senseId, pictureId); } finally { busyAction = null; } } + // The edit dialog deletes whatever picture it currently has open. + function deleteEditingPicture(): Promise { + return editingPicture ? deletePicture(editingPicture.id) : Promise.resolve(); + } + + async function downloadPicture(picture: IPicture): Promise { + const result = await downloadPictureFile(api, picture.mediaUri); + if (!result.success) { + AppNotification.display(result.errorMessage ?? $t`Unable to download the picture`, {type: 'error'}); + } + } + // The server rejects files above its size limit; the advice differs by format. function tooBigMessage(file: File): string { return isLosslessImage(file) @@ -137,8 +149,8 @@
{#if pictures.length > 0} + with no CSS change. Each picture + its caption is one flex item. Each picture has a + three-dots actions menu (also opened by a long-press); clicking a picture opens the editor. -->
{#each pictures as picture (picture.id)} openEditor(picture)} + onDownload={() => void downloadPicture(picture)} + onDelete={() => void deletePicture(picture.id)} /> {/each}
diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/picture-actions.ts b/frontend/viewer/src/lib/entry-editor/field-editors/picture-actions.ts new file mode 100644 index 0000000000..a61fb08a0b --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/field-editors/picture-actions.ts @@ -0,0 +1,25 @@ +import type {IMiniLcmJsInvokable} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable'; + +export type DownloadPictureResult = {success: true} | {success: false; errorMessage?: string}; + +/** + * Downloads the image behind a picture's mediaUri, saved under the filename the media server + * reports for it. Shared by the picture field, the edit dialog, and the fullscreen viewer so the + * "Download" action behaves identically wherever it's offered. + * + * getFileStream reports failure via the response (not an exception), so the caller decides how to + * surface `errorMessage` — this keeps the notification/translation in the calling component. + */ +export async function downloadPicture(api: IMiniLcmJsInvokable, mediaUri: string): Promise { + const file = await api.getFileStream(mediaUri); + if (!file.stream) return {success: false, errorMessage: file.errorMessage ?? undefined}; + const blob = await new Response(await file.stream.stream()).blob(); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = file.fileName ?? 'picture'; + anchor.click(); + // Release the object URL on the next tick, once the browser has captured the blob. + setTimeout(() => URL.revokeObjectURL(url), 0); + return {success: true}; +} diff --git a/frontend/viewer/src/locales/en.po b/frontend/viewer/src/locales/en.po index 66d42de9d2..f7933560ec 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -619,6 +619,7 @@ msgstr "Definition" #. Appears in project card; removes local project cache (not server copy) #. Action: Prompts confirmation, frees up local storage #: src/home/HomeView.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Delete" msgstr "Delete" @@ -703,6 +704,7 @@ msgstr "Done" #. Notification action button #: src/home/Server.svelte #: src/lib/components/audio/audio-editor.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte #: src/lib/notifications/NotificationOutlet.svelte msgid "Download" msgstr "Download" @@ -758,6 +760,7 @@ msgid "e.g. th, sw" msgstr "e.g. th, sw" #: src/lib/entry-editor/CommentDialog.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Edit" msgstr "Edit" @@ -1818,6 +1821,11 @@ msgstr "Pick a Part of Speech" msgid "Picture" msgstr "Picture" +#. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte +msgid "Picture actions" +msgstr "Picture actions" + #. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) #: src/lib/views/entity-config.ts msgid "Pictures" @@ -2456,6 +2464,7 @@ msgid "Type:" msgstr "Type:" #: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Unable to download the picture" msgstr "Unable to download the picture" diff --git a/frontend/viewer/src/locales/es.po b/frontend/viewer/src/locales/es.po index a21e5bb4c0..b7cb9b2088 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -624,6 +624,7 @@ msgstr "Definición" #. Appears in project card; removes local project cache (not server copy) #. Action: Prompts confirmation, frees up local storage #: src/home/HomeView.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Delete" msgstr "Borrar" @@ -708,6 +709,7 @@ msgstr "Hecho" #. Notification action button #: src/home/Server.svelte #: src/lib/components/audio/audio-editor.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte #: src/lib/notifications/NotificationOutlet.svelte msgid "Download" msgstr "Descargar" @@ -763,6 +765,7 @@ msgid "e.g. th, sw" msgstr "" #: src/lib/entry-editor/CommentDialog.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Edit" msgstr "" @@ -1823,6 +1826,10 @@ msgstr "Escoge una parte de la voz" msgid "Picture" msgstr "" +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte +msgid "Picture actions" +msgstr "" + #. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) #: src/lib/views/entity-config.ts msgid "Pictures" @@ -2461,6 +2468,7 @@ msgid "Type:" msgstr "Tipo:" #: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Unable to download the picture" msgstr "" diff --git a/frontend/viewer/src/locales/fr.po b/frontend/viewer/src/locales/fr.po index 49c34eb17f..212ca99c69 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -624,6 +624,7 @@ msgstr "Définition" #. Appears in project card; removes local project cache (not server copy) #. Action: Prompts confirmation, frees up local storage #: src/home/HomeView.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Delete" msgstr "Supprimer" @@ -708,6 +709,7 @@ msgstr "Fait" #. Notification action button #: src/home/Server.svelte #: src/lib/components/audio/audio-editor.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte #: src/lib/notifications/NotificationOutlet.svelte msgid "Download" msgstr "Télécharger" @@ -763,6 +765,7 @@ msgid "e.g. th, sw" msgstr "" #: src/lib/entry-editor/CommentDialog.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Edit" msgstr "" @@ -1823,6 +1826,10 @@ msgstr "Choisir une partie du discours" msgid "Picture" msgstr "" +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte +msgid "Picture actions" +msgstr "" + #. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) #: src/lib/views/entity-config.ts msgid "Pictures" @@ -2461,6 +2468,7 @@ msgid "Type:" msgstr "Type :" #: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Unable to download the picture" msgstr "" diff --git a/frontend/viewer/src/locales/id.po b/frontend/viewer/src/locales/id.po index 2c503d0f62..0dd760a75e 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -624,6 +624,7 @@ msgstr "Definisi" #. Appears in project card; removes local project cache (not server copy) #. Action: Prompts confirmation, frees up local storage #: src/home/HomeView.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Delete" msgstr "Menghapus" @@ -708,6 +709,7 @@ msgstr "Selesai." #. Notification action button #: src/home/Server.svelte #: src/lib/components/audio/audio-editor.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte #: src/lib/notifications/NotificationOutlet.svelte msgid "Download" msgstr "Unduh" @@ -763,6 +765,7 @@ msgid "e.g. th, sw" msgstr "" #: src/lib/entry-editor/CommentDialog.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Edit" msgstr "" @@ -1823,6 +1826,10 @@ msgstr "Pilih Bagian dari Pidato" msgid "Picture" msgstr "" +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte +msgid "Picture actions" +msgstr "" + #. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) #: src/lib/views/entity-config.ts msgid "Pictures" @@ -2461,6 +2468,7 @@ msgid "Type:" msgstr "Ketik:" #: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Unable to download the picture" msgstr "" diff --git a/frontend/viewer/src/locales/ko.po b/frontend/viewer/src/locales/ko.po index d2561a8ee1..e076bbe39a 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -624,6 +624,7 @@ msgstr "정의" #. Appears in project card; removes local project cache (not server copy) #. Action: Prompts confirmation, frees up local storage #: src/home/HomeView.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Delete" msgstr "삭제" @@ -708,6 +709,7 @@ msgstr "완료" #. Notification action button #: src/home/Server.svelte #: src/lib/components/audio/audio-editor.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte #: src/lib/notifications/NotificationOutlet.svelte msgid "Download" msgstr "다운로드" @@ -763,6 +765,7 @@ msgid "e.g. th, sw" msgstr "" #: src/lib/entry-editor/CommentDialog.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Edit" msgstr "" @@ -1823,6 +1826,10 @@ msgstr "품사 선택" msgid "Picture" msgstr "" +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte +msgid "Picture actions" +msgstr "" + #. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) #: src/lib/views/entity-config.ts msgid "Pictures" @@ -2461,6 +2468,7 @@ msgid "Type:" msgstr "유형:" #: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Unable to download the picture" msgstr "" diff --git a/frontend/viewer/src/locales/ms.po b/frontend/viewer/src/locales/ms.po index cbf8010b54..503e174519 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -624,6 +624,7 @@ msgstr "Definisi" #. Appears in project card; removes local project cache (not server copy) #. Action: Prompts confirmation, frees up local storage #: src/home/HomeView.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Delete" msgstr "Hapus" @@ -708,6 +709,7 @@ msgstr "Selesai" #. Notification action button #: src/home/Server.svelte #: src/lib/components/audio/audio-editor.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte #: src/lib/notifications/NotificationOutlet.svelte msgid "Download" msgstr "Muat turun" @@ -763,6 +765,7 @@ msgid "e.g. th, sw" msgstr "" #: src/lib/entry-editor/CommentDialog.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Edit" msgstr "" @@ -1823,6 +1826,10 @@ msgstr "Pilih Bahagian Pertuturan" msgid "Picture" msgstr "" +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte +msgid "Picture actions" +msgstr "" + #. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) #: src/lib/views/entity-config.ts msgid "Pictures" @@ -2461,6 +2468,7 @@ msgid "Type:" msgstr "Jenis:" #: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Unable to download the picture" msgstr "" diff --git a/frontend/viewer/src/locales/sw.po b/frontend/viewer/src/locales/sw.po index 196528f983..d102268617 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -624,6 +624,7 @@ msgstr "Maana" #. Appears in project card; removes local project cache (not server copy) #. Action: Prompts confirmation, frees up local storage #: src/home/HomeView.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Delete" msgstr "Futa" @@ -708,6 +709,7 @@ msgstr "Imekamilika" #. Notification action button #: src/home/Server.svelte #: src/lib/components/audio/audio-editor.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte #: src/lib/notifications/NotificationOutlet.svelte msgid "Download" msgstr "Pakua" @@ -763,6 +765,7 @@ msgid "e.g. th, sw" msgstr "" #: src/lib/entry-editor/CommentDialog.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Edit" msgstr "" @@ -1823,6 +1826,10 @@ msgstr "Chagua Sehemu ya Mazungumzo" msgid "Picture" msgstr "" +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte +msgid "Picture actions" +msgstr "" + #. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) #: src/lib/views/entity-config.ts msgid "Pictures" @@ -2461,6 +2468,7 @@ msgid "Type:" msgstr "Aina:" #: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Unable to download the picture" msgstr "" diff --git a/frontend/viewer/src/locales/vi.po b/frontend/viewer/src/locales/vi.po index 385767a47f..28d0bfab2e 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -624,6 +624,7 @@ msgstr "Định nghĩa" #. Appears in project card; removes local project cache (not server copy) #. Action: Prompts confirmation, frees up local storage #: src/home/HomeView.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Delete" msgstr "Xóa" @@ -708,6 +709,7 @@ msgstr "Hoàn thành" #. Notification action button #: src/home/Server.svelte #: src/lib/components/audio/audio-editor.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte #: src/lib/notifications/NotificationOutlet.svelte msgid "Download" msgstr "Tải xuống" @@ -763,6 +765,7 @@ msgid "e.g. th, sw" msgstr "" #: src/lib/entry-editor/CommentDialog.svelte +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Edit" msgstr "" @@ -1823,6 +1826,10 @@ msgstr "Chọn một Loại từ" msgid "Picture" msgstr "" +#: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte +msgid "Picture actions" +msgstr "" + #. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) #: src/lib/views/entity-config.ts msgid "Pictures" @@ -2461,6 +2468,7 @@ msgid "Type:" msgstr "Loại:" #: src/lib/entry-editor/field-editors/EditPictureDialog.svelte +#: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Unable to download the picture" msgstr "" diff --git a/frontend/viewer/tests/pages/entry-view.component.ts b/frontend/viewer/tests/pages/entry-view.component.ts index d0c45bd9f9..a4b64c5291 100644 --- a/frontend/viewer/tests/pages/entry-view.component.ts +++ b/frontend/viewer/tests/pages/entry-view.component.ts @@ -18,7 +18,9 @@ export class EntryViewComponent { } async waitForEntryLoaded(): Promise { - await expect(this.menuButton).toBeVisible({timeout: 5000}); + // The lexeme form is the canonical "entry loaded" signal. (The dots-vertical menu icon isn't + // unique — audio fields and picture menus use it too — so it can't gate readiness.) + await expect(this.lexemeFormField).toBeVisible({timeout: 5000}); } async waitForEntrySaved(): Promise { diff --git a/frontend/viewer/tests/ui/sense-pictures.test.ts b/frontend/viewer/tests/ui/sense-pictures.test.ts index ccfc7e8392..b75d9ad4b5 100644 --- a/frontend/viewer/tests/ui/sense-pictures.test.ts +++ b/frontend/viewer/tests/ui/sense-pictures.test.ts @@ -1,4 +1,4 @@ -import {expect, test, type Page} from '@playwright/test'; +import {expect, test, type Locator, type Page} from '@playwright/test'; import {DemoProjectPage} from './demo-project.page'; // A valid 96x96 PNG, used to exercise the upload flow without a real image file. It has real @@ -113,28 +113,40 @@ test.describe('Sense pictures', () => { return picturesField; } - /** Adds a picture, clicks it to open the edit dialog, and returns [picturesField, dialog]. */ + /** Opens the three-dots actions menu on the first picture in the field. */ + async function openPictureMenu(page: Page, picturesField: Locator) { + await picturesField.getByRole('button', {name: 'Picture actions'}).first().click(); + // The menu content is portaled to the body (a dropdown at this viewport), so query from the page. + await expect(page.getByRole('menuitem', {name: 'Edit'})).toBeVisible({timeout: 5000}); + } + + /** Adds a picture, opens the edit dialog via the three-dots menu, and returns [picturesField, dialog]. */ async function openEditor(page: Page) { const picturesField = await addOnePicture(page); - await picturesField.getByRole('button', {name: 'Edit Picture'}).click(); + await openPictureMenu(page, picturesField); + await page.getByRole('menuitem', {name: 'Edit'}).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({timeout: 5000}); return {picturesField, dialog}; } - test('"+ Picture" stays available; clicking a picture opens the edit dialog', async ({page}) => { + test('the three-dots menu offers Edit/Download/Delete and Edit opens the editor', async ({page}) => { const picturesField = await addOnePicture(page); // The add button is still present even though a picture now exists... await expect(picturesField.getByRole('button', {name: 'Picture', exact: true})).toBeVisible(); - // ...and the picture itself is a button that opens the editor (no field-level action buttons). - const editButton = picturesField.getByRole('button', {name: 'Edit Picture'}); - await expect(editButton).toBeVisible(); + // ...and there are no field-level Replace/Delete buttons (those live inside the edit dialog). await expect(picturesField.getByRole('button', {name: 'Replace Picture'})).toHaveCount(0); await expect(picturesField.getByRole('button', {name: 'Delete Picture'})).toHaveCount(0); - // Opening it reveals the caption editor and the Replace/Delete actions inside the dialog. - await editButton.click(); + // The corner three-dots menu exposes the three picture actions. + await openPictureMenu(page, picturesField); + await expect(page.getByRole('menuitem', {name: 'Edit'})).toBeVisible(); + await expect(page.getByRole('menuitem', {name: 'Download'})).toBeVisible(); + await expect(page.getByRole('menuitem', {name: 'Delete'})).toBeVisible(); + + // Edit reveals the caption editor and the Replace/Delete actions inside the dialog. + await page.getByRole('menuitem', {name: 'Edit'}).click(); const dialog = page.getByRole('dialog'); await expect(dialog.getByText('Caption')).toBeVisible(); await expect(dialog.getByRole('button', {name: 'Replace Picture'})).toBeVisible(); @@ -145,7 +157,19 @@ test.describe('Sense pictures', () => { // Submit dismisses the dialog (leaving the picture in place). await dialog.getByRole('button', {name: 'Submit'}).click(); await expect(dialog).toHaveCount(0); - await expect(picturesField.getByRole('button', {name: 'Edit Picture'})).toBeVisible(); + await expect(picturesField.getByRole('button', {name: 'Picture actions'})).toBeVisible(); + }); + + test('the three-dots menu Delete removes the picture after confirmation', async ({page}) => { + const picturesField = await addOnePicture(page); + + await openPictureMenu(page, picturesField); + await page.getByRole('menuitem', {name: 'Delete'}).click(); + // Confirm in the delete alert dialog (its confirm button is labelled "Delete Picture"). + await page.getByRole('alertdialog').getByRole('button', {name: 'Delete Picture', exact: true}).click(); + + await expect(picturesField.locator('img')).toHaveCount(0, {timeout: 5000}); + await expect(picturesField.getByRole('button', {name: 'Picture', exact: true})).toBeVisible(); }); test('Delete Picture (in the dialog) removes the picture after confirmation', async ({page}) => { @@ -214,7 +238,7 @@ test.describe('Sense pictures', () => { await expect(picturesField.getByText('Discarded')).toHaveCount(0); }); - test('Download Picture saves the image under its media-server filename', async ({page}) => { + test('Download Picture (in the dialog) saves the image under its media-server filename', async ({page}) => { const projectPage = new DemoProjectPage(page); await projectPage.goto(); // "nyumba" has demo pictures whose media-server filename is deterministic (demo-picture.svg). @@ -222,7 +246,8 @@ test.describe('Sense pictures', () => { const picturesField = page.locator('[style*="grid-area: pictures"]').first(); await expect(picturesField.locator('img').first()).toBeVisible({timeout: 5000}); - await picturesField.getByRole('button', {name: 'Edit Picture'}).first().click(); + await openPictureMenu(page, picturesField); + await page.getByRole('menuitem', {name: 'Edit'}).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({timeout: 5000}); @@ -231,4 +256,18 @@ test.describe('Sense pictures', () => { const download = await downloadPromise; expect(download.suggestedFilename()).toBe('demo-picture.svg'); }); + + test('the three-dots menu Download saves the image under its media-server filename', async ({page}) => { + const projectPage = new DemoProjectPage(page); + await projectPage.goto(); + await projectPage.selectEntryByFilter('nyumba'); + const picturesField = page.locator('[style*="grid-area: pictures"]').first(); + await expect(picturesField.locator('img').first()).toBeVisible({timeout: 5000}); + + const downloadPromise = page.waitForEvent('download'); + await openPictureMenu(page, picturesField); + await page.getByRole('menuitem', {name: 'Download'}).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe('demo-picture.svg'); + }); }); From 1a29c91e6a489d2c67fc9502c8abd34ddb3ff48b Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Sat, 18 Jul 2026 17:31:26 +0700 Subject: [PATCH 02/32] Open a fullscreen viewer when a picture is clicked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking (or tapping) a sense picture now opens a new PictureViewerDialog instead of the edit dialog — the three-dots menu (added previously) already reaches the editor. The viewer shows the picture sized to the viewport but never larger than its native resolution (PictureImage gains a size="full" variant; the dialog shrinks to fit). It carries the same three-dots actions menu (Edit/Download/Delete) acting on the current picture, left/right arrows to move between pictures when the sense has more than one, and below the image the current picture's non-empty captions shown read-only with writing-system abbreviation labels. The shown picture is tracked by id so navigation and deletion stay in sync with the reloaded entry; the dialog closes if that picture is gone. PictureImage's click now calls onView (aria-label "View Picture"); the menu's Edit still uses onEdit. The viewer's actions menu is disabled while an operation is in flight, matching the edit dialog's double-invoke guard. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../field-editors/PictureImage.svelte | 32 +++-- .../field-editors/PictureViewerDialog.svelte | 116 ++++++++++++++++++ .../field-editors/PicturesEditor.svelte | 25 ++++ frontend/viewer/src/locales/en.po | 17 ++- frontend/viewer/src/locales/es.po | 18 ++- frontend/viewer/src/locales/fr.po | 18 ++- frontend/viewer/src/locales/id.po | 18 ++- frontend/viewer/src/locales/ko.po | 18 ++- frontend/viewer/src/locales/ms.po | 18 ++- frontend/viewer/src/locales/sw.po | 18 ++- frontend/viewer/src/locales/vi.po | 18 ++- .../viewer/tests/ui/sense-pictures.test.ts | 67 ++++++++++ 12 files changed, 364 insertions(+), 19 deletions(-) create mode 100644 frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte index d9c6142432..633385177d 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte @@ -9,8 +9,10 @@ type Props = { picture: IPicture; - /** When provided (and not readonly), clicking the picture opens the edit dialog and a three-dots - actions menu is shown; the menu also opens on a long-press of the picture. */ + /** When provided (and not readonly), clicking the picture opens the fullscreen viewer and a + three-dots actions menu is shown; the menu also opens on a long-press of the picture. */ + onView?: () => void; + /** Opens the edit dialog; wired into the actions menu (the "Edit" item). */ onEdit?: () => void; /** Downloads the picture; wired into the actions menu (and long-press menu). */ onDownload?: () => void; @@ -18,15 +20,23 @@ onDelete?: () => void; /** Disables the actions affordance while an operation is in flight. */ busy?: boolean; - /** Whether to render the caption beneath the picture (hidden inside the edit dialog). */ + /** Whether to render the caption beneath the picture (hidden inside the edit/viewer dialogs). */ showCaption?: boolean; + /** 'thumbnail' (default) is the fixed-height field size; 'full' fills the viewer up to the + viewport while never exceeding the image's native size. */ + size?: 'thumbnail' | 'full'; readonly?: boolean; }; - const {picture, onEdit, onDownload, onDelete, busy = false, showCaption = true, readonly = false}: Props = $props(); + const {picture, onView, onEdit, onDownload, onDelete, busy = false, showCaption = true, size = 'thumbnail', readonly = false}: Props = $props(); - // With an edit handler wired (and not readonly) the picture is interactive: clicking opens the - // edit dialog and a three-dots menu offers the actions. - const interactive = $derived(!readonly && !!onEdit); + // With a view handler wired (and not readonly) the picture is interactive: clicking opens the + // viewer and a three-dots menu offers the actions. + const interactive = $derived(!readonly && !!onView); + const imageClass = $derived( + size === 'full' + ? 'max-h-[80dvh] w-auto max-w-full rounded-md object-contain' + : 'h-40 w-auto rounded-md object-contain', + ); // Long-press opens the actions menu, so touch users don't have to hit the small three-dots target. let menuOpen = $state(false); @@ -56,7 +66,7 @@ event.preventDefault(); return; } - onEdit?.(); + onView?.(); } onDestroy(cancelLongPress); @@ -118,8 +128,8 @@ {#snippet imageContent()} {#if loadState.status === 'loaded'} - - {caption + + {caption {:else if loadState.status === 'loading'}
@@ -139,7 +149,7 @@
+ + {#if captions.length > 0} + +
+ {#each captions as {ws, text} (ws.wsId)} + + {ws.abbreviation} + + {text} + {/each} +
+ {/if} + {/if} + + diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte index f6f61e7b5d..bbfbc494fc 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -4,6 +4,7 @@ import {Button} from '$lib/components/ui/button'; import PictureImage from './PictureImage.svelte'; import EditPictureDialog from './EditPictureDialog.svelte'; + import PictureViewerDialog from './PictureViewerDialog.svelte'; import {ACCEPTED_PICTURE_TYPES, isLosslessImage} from './picture-formats'; import {downloadPicture as downloadPictureFile} from './picture-actions'; import {t} from 'svelte-i18n-lingui'; @@ -34,11 +35,20 @@ const editingPicture = $derived(editingPictureId ? pictures.find((p) => p.id === editingPictureId) : undefined); let editDialogOpen = $state(false); + // The fullscreen viewer, tracked by id so prev/next and deletion stay in sync with the reloaded entry. + let viewerPictureId = $state(); + let viewerOpen = $state(false); + function openEditor(picture: IPicture) { editingPictureId = picture.id; editDialogOpen = true; } + function openViewer(picture: IPicture) { + viewerPictureId = picture.id; + viewerOpen = true; + } + function onFileSelected(event: Event) { const target = event.target as HTMLInputElement; const file = target.files?.[0]; @@ -157,6 +167,7 @@ {picture} {readonly} busy={busyAction !== null} + onView={() => openViewer(picture)} onEdit={() => openEditor(picture)} onDownload={() => void downloadPicture(picture)} onDelete={() => void deletePicture(picture.id)} @@ -196,3 +207,17 @@ onDelete={() => deleteEditingPicture()} /> {/if} + + { + // Hand off from the viewer to the edit dialog. + viewerOpen = false; + openEditor(picture); + }} + onDownload={(picture) => void downloadPicture(picture)} + onDelete={(picture) => void deletePicture(picture.id)} +/> diff --git a/frontend/viewer/src/locales/en.po b/frontend/viewer/src/locales/en.po index f7933560ec..a1b1da9b22 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -774,7 +774,6 @@ msgid "Edit Custom View" msgstr "Edit Custom View" #: src/lib/entry-editor/field-editors/EditPictureDialog.svelte -#: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Edit Picture" msgstr "Edit Picture" @@ -1548,6 +1547,11 @@ msgstr "Next" msgid "Next milestone: {0}. {1} to go." msgstr "Next milestone: {0}. {1} to go." +#. Accessible label for the arrow button that shows the next picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Next picture" +msgstr "Next picture" + #. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte msgid "No activity matches these filters" @@ -1816,6 +1820,7 @@ msgstr "Pick a Part of Speech" #. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor #: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Picture" @@ -1864,6 +1869,11 @@ msgstr "Preview not available for this file type." msgid "Preview not supported" msgstr "Preview not supported" +#. Accessible label for the arrow button that shows the previous picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Previous picture" +msgstr "Previous picture" + #. Label in project card #: src/home/HomeView.svelte msgid "Project" @@ -2638,6 +2648,11 @@ msgstr "Vernacular writing systems" msgid "View" msgstr "View" +#. Accessible label for a sense picture; activating it opens the picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "View Picture" +msgstr "View Picture" + #. Color theme option #: src/lib/components/ThemePicker.svelte msgid "violet" diff --git a/frontend/viewer/src/locales/es.po b/frontend/viewer/src/locales/es.po index b7cb9b2088..5341045bbe 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -779,7 +779,6 @@ msgid "Edit Custom View" msgstr "Editar vista personalizada" #: src/lib/entry-editor/field-editors/EditPictureDialog.svelte -#: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Edit Picture" msgstr "" @@ -1553,6 +1552,11 @@ msgstr "Siguiente" msgid "Next milestone: {0}. {1} to go." msgstr "" +#. Accessible label for the arrow button that shows the next picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Next picture" +msgstr "" + #. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte msgid "No activity matches these filters" @@ -1821,11 +1825,13 @@ msgstr "Escoge una parte de la voz" #. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor #: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Picture" msgstr "" +#. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) #: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Picture actions" msgstr "" @@ -1868,6 +1874,11 @@ msgstr "" msgid "Preview not supported" msgstr "" +#. Accessible label for the arrow button that shows the previous picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Previous picture" +msgstr "" + #. Label in project card #: src/home/HomeView.svelte msgid "Project" @@ -2642,6 +2653,11 @@ msgstr "" msgid "View" msgstr "Ver" +#. Accessible label for a sense picture; activating it opens the picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "View Picture" +msgstr "" + #. Color theme option #: src/lib/components/ThemePicker.svelte msgid "violet" diff --git a/frontend/viewer/src/locales/fr.po b/frontend/viewer/src/locales/fr.po index 212ca99c69..9bab187fd3 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -779,7 +779,6 @@ msgid "Edit Custom View" msgstr "Modifier la vue personnalisée" #: src/lib/entry-editor/field-editors/EditPictureDialog.svelte -#: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Edit Picture" msgstr "" @@ -1553,6 +1552,11 @@ msgstr "Suivant" msgid "Next milestone: {0}. {1} to go." msgstr "" +#. Accessible label for the arrow button that shows the next picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Next picture" +msgstr "" + #. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte msgid "No activity matches these filters" @@ -1821,11 +1825,13 @@ msgstr "Choisir une partie du discours" #. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor #: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Picture" msgstr "" +#. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) #: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Picture actions" msgstr "" @@ -1868,6 +1874,11 @@ msgstr "" msgid "Preview not supported" msgstr "" +#. Accessible label for the arrow button that shows the previous picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Previous picture" +msgstr "" + #. Label in project card #: src/home/HomeView.svelte msgid "Project" @@ -2642,6 +2653,11 @@ msgstr "" msgid "View" msgstr "Voir" +#. Accessible label for a sense picture; activating it opens the picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "View Picture" +msgstr "" + #. Color theme option #: src/lib/components/ThemePicker.svelte msgid "violet" diff --git a/frontend/viewer/src/locales/id.po b/frontend/viewer/src/locales/id.po index 0dd760a75e..9fc1915ccd 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -779,7 +779,6 @@ msgid "Edit Custom View" msgstr "Edit Tampilan Khusus" #: src/lib/entry-editor/field-editors/EditPictureDialog.svelte -#: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Edit Picture" msgstr "" @@ -1553,6 +1552,11 @@ msgstr "Berikutnya" msgid "Next milestone: {0}. {1} to go." msgstr "" +#. Accessible label for the arrow button that shows the next picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Next picture" +msgstr "" + #. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte msgid "No activity matches these filters" @@ -1821,11 +1825,13 @@ msgstr "Pilih Bagian dari Pidato" #. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor #: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Picture" msgstr "" +#. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) #: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Picture actions" msgstr "" @@ -1868,6 +1874,11 @@ msgstr "" msgid "Preview not supported" msgstr "" +#. Accessible label for the arrow button that shows the previous picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Previous picture" +msgstr "" + #. Label in project card #: src/home/HomeView.svelte msgid "Project" @@ -2642,6 +2653,11 @@ msgstr "" msgid "View" msgstr "Melihat" +#. Accessible label for a sense picture; activating it opens the picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "View Picture" +msgstr "" + #. Color theme option #: src/lib/components/ThemePicker.svelte msgid "violet" diff --git a/frontend/viewer/src/locales/ko.po b/frontend/viewer/src/locales/ko.po index e076bbe39a..d667c13d91 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -779,7 +779,6 @@ msgid "Edit Custom View" msgstr "사용자 지정 보기 편집" #: src/lib/entry-editor/field-editors/EditPictureDialog.svelte -#: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Edit Picture" msgstr "" @@ -1553,6 +1552,11 @@ msgstr "다음" msgid "Next milestone: {0}. {1} to go." msgstr "" +#. Accessible label for the arrow button that shows the next picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Next picture" +msgstr "" + #. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte msgid "No activity matches these filters" @@ -1821,11 +1825,13 @@ msgstr "품사 선택" #. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor #: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Picture" msgstr "" +#. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) #: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Picture actions" msgstr "" @@ -1868,6 +1874,11 @@ msgstr "" msgid "Preview not supported" msgstr "" +#. Accessible label for the arrow button that shows the previous picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Previous picture" +msgstr "" + #. Label in project card #: src/home/HomeView.svelte msgid "Project" @@ -2642,6 +2653,11 @@ msgstr "" msgid "View" msgstr "보기" +#. Accessible label for a sense picture; activating it opens the picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "View Picture" +msgstr "" + #. Color theme option #: src/lib/components/ThemePicker.svelte msgid "violet" diff --git a/frontend/viewer/src/locales/ms.po b/frontend/viewer/src/locales/ms.po index 503e174519..cca0dd29ca 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -779,7 +779,6 @@ msgid "Edit Custom View" msgstr "Sunting Tinjauan Tersuai" #: src/lib/entry-editor/field-editors/EditPictureDialog.svelte -#: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Edit Picture" msgstr "" @@ -1553,6 +1552,11 @@ msgstr "Seterusnya" msgid "Next milestone: {0}. {1} to go." msgstr "" +#. Accessible label for the arrow button that shows the next picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Next picture" +msgstr "" + #. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte msgid "No activity matches these filters" @@ -1821,11 +1825,13 @@ msgstr "Pilih Bahagian Pertuturan" #. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor #: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Picture" msgstr "" +#. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) #: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Picture actions" msgstr "" @@ -1868,6 +1874,11 @@ msgstr "" msgid "Preview not supported" msgstr "" +#. Accessible label for the arrow button that shows the previous picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Previous picture" +msgstr "" + #. Label in project card #: src/home/HomeView.svelte msgid "Project" @@ -2642,6 +2653,11 @@ msgstr "" msgid "View" msgstr "Lihat" +#. Accessible label for a sense picture; activating it opens the picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "View Picture" +msgstr "" + #. Color theme option #: src/lib/components/ThemePicker.svelte msgid "violet" diff --git a/frontend/viewer/src/locales/sw.po b/frontend/viewer/src/locales/sw.po index d102268617..ed4b86696e 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -779,7 +779,6 @@ msgid "Edit Custom View" msgstr "Hariri Muonekano Binafsi" #: src/lib/entry-editor/field-editors/EditPictureDialog.svelte -#: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Edit Picture" msgstr "" @@ -1553,6 +1552,11 @@ msgstr "Inayofuata" msgid "Next milestone: {0}. {1} to go." msgstr "" +#. Accessible label for the arrow button that shows the next picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Next picture" +msgstr "" + #. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte msgid "No activity matches these filters" @@ -1821,11 +1825,13 @@ msgstr "Chagua Sehemu ya Mazungumzo" #. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor #: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Picture" msgstr "" +#. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) #: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Picture actions" msgstr "" @@ -1868,6 +1874,11 @@ msgstr "" msgid "Preview not supported" msgstr "" +#. Accessible label for the arrow button that shows the previous picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Previous picture" +msgstr "" + #. Label in project card #: src/home/HomeView.svelte msgid "Project" @@ -2642,6 +2653,11 @@ msgstr "" msgid "View" msgstr "Muonekano" +#. Accessible label for a sense picture; activating it opens the picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "View Picture" +msgstr "" + #. Color theme option #: src/lib/components/ThemePicker.svelte msgid "violet" diff --git a/frontend/viewer/src/locales/vi.po b/frontend/viewer/src/locales/vi.po index 28d0bfab2e..f331e259f1 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -779,7 +779,6 @@ msgid "Edit Custom View" msgstr "Chỉnh sửa chế độ xem tùy chỉnh" #: src/lib/entry-editor/field-editors/EditPictureDialog.svelte -#: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Edit Picture" msgstr "" @@ -1553,6 +1552,11 @@ msgstr "Tiếp theo" msgid "Next milestone: {0}. {1} to go." msgstr "" +#. Accessible label for the arrow button that shows the next picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Next picture" +msgstr "" + #. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte msgid "No activity matches these filters" @@ -1821,11 +1825,13 @@ msgstr "Chọn một Loại từ" #. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor #: src/lib/entry-editor/field-editors/PictureImage.svelte +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "Picture" msgstr "" +#. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) #: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Picture actions" msgstr "" @@ -1868,6 +1874,11 @@ msgstr "" msgid "Preview not supported" msgstr "" +#. Accessible label for the arrow button that shows the previous picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Previous picture" +msgstr "" + #. Label in project card #: src/home/HomeView.svelte msgid "Project" @@ -2642,6 +2653,11 @@ msgstr "" msgid "View" msgstr "Xem" +#. Accessible label for a sense picture; activating it opens the picture in the fullscreen viewer +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "View Picture" +msgstr "" + #. Color theme option #: src/lib/components/ThemePicker.svelte msgid "violet" diff --git a/frontend/viewer/tests/ui/sense-pictures.test.ts b/frontend/viewer/tests/ui/sense-pictures.test.ts index b75d9ad4b5..2bcdedd8c7 100644 --- a/frontend/viewer/tests/ui/sense-pictures.test.ts +++ b/frontend/viewer/tests/ui/sense-pictures.test.ts @@ -270,4 +270,71 @@ test.describe('Sense pictures', () => { const download = await downloadPromise; expect(download.suggestedFilename()).toBe('demo-picture.svg'); }); + + test('clicking a picture opens the fullscreen viewer', async ({page}) => { + const picturesField = await addOnePicture(page); + + await picturesField.getByRole('button', {name: 'View Picture'}).click(); + const viewer = page.getByRole('dialog'); + await expect(viewer).toBeVisible({timeout: 5000}); + await expect(viewer.getByRole('heading', {name: 'Picture', exact: true})).toBeVisible(); + + // The picture is shown (loaded into a blob url) and the same three-dots menu is available. + await expect(viewer.locator('img')).toHaveAttribute('src', /^blob:/, {timeout: 5000}); + await expect(viewer.getByRole('button', {name: 'Picture actions'})).toBeVisible(); + + // A freshly-uploaded picture has no caption, and a single picture has no navigation arrows. + await expect(viewer.getByRole('button', {name: 'Previous picture'})).toHaveCount(0); + await expect(viewer.getByRole('button', {name: 'Next picture'})).toHaveCount(0); + }); + + test('the fullscreen viewer navigates between pictures and shows their non-empty captions', async ({page}) => { + const projectPage = new DemoProjectPage(page); + await projectPage.goto(); + // "nyumba" has two pictures, each with an English and Portuguese caption. + await projectPage.selectEntryByFilter('nyumba'); + const picturesField = page.locator('[style*="grid-area: pictures"]').first(); + await expect(picturesField.locator('img').first()).toBeVisible({timeout: 5000}); + + await picturesField.getByRole('button', {name: 'View Picture'}).first().click(); + const viewer = page.getByRole('dialog'); + await expect(viewer).toBeVisible({timeout: 5000}); + + // Both non-empty captions of the first picture are shown. + await expect(viewer.getByText('A traditional house')).toBeVisible(); + await expect(viewer.getByText('Uma casa tradicional')).toBeVisible(); + + // At the first picture, Previous is disabled and Next is enabled. + const previous = viewer.getByRole('button', {name: 'Previous picture'}); + const next = viewer.getByRole('button', {name: 'Next picture'}); + await expect(previous).toBeDisabled(); + await expect(next).toBeEnabled(); + + // Next swaps in the second picture's captions and reaches the end. + await next.click(); + await expect(viewer.getByText('A modern house')).toBeVisible(); + await expect(viewer.getByText('A traditional house')).toHaveCount(0); + await expect(next).toBeDisabled(); + await expect(previous).toBeEnabled(); + + // Previous returns to the first picture. + await previous.click(); + await expect(viewer.getByText('A traditional house')).toBeVisible(); + await expect(previous).toBeDisabled(); + }); + + test('the fullscreen viewer Edit hands off to the edit dialog', async ({page}) => { + const picturesField = await addOnePicture(page); + + await picturesField.getByRole('button', {name: 'View Picture'}).click(); + const viewer = page.getByRole('dialog'); + await expect(viewer).toBeVisible({timeout: 5000}); + + await viewer.getByRole('button', {name: 'Picture actions'}).click(); + await page.getByRole('menuitem', {name: 'Edit'}).click(); + + // The edit dialog takes over (Replace/Submit live only there). + await expect(page.getByRole('button', {name: 'Replace Picture'})).toBeVisible({timeout: 5000}); + await expect(page.getByRole('button', {name: 'Submit'})).toBeVisible(); + }); }); From 22a61308b4150f7cb28c3180c7b3a86d038b4344 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Mon, 20 Jul 2026 12:30:53 +0700 Subject: [PATCH 03/32] Load picture images once per entry via a shared cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an entry-view-scoped image service (context-based) that fetches and decodes each picture mediaUri once and hands out a shared blob object URL. Two pictures with the same mediaUri — or the same picture shown in both the field and a dialog — now load a single time instead of once per PictureImage instance. The service is initialized in EntryEditor (the entry view, which remounts per entry), so its cached object URLs are revoked when that view is torn down. PictureImage loads through it (preload in an $effect, reactive read via a SvelteMap-backed get in a $derived), falling back to a component-local cache when rendered without an entry-view scope. EditPictureDialog and PictureViewerDialog get the cache transitively since they render PictureImage within the EntryEditor subtree. A #disposed guard prevents a load that finishes after teardown from minting an object URL that would never be revoked. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../field-editors/PictureImage.svelte | 68 +++++--------- .../field-editors/image-service.svelte.ts | 89 +++++++++++++++++++ .../object-editors/EntryEditor.svelte | 6 ++ .../viewer/tests/ui/sense-pictures.test.ts | 25 ++++++ 4 files changed, 143 insertions(+), 45 deletions(-) create mode 100644 frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte index 633385177d..2455a340f8 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte @@ -1,11 +1,11 @@ {#snippet imageContent()} @@ -137,7 +115,7 @@ {:else}
- {loadState.message} + {errorText(loadState)}
{/if} {/snippet} diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts b/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts new file mode 100644 index 0000000000..cbb532a681 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts @@ -0,0 +1,89 @@ +import {Context} from 'runed'; +import {onDestroy} from 'svelte'; +import {SvelteMap} from 'svelte/reactivity'; +import type {IMiniLcmJsInvokable} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable'; +import {ReadFileResult} from '$lib/dotnet-types/generated-types/MiniLcm/Media/ReadFileResult'; + +export type ImageLoadState = + | {status: 'loading'} + | {status: 'loaded'; url: string} + | {status: 'error'; reason: 'not-found' | 'offline' | 'unknown'; detail?: string}; + +/** + * Loads picture images once per mediaUri and hands out a shared blob object URL. Scoped (via context) + * to the entry view, so identical mediaUris across a sense's pictures — or the same picture shown in + * both the field and a dialog — are fetched and decoded a single time. The cached object URLs live + * until the owning entry view is torn down (see initImageService), then dispose() revokes them. + */ +export class ImageService { + readonly #getApi: () => IMiniLcmJsInvokable | undefined; + readonly #cache = new SvelteMap(); + #disposed = false; + + constructor(getApi: () => IMiniLcmJsInvokable | undefined) { + this.#getApi = getApi; + } + + /** Starts loading `mediaUri` if it hasn't been requested yet. Call from an $effect (it mutates). */ + preload(mediaUri: string): void { + if (this.#cache.has(mediaUri)) return; + this.#cache.set(mediaUri, {status: 'loading'}); + void this.#load(mediaUri); + } + + /** Reactive load-state for `mediaUri`. Call from a $derived; 'loading' until preload has resolved. */ + get(mediaUri: string): ImageLoadState { + return this.#cache.get(mediaUri) ?? {status: 'loading'}; + } + + // getFileStream reports failure via the response (not exceptions), so we branch on it; a thrown + // error (e.g. from blob()) propagates to the global handler, matching the rest of the viewer. + async #load(mediaUri: string): Promise { + const api = this.#getApi(); + if (!api) { + this.#cache.set(mediaUri, {status: 'error', reason: 'unknown'}); + return; + } + const file = await api.getFileStream(mediaUri); + if (this.#disposed) return; + if (!file.stream) { + const reason = + file.result === ReadFileResult.NotFound ? 'not-found' + : file.result === ReadFileResult.Offline ? 'offline' + : 'unknown'; + this.#cache.set(mediaUri, {status: 'error', reason, detail: file.errorMessage ?? undefined}); + return; + } + const blob = await new Response(await file.stream.stream()).blob(); + // Bail before minting a URL if the service was torn down mid-load, else it would never be revoked. + if (this.#disposed) return; + this.#cache.set(mediaUri, {status: 'loaded', url: URL.createObjectURL(blob)}); + } + + dispose(): void { + this.#disposed = true; + for (const state of this.#cache.values()) { + if (state.status === 'loaded') URL.revokeObjectURL(state.url); + } + this.#cache.clear(); + } +} + +const imageServiceContext = new Context('image-service'); + +/** + * Creates an entry-view-scoped image cache and publishes it to descendants (pictures, the edit + * dialog, the fullscreen viewer). Call once from the entry view; its object URLs are revoked when + * that view is destroyed. + */ +export function initImageService(getApi: () => IMiniLcmJsInvokable | undefined): ImageService { + const service = new ImageService(getApi); + imageServiceContext.set(service); + onDestroy(() => service.dispose()); + return service; +} + +/** The entry-view image service, or undefined when rendered outside an entry view. */ +export function useImageService(): ImageService | undefined { + return imageServiceContext.getOr(undefined); +} diff --git a/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte b/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte index aaa36c051b..3694bc8eea 100644 --- a/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte @@ -15,6 +15,8 @@ @@ -101,15 +112,22 @@
{#if captions.length > 0} - -
- {#each captions as {ws, text} (ws.wsId)} - + +
+ {/if} {/if} diff --git a/frontend/viewer/src/locales/en.po b/frontend/viewer/src/locales/en.po index 2bede5a253..77a39eedf2 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -2420,6 +2420,11 @@ msgstr "This will synchronize the FieldWorks Lite and FieldWorks Classic copies msgid "Thread" msgstr "Thread" +#. Accessible label for the caption block in the fullscreen picture viewer; clicking it collapses to the first caption or expands to show all +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Toggle captions" +msgstr "Toggle captions" + #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" diff --git a/frontend/viewer/src/locales/es.po b/frontend/viewer/src/locales/es.po index eba9165a12..38e0d00577 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -2425,6 +2425,11 @@ msgstr "Esto sincronizará las copias de FieldWorks Lite y FieldWorks Classic de msgid "Thread" msgstr "" +#. Accessible label for the caption block in the fullscreen picture viewer; clicking it collapses to the first caption or expands to show all +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Toggle captions" +msgstr "" + #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" diff --git a/frontend/viewer/src/locales/fr.po b/frontend/viewer/src/locales/fr.po index 9586539422..7fb5a1dde6 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -2425,6 +2425,11 @@ msgstr "Cela synchronisera les copies FieldWorks Lite et FieldWorks Classic de v msgid "Thread" msgstr "" +#. Accessible label for the caption block in the fullscreen picture viewer; clicking it collapses to the first caption or expands to show all +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Toggle captions" +msgstr "" + #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" diff --git a/frontend/viewer/src/locales/id.po b/frontend/viewer/src/locales/id.po index 283ad14e8f..bc6fd68322 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -2425,6 +2425,11 @@ msgstr "Ini akan menyinkronkan salinan FieldWorks Lite dan FieldWorks Classic da msgid "Thread" msgstr "" +#. Accessible label for the caption block in the fullscreen picture viewer; clicking it collapses to the first caption or expands to show all +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Toggle captions" +msgstr "" + #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" diff --git a/frontend/viewer/src/locales/ko.po b/frontend/viewer/src/locales/ko.po index b691bf3e59..bf72d3dea8 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -2425,6 +2425,11 @@ msgstr "이렇게 하면 렉스박스에서 프로젝트의 FieldWorks Lite 및 msgid "Thread" msgstr "" +#. Accessible label for the caption block in the fullscreen picture viewer; clicking it collapses to the first caption or expands to show all +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Toggle captions" +msgstr "" + #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" diff --git a/frontend/viewer/src/locales/ms.po b/frontend/viewer/src/locales/ms.po index be088dc905..72cddbb4a0 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -2425,6 +2425,11 @@ msgstr "Ini akan menyegerakkan salinan FieldWorks Lite dan FieldWorks Classic pr msgid "Thread" msgstr "" +#. Accessible label for the caption block in the fullscreen picture viewer; clicking it collapses to the first caption or expands to show all +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Toggle captions" +msgstr "" + #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" diff --git a/frontend/viewer/src/locales/sw.po b/frontend/viewer/src/locales/sw.po index fc16ad4f07..326292178d 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -2425,6 +2425,11 @@ msgstr "Hii itaoanisha kopia za FieldWorks Lite na FieldWorks Classic za mradi w msgid "Thread" msgstr "" +#. Accessible label for the caption block in the fullscreen picture viewer; clicking it collapses to the first caption or expands to show all +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Toggle captions" +msgstr "" + #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" diff --git a/frontend/viewer/src/locales/vi.po b/frontend/viewer/src/locales/vi.po index 69baf6b6a0..6f0b4a50a8 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -2425,6 +2425,11 @@ msgstr "Điều này sẽ đồng bộ các bản sao FieldWorks Lite và FieldW msgid "Thread" msgstr "" +#. Accessible label for the caption block in the fullscreen picture viewer; clicking it collapses to the first caption or expands to show all +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Toggle captions" +msgstr "" + #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" diff --git a/frontend/viewer/tests/ui/sense-pictures.test.ts b/frontend/viewer/tests/ui/sense-pictures.test.ts index 67d9f9660c..af89e7ecfd 100644 --- a/frontend/viewer/tests/ui/sense-pictures.test.ts +++ b/frontend/viewer/tests/ui/sense-pictures.test.ts @@ -380,6 +380,32 @@ test.describe('Sense pictures', () => { await expect(viewer.locator('img')).toHaveAttribute('src', thumbnailSrc ?? '', {timeout: 5000}); }); + test('clicking the viewer captions collapses to the first caption and expands again', async ({page}) => { + const projectPage = new DemoProjectPage(page); + await projectPage.goto(); + // "nyumba"'s first picture has two captions (English + Portuguese), i.e. more than one line. + await projectPage.selectEntryByFilter('nyumba'); + const picturesField = page.locator('[style*="grid-area: pictures"]').first(); + await loadFirstPicture(picturesField); + await picturesField.getByRole('button', {name: 'View Picture'}).first().click(); + const viewer = page.getByRole('dialog'); + await expect(viewer).toBeVisible({timeout: 5000}); + + // Expanded by default: both captions are shown. + await expect(viewer.getByText('A traditional house')).toBeVisible(); + await expect(viewer.getByText('Uma casa tradicional')).toBeVisible(); + + const toggle = viewer.getByRole('button', {name: 'Toggle captions'}); + // Collapse: only the first non-empty caption remains. + await toggle.click(); + await expect(viewer.getByText('Uma casa tradicional')).toHaveCount(0); + await expect(viewer.getByText('A traditional house')).toBeVisible(); + + // Expand again: both captions return. + await toggle.click(); + await expect(viewer.getByText('Uma casa tradicional')).toBeVisible(); + }); + test('a loaded image stays cached (project-scoped) after navigating to another entry and back', async ({page}) => { const projectPage = new DemoProjectPage(page); await projectPage.goto(); From f113dd2d46d3dc073c5bebf70a16a8457032d414 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Mon, 20 Jul 2026 17:16:44 +0700 Subject: [PATCH 08/32] Show "click to load" only for pictures not available locally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that getFileStream takes a downloadIfMissing flag, implement the original design: a picture already cached locally loads automatically, and only a picture that would have to be fetched from the remote media service shows the "Click to load" / "Tap to load" placeholder. ImageService.ensureLocal (called from an $effect on render) probes with downloadIfMissing: false — a local file displays immediately; a NotFound result means "not downloaded" and surfaces the placeholder. Clicking it calls download() (downloadIfMissing: true) to fetch and display. The project-scoped cache still means a mediaUri loaded once shows everywhere. The in-memory demo API now simulates a remote media service: pre-seeded pictures aren't available locally until downloaded once, while uploaded files are local. Tests updated to match (uploads auto-load; the demo's pre-seeded pictures click-to-download). Also drops a stray third argument to the shared downloadPicture helper in EditPictureDialog, and updates picture-loading-findings.md to record the final design. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../field-editors/EditPictureDialog.svelte | 2 +- .../field-editors/PictureImage.svelte | 29 +++++++----- .../field-editors/image-service.svelte.ts | 46 +++++++++++++------ .../src/project/demo/in-memory-demo-api.ts | 12 ++++- .../viewer/tests/ui/sense-pictures.test.ts | 35 +++++++------- picture-loading-findings.md | 45 ++++++++++-------- 6 files changed, 106 insertions(+), 63 deletions(-) diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte index c52bfc9a3e..d10025acf4 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte @@ -70,7 +70,7 @@ if (downloading) return; downloading = true; try { - const result = await downloadPictureFile(api, mediaUri, true); + const result = await downloadPictureFile(api, mediaUri); if (!result.success) { AppNotification.display(result.errorMessage ?? $t`Unable to download the picture`, {type: 'error'}); } diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte index 06af667636..dcd8fca7e2 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte @@ -67,9 +67,10 @@ event.preventDefault(); return; } - // First click on an unloaded picture loads it; once loaded, a click opens the viewer. - if (loadState.status === 'not-loaded') { - imageService.load(mediaUri); + // A picture that isn't available locally is downloaded on the first click; once loaded, a click + // opens the viewer. + if (loadState.status === 'not-downloaded') { + imageService.download(mediaUri); return; } onView?.(); @@ -92,18 +93,22 @@ const imageService = sharedImageService ?? localImageService!; onDestroy(() => localImageService?.dispose()); - // Nothing is fetched up front: a picture whose image isn't in the (entry-scoped) cache shows a - // "click to load" placeholder and is loaded only when the user clicks it. Once a mediaUri is in - // the cache, every picture sharing it — and the dialogs/viewer — display it immediately. + // A picture already available locally loads automatically; one that would have to be downloaded + // from the remote media service shows a "click/tap to load" placeholder instead and is fetched + // only when clicked. The cache is shared, so a mediaUri loaded once (here, in a dialog, or in + // another entry) displays immediately everywhere. const mediaUri = $derived(picture.mediaUri); + $effect(() => { + imageService.ensureLocal(mediaUri); + }); const loadState = $derived(imageService.get(mediaUri)); - // Clickable to load (when not yet loaded) or to open the viewer (when loaded and interactive). - const needsLoad = $derived(loadState.status === 'not-loaded'); - const clickable = $derived(needsLoad || (loadState.status === 'loaded' && interactive)); + // Clickable to download (when not available locally) or to open the viewer (when loaded and interactive). + const needsDownload = $derived(loadState.status === 'not-downloaded'); + const clickable = $derived(needsDownload || (loadState.status === 'loaded' && interactive)); const showMenu = $derived(interactive && !!onEdit && !!onDownload && !!onDelete); const loadLabel = $derived(IsMobile.value ? $t`Tap to load` : $t`Click to load`); - const clickLabel = $derived(needsLoad ? loadLabel : $t`View Picture`); + const clickLabel = $derived(needsDownload ? loadLabel : $t`View Picture`); function errorText(state: Extract): string { switch (state.reason) { @@ -121,8 +126,8 @@ {#if loadState.status === 'loaded'} {caption - {:else if loadState.status === 'not-loaded'} - + {:else if loadState.status === 'not-downloaded'} +
{loadLabel} diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts b/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts index b56531ae11..3a4b23e814 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts +++ b/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts @@ -4,17 +4,21 @@ import {ReadFileResult} from '$lib/dotnet-types/generated-types/MiniLcm/Media/Re import {useProjectContext} from '$project/project-context.svelte'; export type ImageLoadState = - | {status: 'not-loaded'} | {status: 'loading'} | {status: 'loaded'; url: string} + | {status: 'not-downloaded'} | {status: 'error'; reason: 'not-found' | 'offline' | 'unknown'; detail?: string}; /** - * Loads picture images once per mediaUri and hands out a shared blob object URL. Scoped to the open - * project (see useImageService), so identical mediaUris across a sense's pictures — or the same + * Loads picture images and hands out a shared blob object URL, cached per mediaUri. Scoped to the + * open project (see useImageService), so identical mediaUris across a sense's pictures — the same * picture shown in a dialog, or revisited after navigating to another entry and back — are fetched - * and decoded a single time. The cached object URLs live until the project is closed, then dispose() + * and decoded a single time. Cached object URLs live until the project is closed, then dispose() * revokes them. + * + * ensureLocal() shows a picture that's already available locally without touching the network; a + * picture that would have to be fetched from the remote media service stays 'not-downloaded' until + * download() is called (wired to a "click/tap to load" placeholder). */ export class ImageService { readonly #getApi: () => IMiniLcmJsInvokable | undefined; @@ -26,32 +30,48 @@ export class ImageService { } /** - * Begins loading `mediaUri` on demand (e.g. from a click). No-op if it's already been requested, - * so repeated clicks — or several pictures sharing one mediaUri — trigger a single fetch. - * Nothing loads until this is called: an untouched mediaUri stays {@link get}-reported as 'not-loaded'. + * Loads `mediaUri` if it's already cached locally, without downloading a remote file. Runs once + * per mediaUri (call from an $effect); a file that isn't local resolves to 'not-downloaded'. */ - load(mediaUri: string): void { + ensureLocal(mediaUri: string): void { if (this.#cache.has(mediaUri)) return; this.#cache.set(mediaUri, {status: 'loading'}); - void this.#load(mediaUri); + void this.#load(mediaUri, false); } - /** Reactive load-state for `mediaUri`. Call from a $derived; 'not-loaded' until load() is called. */ + /** + * Downloads `mediaUri` from the remote media service on demand (e.g. a click on the placeholder). + * No-op while it's already loading or loaded. + */ + download(mediaUri: string): void { + const current = this.#cache.get(mediaUri); + if (current?.status === 'loading' || current?.status === 'loaded') return; + this.#cache.set(mediaUri, {status: 'loading'}); + void this.#load(mediaUri, true); + } + + /** Reactive load-state for `mediaUri`. Call from a $derived; 'loading' until ensureLocal resolves. */ get(mediaUri: string): ImageLoadState { - return this.#cache.get(mediaUri) ?? {status: 'not-loaded'}; + return this.#cache.get(mediaUri) ?? {status: 'loading'}; } // getFileStream reports failure via the response (not exceptions), so we branch on it; a thrown // error (e.g. from blob()) propagates to the global handler, matching the rest of the viewer. - async #load(mediaUri: string): Promise { + async #load(mediaUri: string, downloadIfMissing: boolean): Promise { const api = this.#getApi(); if (!api) { this.#cache.set(mediaUri, {status: 'error', reason: 'unknown'}); return; } - const file = await api.getFileStream(mediaUri); + const file = await api.getFileStream(mediaUri, downloadIfMissing); if (this.#disposed) return; if (!file.stream) { + // A local-only probe (downloadIfMissing=false) reports NotFound when the file simply isn't + // cached yet — that's the click-to-download case, not an error. + if (!downloadIfMissing && file.result === ReadFileResult.NotFound) { + this.#cache.set(mediaUri, {status: 'not-downloaded'}); + return; + } const reason = file.result === ReadFileResult.NotFound ? 'not-found' : file.result === ReadFileResult.Offline ? 'offline' diff --git a/frontend/viewer/src/project/demo/in-memory-demo-api.ts b/frontend/viewer/src/project/demo/in-memory-demo-api.ts index 6da02731cf..64a2c609d1 100644 --- a/frontend/viewer/src/project/demo/in-memory-demo-api.ts +++ b/frontend/viewer/src/project/demo/in-memory-demo-api.ts @@ -722,10 +722,14 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { // Maps an already-uploaded filename to its mediaUri, mirroring how the real server dedups by // filename (LcmMediaService keys by the file name within the project's resource cache). #uploadedFilenames = new Map(); + // Pre-seeded demo pictures that have been "downloaded" from the (simulated) remote media service, + // so a later local-only request returns them instead of NotFound. + #downloadedRemoteFiles = new Set(); - getFileStream(mediaUri: string, _downloadIfMissing: boolean): Promise { + getFileStream(mediaUri: string, downloadIfMissing: boolean): Promise { const uploaded = this.#uploadedFiles.get(mediaUri); if (uploaded) { + // Files uploaded this session live locally, so they're available without a download. return Promise.resolve({ result: ReadFileResult.Success, fileName: mediaUri.split('/').pop() ?? 'demo-upload', @@ -737,6 +741,12 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { } const svg = demoPictureSvgs[mediaUri]; if (!svg) return Promise.resolve({result: ReadFileResult.NotFound}); + // Pre-seeded demo pictures stand in for a remote media service: not available locally until + // downloaded once (mirroring how a synced project fetches images on demand). + if (!this.#downloadedRemoteFiles.has(mediaUri)) { + if (!downloadIfMissing) return Promise.resolve({result: ReadFileResult.NotFound}); + this.#downloadedRemoteFiles.add(mediaUri); + } const blob = new Blob([svg], {type: 'image/svg+xml'}); return Promise.resolve({ result: ReadFileResult.Success, diff --git a/frontend/viewer/tests/ui/sense-pictures.test.ts b/frontend/viewer/tests/ui/sense-pictures.test.ts index af89e7ecfd..7316007a74 100644 --- a/frontend/viewer/tests/ui/sense-pictures.test.ts +++ b/frontend/viewer/tests/ui/sense-pictures.test.ts @@ -28,11 +28,12 @@ test.describe('Sense pictures', () => { const picturesField = page.locator('[style*="grid-area: pictures"]').first(); await expect(picturesField).toBeVisible({timeout: 5000}); - // Pictures don't auto-load: the field first shows a "Click to load" placeholder, no image. + // The demo's pre-seeded pictures stand in for a remote media service, so they aren't available + // locally: the field shows a "Click to load" placeholder rather than auto-loading. await expect(picturesField.getByRole('button', {name: 'Click to load'}).first()).toBeVisible({timeout: 5000}); await expect(picturesField.locator('img')).toHaveCount(0); - // Clicking loads it — a blob: src proves the full pipeline ran (getFileStream -> Blob -> object url). + // Clicking downloads it — a blob: src proves the full pipeline ran (getFileStream -> Blob -> object url). await loadFirstPicture(picturesField); // Caption is rendered from the best analysis alternative (shown regardless of image load). @@ -73,11 +74,12 @@ test.describe('Sense pictures', () => { buffer: TEST_PNG, }); - // The uploaded picture is added as a "click to load" placeholder (not auto-loaded); clicking - // it loads the image via getFileStream into a blob url. - await expect(picturesField.locator('img')).toHaveCount(0); - await loadFirstPicture(picturesField); - await expect(picturesField.locator('img').first()).toBeVisible({timeout: 5000}); + // An uploaded picture is available locally, so it loads automatically (no "click to load" + // placeholder) and renders into a blob url. + const image = picturesField.locator('img').first(); + await expect(image).toBeVisible({timeout: 5000}); + await expect(image).toHaveAttribute('src', /^blob:/); + await expect(picturesField.getByRole('button', {name: 'Click to load'})).toHaveCount(0); }); test('re-uploading an existing file adds a second picture that reuses it', async ({page}) => { @@ -89,15 +91,15 @@ test.describe('Sense pictures', () => { await expect(picturesField).toBeVisible({timeout: 5000}); const fileInput = picturesField.locator('input[type="file"]'); - // First upload adds a picture; load it (click) so its image enters the entry-scoped cache. + // First upload adds a picture; uploaded files are local, so it loads automatically. await fileInput.setInputFiles({name: 'shared.png', mimeType: 'image/png', buffer: TEST_PNG}); - await loadFirstPicture(picturesField); + await expect(picturesField.locator('img').first()).toHaveAttribute('src', /^blob:/, {timeout: 5000}); // Uploading the same filename again -> server reports AlreadyExists with the existing mediaUri; - // that mediaUri is already cached, so the second picture shows immediately without a click. + // it's already cached, so the second picture also renders immediately. await fileInput.setInputFiles({name: 'shared.png', mimeType: 'image/png', buffer: TEST_PNG}); - // Both pictures now render an image (the second straight from the cache, never clicked). + // Both pictures now render an image. await expect(picturesField.locator('img')).toHaveCount(2, {timeout: 5000}); // Both pictures point at the same uploaded file (mediaUri), so the entry-scoped cache backs @@ -119,8 +121,8 @@ test.describe('Sense pictures', () => { await picturesField.locator('input[type="file"]').setInputFiles({ name: 'photo.png', mimeType: 'image/png', buffer: TEST_PNG, }); - // A new picture starts unloaded; click "Click to load" so callers get a loaded picture. - await loadFirstPicture(picturesField); + // An uploaded picture is local, so it loads automatically; wait for it before returning. + await expect(picturesField.locator('img').first()).toHaveAttribute('src', /^blob:/, {timeout: 5000}); return picturesField; } @@ -212,10 +214,9 @@ test.describe('Sense pictures', () => { name: 'replacement.png', mimeType: 'image/png', buffer: TEST_PNG, }); - // The replacement is a new (uncached) mediaUri, so the preview shows "click to load"; load it. - await loadFirstPicture(dialog); - // The dialog now previews the replacement, but the field picture is unchanged until Submit. - await expect(dialog.locator('img')).toHaveAttribute('src', /^blob:/); + // The replacement is an uploaded (local) file, so the dialog previews it immediately; the field + // picture is unchanged until Submit. + await expect(dialog.locator('img')).toHaveAttribute('src', /^blob:/, {timeout: 5000}); await expect(fieldImage).toHaveAttribute('src', originalSrc ?? ''); await dialog.getByRole('button', {name: 'Submit'}).click(); diff --git a/picture-loading-findings.md b/picture-loading-findings.md index ee52c510a1..4db765d211 100644 --- a/picture-loading-findings.md +++ b/picture-loading-findings.md @@ -30,28 +30,35 @@ locally (so it can auto-display it) versus only available remotely (so it should (`FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.GetFileStream`), so they have no remote/download concept at all — they are always local. -### What a true locality feature would have required (not done) +### What a true locality feature required (subsequently done) -A 🔴 cross-boundary change to the shared MiniLcm media API: +A cross-boundary change to the shared MiniLcm media API — implemented as `downloadIfMissing`: -1. `MiniLcm/Media/ReadFileResult.cs` — a new value, e.g. `RemoteNotLoaded`. -2. `MiniLcm/IMiniLcmReadApi.GetFileStream(MediaUri, bool download = true)` — a new flag. -3. `LcmCrdt/CrdtMiniLcmApi` + `LcmCrdt/MediaServer/LcmMediaService` — when not cached locally and - `download == false`, return `RemoteNotLoaded` instead of downloading. -4. `FwDataMiniLcmBridge/Api/FwDataMiniLcmApi` — flag is a no-op (files are always local). -5. `FwLiteShared/Services/MiniLcmJsInvokable` — thread the flag, then rebuild `FwLiteShared` to - regenerate the TypeScript types (`getFileStream`, `ReadFileResult`). -6. Frontend + the in-memory demo API to exercise it. +1. `MiniLcm/IMiniLcmReadApi.GetFileStream(MediaUri, bool downloadIfMissing = true)` — the flag. +2. `LcmCrdt/CrdtMiniLcmApi` + `LcmCrdt/MediaServer/LcmMediaService` — when not cached locally and + `downloadIfMissing == false`, return `ReadFileResult.NotFound` instead of downloading (reusing + `NotFound` rather than adding a new enum value). +3. `FwDataMiniLcmBridge/Api/FwDataMiniLcmApi` — flag is a no-op (files are always local). +4. `FwLiteShared/Services/MiniLcmJsInvokable` — threads the flag; the regenerated TypeScript + `getFileStream(mediaUri, downloadIfMissing)` is what the viewer consumes. +5. Frontend + the in-memory demo API (which simulates a remote media service for its pre-seeded + pictures) exercise it. -## Decision +## Decision (final) -Skip the backend/locality work. Instead, gate auto-display purely on the **entry-view image cache** -(`frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts`): +An interim cache-based "click to load" (no backend change) shipped first, then the backend gained a +`downloadIfMissing` flag on `GetFileStream`, which let us implement the original locality-based +design: -- A picture whose `mediaUri` is **not in the cache** shows a "Click to load" / "Tap to load" - placeholder (styled like the error placeholder) and does not fetch anything up front. -- Clicking/tapping the placeholder loads the image into the cache, replacing the placeholder. -- Because the cache is shared for the whole entry view, once a `mediaUri` is loaded, every picture - that shares it — and the edit dialog / fullscreen viewer — display it immediately from the cache. +- `GetFileStream(mediaUri, downloadIfMissing)` — when `false`, a file that isn't cached locally + returns `ReadFileResult.NotFound` **without** downloading (no new enum value was needed; `NotFound` + from a local-only probe is treated as "not downloaded yet"). When `true`, it downloads as before. +- The viewer's `ImageService` probes each picture with `downloadIfMissing: false` on render + (`ensureLocal`): a locally-available picture displays immediately; one that would have to be + fetched from the remote media service stays `not-downloaded` and shows a "Click to load" / + "Tap to load" placeholder. +- Clicking the placeholder calls `download()` (`downloadIfMissing: true`) to fetch and display it. +- The cache is project-scoped, so once a `mediaUri` is loaded, every picture sharing it — and the + edit dialog / fullscreen viewer, and revisits after navigating entries — display it immediately. -No backend API change. +FwData pictures are always local, so they always auto-load. From 7a1db7630ea08fd8d959f2e66ffb12e3a05879bf Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Mon, 20 Jul 2026 17:27:50 +0700 Subject: [PATCH 09/32] Delete planning document that shouldn't be merged This was an accidental commit of a planning document, which doesn't belong in the PR. --- picture-loading-findings.md | 64 ------------------------------------- 1 file changed, 64 deletions(-) delete mode 100644 picture-loading-findings.md diff --git a/picture-loading-findings.md b/picture-loading-findings.md deleted file mode 100644 index 4db765d211..0000000000 --- a/picture-loading-findings.md +++ /dev/null @@ -1,64 +0,0 @@ -# Picture loading: local-vs-remote findings - -Investigation into detecting whether a sense picture is available **locally** vs **only -remotely**, and why the feature was ultimately implemented as a cache-based "click to load" -instead of a true locality check. - -## Question - -Can the viewer frontend tell, at render time, whether a picture's image is already available -locally (so it can auto-display it) versus only available remotely (so it should defer loading)? - -## Finding: locality is backend runtime state, not derivable on the frontend - -- A picture references its image via `mediaUri`, a `sil-media://{authority}/{fileId}` URI - (`backend/FwLite/MiniLcm/Media/MediaUri.cs`). The URI encodes a `fileId` + `authority`; it does - **not** encode whether the file is cached on disk. Locality is dynamic — a remote file becomes - local after it is downloaded — so the same `mediaUri` is used before and after caching. -- The frontend loads images through `IMiniLcmJsInvokable.getFileStream(mediaUri)` - (`frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts`). - There is **no** parameter or companion method exposing "is this local?" and no "local-only" mode. -- On the backend, `LcmCrdt/MediaServer/LcmMediaService.GetFileStream(fileId)` is where locality is - actually known: - - `resourceService.GetLocalResource(fileId)` returns the local file if cached. - - If it is **not** cached, the method **downloads it** (or returns `ReadFileResult.Offline` when - offline). There is no way to ask "would you have to download this?" without triggering the - download. -- `ReadFileResult` (`backend/FwLite/MiniLcm/Media/ReadFileResult.cs`) has `Success`, `NotFound`, - `Offline`, `NotSupported`, `Error` — nothing for "available remotely but not downloaded". -- FwData (FieldWorks desktop) projects store pictures as local files on disk - (`FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.GetFileStream`), so they have no remote/download concept - at all — they are always local. - -### What a true locality feature required (subsequently done) - -A cross-boundary change to the shared MiniLcm media API — implemented as `downloadIfMissing`: - -1. `MiniLcm/IMiniLcmReadApi.GetFileStream(MediaUri, bool downloadIfMissing = true)` — the flag. -2. `LcmCrdt/CrdtMiniLcmApi` + `LcmCrdt/MediaServer/LcmMediaService` — when not cached locally and - `downloadIfMissing == false`, return `ReadFileResult.NotFound` instead of downloading (reusing - `NotFound` rather than adding a new enum value). -3. `FwDataMiniLcmBridge/Api/FwDataMiniLcmApi` — flag is a no-op (files are always local). -4. `FwLiteShared/Services/MiniLcmJsInvokable` — threads the flag; the regenerated TypeScript - `getFileStream(mediaUri, downloadIfMissing)` is what the viewer consumes. -5. Frontend + the in-memory demo API (which simulates a remote media service for its pre-seeded - pictures) exercise it. - -## Decision (final) - -An interim cache-based "click to load" (no backend change) shipped first, then the backend gained a -`downloadIfMissing` flag on `GetFileStream`, which let us implement the original locality-based -design: - -- `GetFileStream(mediaUri, downloadIfMissing)` — when `false`, a file that isn't cached locally - returns `ReadFileResult.NotFound` **without** downloading (no new enum value was needed; `NotFound` - from a local-only probe is treated as "not downloaded yet"). When `true`, it downloads as before. -- The viewer's `ImageService` probes each picture with `downloadIfMissing: false` on render - (`ensureLocal`): a locally-available picture displays immediately; one that would have to be - fetched from the remote media service stays `not-downloaded` and shows a "Click to load" / - "Tap to load" placeholder. -- Clicking the placeholder calls `download()` (`downloadIfMissing: true`) to fetch and display it. -- The cache is project-scoped, so once a `mediaUri` is loaded, every picture sharing it — and the - edit dialog / fullscreen viewer, and revisits after navigating entries — display it immediately. - -FwData pictures are always local, so they always auto-load. From e014ec823aec3a88e7b631cf674ac9bf74ea2459 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 21 Jul 2026 09:49:37 +0700 Subject: [PATCH 10/32] Make PicturesEditor.pictures bindable and update the sense directly `pictures` is now $bindable, so add/delete/update mutate the sense's picture list directly for immediate UI feedback instead of waiting for the change to round-trip back through the entry-changed reload: - addPicture appends the new picture, deletePicture filters it out, submitEdits swaps it. - Each reassigns `pictures` (not in-place push/splice) so the parent's bound setter fires. SenseEditorPrimitive binds via a getter/setter (bind:pictures={() => sense.pictures ?? [], (v) => (sense.pictures = v)}), matching the function-binding idiom used elsewhere; the getter keeps the longstanding guard against a runtime-undefined sense.pictures. Pictures still persist through their own api.createPicture/updatePicture/deletePicture calls (not the sense onchange flow), so nothing double-persists. The edit dialog now retains the last picture it showed (lastEditedPicture) so it stays mounted for its close animation even though a delete removes the picture from `pictures` sooner than the reload used to. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../field-editors/PicturesEditor.svelte | 26 +++++++++++++------ .../SenseEditorPrimitive.svelte | 2 +- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte index bbfbc494fc..e6dda0dd40 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -19,7 +19,9 @@ senseId: string; readonly?: boolean; }; - const {pictures, entryId, senseId, readonly = false}: Props = $props(); + // `pictures` is bindable so add/delete/update mutate the sense directly (immediate UI) rather than + // waiting for the change to round-trip back through an entry reload. + let {pictures = $bindable(), entryId, senseId, readonly = false}: Props = $props(); const api = useLexboxApi(); const dialogsService = useDialogsService(); @@ -29,13 +31,19 @@ // replace/delete/caption operations invoked from the edit dialog. let busyAction = $state<'add' | 'edit' | null>(null); - // The picture currently open in the edit dialog, tracked by id so it stays in sync with the - // reloaded entry after each change (e.g. its image updates in the dialog after a replace). + // The picture currently open in the edit dialog, tracked by id so the dialog reflects live edits + // to `pictures` (e.g. its image updates after a replace). let editingPictureId = $state(); const editingPicture = $derived(editingPictureId ? pictures.find((p) => p.id === editingPictureId) : undefined); let editDialogOpen = $state(false); - - // The fullscreen viewer, tracked by id so prev/next and deletion stay in sync with the reloaded entry. + // Retain the last picture the dialog showed so it stays mounted for its close animation even after + // that picture is removed from `pictures` (e.g. deleted from within the dialog). + let lastEditedPicture = $state(); + $effect(() => { + if (editingPicture) lastEditedPicture = editingPicture; + }); + + // The fullscreen viewer, tracked by id so prev/next and (direct) deletion stay in sync with `pictures`. let viewerPictureId = $state(); let viewerOpen = $state(false); @@ -92,7 +100,7 @@ if (!mediaUri) return; const picture: IPicture = {id: randomId(), order: pictures.length, mediaUri, caption: {}}; await api.createPicture(entryId, senseId, picture); - // The change surfaces via the entry-changed event, which reloads the entry. + pictures = [...pictures, picture]; } finally { busyAction = null; } @@ -118,6 +126,7 @@ busyAction = 'edit'; try { await api.updatePicture(entryId, senseId, before, after); + pictures = pictures.map((p) => (p.id === after.id ? after : p)); } finally { busyAction = null; } @@ -131,6 +140,7 @@ // has time to play; harmless when the delete came from the field/menu instead. editDialogOpen = false; await api.deletePicture(entryId, senseId, pictureId); + pictures = pictures.filter((p) => p.id !== pictureId); } finally { busyAction = null; } @@ -198,10 +208,10 @@ {/if}
-{#if editingPicture} +{#if lastEditedPicture} uploadReplacement(file)} onSubmit={(after) => void submitEdits(after)} onDelete={() => deleteEditingPicture()} diff --git a/frontend/viewer/src/lib/entry-editor/object-editors/SenseEditorPrimitive.svelte b/frontend/viewer/src/lib/entry-editor/object-editors/SenseEditorPrimitive.svelte index fa8c320ffc..022a6fc38c 100644 --- a/frontend/viewer/src/lib/entry-editor/object-editors/SenseEditorPrimitive.svelte +++ b/frontend/viewer/src/lib/entry-editor/object-editors/SenseEditorPrimitive.svelte @@ -102,7 +102,7 @@ - + sense.pictures ?? [], (v) => (sense.pictures = v)} entryId={sense.entryId} senseId={sense.id} {readonly} /> From e658693265b080bdc30fdf7473a0f7d81b4cde26 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 21 Jul 2026 10:13:24 +0700 Subject: [PATCH 11/32] Fix Claude's misunderstanding of MiniLcm API --- .../lib/entry-editor/field-editors/PicturesEditor.svelte | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte index e6dda0dd40..6508fc6cc2 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -99,8 +99,8 @@ const mediaUri = await uploadFile(file); if (!mediaUri) return; const picture: IPicture = {id: randomId(), order: pictures.length, mediaUri, caption: {}}; - await api.createPicture(entryId, senseId, picture); - pictures = [...pictures, picture]; + const newPicture = await api.createPicture(entryId, senseId, picture); + pictures = [...pictures, newPicture]; } finally { busyAction = null; } @@ -125,8 +125,8 @@ if (!before) return; busyAction = 'edit'; try { - await api.updatePicture(entryId, senseId, before, after); - pictures = pictures.map((p) => (p.id === after.id ? after : p)); + const newPicture = await api.updatePicture(entryId, senseId, before, after); + pictures = pictures.map((p) => (p.id === after.id ? newPicture : p)); } finally { busyAction = null; } From d530038a772bdf6c98ba5bb58ab8eeb3fe3e7e6b Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 21 Jul 2026 10:42:14 +0700 Subject: [PATCH 12/32] Add a disclosure chevron to the viewer caption toggle The caption block is clickable to collapse/expand but had no affordance signalling it. Add a disclosure chevron (i-mdi-chevron-down) at its trailing edge: it points down while collapsed (more captions available below) and rotates to point up while expanded, with a transition. Standard show-more/less indicator; the button keeps aria-expanded for AT. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../field-editors/PictureViewerDialog.svelte | 25 +++++++++++++------ .../viewer/tests/ui/sense-pictures.test.ts | 11 ++++++-- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte index d9d478d7d5..00987f6c43 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte @@ -113,20 +113,29 @@ {#if captions.length > 0} + collapse to just the first caption (line-clamp-1), click again to show them all. The + disclosure chevron signals it's toggleable. --> {/if} {/if} diff --git a/frontend/viewer/tests/ui/sense-pictures.test.ts b/frontend/viewer/tests/ui/sense-pictures.test.ts index 7316007a74..4d14107a10 100644 --- a/frontend/viewer/tests/ui/sense-pictures.test.ts +++ b/frontend/viewer/tests/ui/sense-pictures.test.ts @@ -397,14 +397,21 @@ test.describe('Sense pictures', () => { await expect(viewer.getByText('Uma casa tradicional')).toBeVisible(); const toggle = viewer.getByRole('button', {name: 'Toggle captions'}); - // Collapse: only the first non-empty caption remains. + // A disclosure chevron signals the toggle; it points up (rotated) while expanded. + const chevron = toggle.locator('.i-mdi-chevron-down'); + await expect(chevron).toBeVisible(); + await expect(chevron).toHaveClass(/rotate-180/); + + // Collapse: only the first non-empty caption remains, and the chevron points down. await toggle.click(); await expect(viewer.getByText('Uma casa tradicional')).toHaveCount(0); await expect(viewer.getByText('A traditional house')).toBeVisible(); + await expect(chevron).not.toHaveClass(/rotate-180/); - // Expand again: both captions return. + // Expand again: both captions return and the chevron points up. await toggle.click(); await expect(viewer.getByText('Uma casa tradicional')).toBeVisible(); + await expect(chevron).toHaveClass(/rotate-180/); }); test('a loaded image stays cached (project-scoped) after navigating to another entry and back', async ({page}) => { From d8f2dde833d9d7c74589cdc051434aa3224b15d4 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 21 Jul 2026 12:41:50 +0700 Subject: [PATCH 13/32] Surface image-load exceptions as an error state If getFileStream rejected or the stream/blob processing threw, #load let the exception propagate but left the cache on 'loading', so the picture spun forever. Wrap the fetch + stream processing in a try/catch that sets an error state (with the exception's message as detail) and then re-throws, so the UI recovers while the global handler still reports it. The disposed checks and the success / not-downloaded paths are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../field-editors/image-service.svelte.ts | 46 +++++++++++-------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts b/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts index 3a4b23e814..33dabc1386 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts +++ b/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts @@ -55,34 +55,42 @@ export class ImageService { return this.#cache.get(mediaUri) ?? {status: 'loading'}; } - // getFileStream reports failure via the response (not exceptions), so we branch on it; a thrown - // error (e.g. from blob()) propagates to the global handler, matching the rest of the viewer. + // getFileStream reports expected failures via the response (branched on below). An unexpected + // thrown error (e.g. from blob()) is surfaced as an error state — else the picture would spin + // forever on 'loading' — and then re-thrown so the global handler still reports it. async #load(mediaUri: string, downloadIfMissing: boolean): Promise { const api = this.#getApi(); if (!api) { this.#cache.set(mediaUri, {status: 'error', reason: 'unknown'}); return; } - const file = await api.getFileStream(mediaUri, downloadIfMissing); - if (this.#disposed) return; - if (!file.stream) { - // A local-only probe (downloadIfMissing=false) reports NotFound when the file simply isn't - // cached yet — that's the click-to-download case, not an error. - if (!downloadIfMissing && file.result === ReadFileResult.NotFound) { - this.#cache.set(mediaUri, {status: 'not-downloaded'}); + try { + const file = await api.getFileStream(mediaUri, downloadIfMissing); + if (this.#disposed) return; + if (!file.stream) { + // A local-only probe (downloadIfMissing=false) reports NotFound when the file simply isn't + // cached yet — that's the click-to-download case, not an error. + if (!downloadIfMissing && file.result === ReadFileResult.NotFound) { + this.#cache.set(mediaUri, {status: 'not-downloaded'}); + return; + } + const reason = + file.result === ReadFileResult.NotFound ? 'not-found' + : file.result === ReadFileResult.Offline ? 'offline' + : 'unknown'; + this.#cache.set(mediaUri, {status: 'error', reason, detail: file.errorMessage ?? undefined}); return; } - const reason = - file.result === ReadFileResult.NotFound ? 'not-found' - : file.result === ReadFileResult.Offline ? 'offline' - : 'unknown'; - this.#cache.set(mediaUri, {status: 'error', reason, detail: file.errorMessage ?? undefined}); - return; + const blob = await new Response(await file.stream.stream()).blob(); + // Bail before minting a URL if the service was torn down mid-load, else it would never be revoked. + if (this.#disposed) return; + this.#cache.set(mediaUri, {status: 'loaded', url: URL.createObjectURL(blob)}); + } catch (error) { + if (!this.#disposed) { + this.#cache.set(mediaUri, {status: 'error', reason: 'unknown', detail: error instanceof Error ? error.message : undefined}); + } + throw error; } - const blob = await new Response(await file.stream.stream()).blob(); - // Bail before minting a URL if the service was torn down mid-load, else it would never be revoked. - if (this.#disposed) return; - this.#cache.set(mediaUri, {status: 'loaded', url: URL.createObjectURL(blob)}); } dispose(): void { From 385e351ec4675caaa9af6b450795913e782d5cab Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 21 Jul 2026 13:40:34 +0700 Subject: [PATCH 14/32] Let users click an errored picture to retry loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit image-service already permits the retry (download() proceeds from any state except in-flight/loaded, so it re-attempts from an error); clarified its doc to say so. The gap was in the UI: PictureImage only routed clicks to download from the 'not-downloaded' state. Make the error state clickable too — clicking (or tapping) an errored picture calls download() to retry — and show a "Click to retry" / "Tap to retry" affordance on the error placeholder. Works in the field, dialog, and viewer (retry is non-mutating, so allowed even when readonly). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../field-editors/PictureImage.svelte | 19 ++++++++++++------- .../field-editors/image-service.svelte.ts | 6 ++++-- frontend/viewer/src/locales/en.po | 10 ++++++++++ frontend/viewer/src/locales/es.po | 8 ++++++++ frontend/viewer/src/locales/fr.po | 8 ++++++++ frontend/viewer/src/locales/id.po | 8 ++++++++ frontend/viewer/src/locales/ko.po | 8 ++++++++ frontend/viewer/src/locales/ms.po | 8 ++++++++ frontend/viewer/src/locales/sw.po | 8 ++++++++ frontend/viewer/src/locales/vi.po | 8 ++++++++ 10 files changed, 82 insertions(+), 9 deletions(-) diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte index dcd8fca7e2..74757c52c4 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte @@ -67,9 +67,9 @@ event.preventDefault(); return; } - // A picture that isn't available locally is downloaded on the first click; once loaded, a click - // opens the viewer. - if (loadState.status === 'not-downloaded') { + // A picture not available locally downloads on click; one that errored retries on click; once + // loaded, a click opens the viewer. + if (loadState.status === 'not-downloaded' || loadState.status === 'error') { imageService.download(mediaUri); return; } @@ -103,12 +103,15 @@ }); const loadState = $derived(imageService.get(mediaUri)); - // Clickable to download (when not available locally) or to open the viewer (when loaded and interactive). + // Clickable to download (not available locally), to retry (after an error), or to open the viewer + // (loaded and interactive). const needsDownload = $derived(loadState.status === 'not-downloaded'); - const clickable = $derived(needsDownload || (loadState.status === 'loaded' && interactive)); + const hasError = $derived(loadState.status === 'error'); + const clickable = $derived(needsDownload || hasError || (loadState.status === 'loaded' && interactive)); const showMenu = $derived(interactive && !!onEdit && !!onDownload && !!onDelete); const loadLabel = $derived(IsMobile.value ? $t`Tap to load` : $t`Click to load`); - const clickLabel = $derived(needsDownload ? loadLabel : $t`View Picture`); + const retryLabel = $derived(IsMobile.value ? $t`Tap to retry` : $t`Click to retry`); + const clickLabel = $derived(needsDownload ? loadLabel : hasError ? retryLabel : $t`View Picture`); function errorText(state: Extract): string { switch (state.reason) { @@ -137,9 +140,11 @@
{:else} -
+ +
{errorText(loadState)} + {retryLabel}
{/if} {/snippet} diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts b/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts index 33dabc1386..ab68325a82 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts +++ b/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts @@ -40,8 +40,10 @@ export class ImageService { } /** - * Downloads `mediaUri` from the remote media service on demand (e.g. a click on the placeholder). - * No-op while it's already loading or loaded. + * Loads `mediaUri`, downloading it from the remote media service if it isn't cached locally. + * Backs both the "click to load" placeholder and retry-after-error: it proceeds from any state + * except in-flight or already-loaded, so a click on an errored (or not-downloaded) picture + * re-attempts the load. */ download(mediaUri: string): void { const current = this.#cache.get(mediaUri); diff --git a/frontend/viewer/src/locales/en.po b/frontend/viewer/src/locales/en.po index 77a39eedf2..8e92439fdc 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -416,6 +416,11 @@ msgstr "clear" msgid "Click to load" msgstr "Click to load" +#. Shown on a picture whose image failed to load (desktop); clicking it retries the load +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Click to retry" +msgstr "Click to retry" + #. Button to close popup #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte @@ -2305,6 +2310,11 @@ msgstr "System" msgid "Tap to load" msgstr "Tap to load" +#. Shown on a picture whose image failed to load (mobile/touch); tapping it retries the load +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Tap to retry" +msgstr "Tap to retry" + #. Page title with task name #: src/project/tasks/TasksView.svelte msgid "Task {0}" diff --git a/frontend/viewer/src/locales/es.po b/frontend/viewer/src/locales/es.po index 38e0d00577..d4bed0068c 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -421,6 +421,10 @@ msgstr "borrar" msgid "Click to load" msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Click to retry" +msgstr "" + #. Button to close popup #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte @@ -2310,6 +2314,10 @@ msgstr "Sistema" msgid "Tap to load" msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Tap to retry" +msgstr "" + #. Page title with task name #: src/project/tasks/TasksView.svelte msgid "Task {0}" diff --git a/frontend/viewer/src/locales/fr.po b/frontend/viewer/src/locales/fr.po index 7fb5a1dde6..7c1cda0bf8 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -421,6 +421,10 @@ msgstr "effacer" msgid "Click to load" msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Click to retry" +msgstr "" + #. Button to close popup #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte @@ -2310,6 +2314,10 @@ msgstr "Système" msgid "Tap to load" msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Tap to retry" +msgstr "" + #. Page title with task name #: src/project/tasks/TasksView.svelte msgid "Task {0}" diff --git a/frontend/viewer/src/locales/id.po b/frontend/viewer/src/locales/id.po index bc6fd68322..cd6f6dcc8d 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -421,6 +421,10 @@ msgstr "jelas" msgid "Click to load" msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Click to retry" +msgstr "" + #. Button to close popup #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte @@ -2310,6 +2314,10 @@ msgstr "Sistem" msgid "Tap to load" msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Tap to retry" +msgstr "" + #. Page title with task name #: src/project/tasks/TasksView.svelte msgid "Task {0}" diff --git a/frontend/viewer/src/locales/ko.po b/frontend/viewer/src/locales/ko.po index bf72d3dea8..c7bc856f7a 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -421,6 +421,10 @@ msgstr "clear" msgid "Click to load" msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Click to retry" +msgstr "" + #. Button to close popup #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte @@ -2310,6 +2314,10 @@ msgstr "시스템" msgid "Tap to load" msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Tap to retry" +msgstr "" + #. Page title with task name #: src/project/tasks/TasksView.svelte msgid "Task {0}" diff --git a/frontend/viewer/src/locales/ms.po b/frontend/viewer/src/locales/ms.po index 72cddbb4a0..dded8010fa 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -421,6 +421,10 @@ msgstr "padam" msgid "Click to load" msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Click to retry" +msgstr "" + #. Button to close popup #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte @@ -2310,6 +2314,10 @@ msgstr "Sistem" msgid "Tap to load" msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Tap to retry" +msgstr "" + #. Page title with task name #: src/project/tasks/TasksView.svelte msgid "Task {0}" diff --git a/frontend/viewer/src/locales/sw.po b/frontend/viewer/src/locales/sw.po index 326292178d..78a01cd33a 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -421,6 +421,10 @@ msgstr "futa" msgid "Click to load" msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Click to retry" +msgstr "" + #. Button to close popup #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte @@ -2310,6 +2314,10 @@ msgstr "Mfumo" msgid "Tap to load" msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Tap to retry" +msgstr "" + #. Page title with task name #: src/project/tasks/TasksView.svelte msgid "Task {0}" diff --git a/frontend/viewer/src/locales/vi.po b/frontend/viewer/src/locales/vi.po index 6f0b4a50a8..e32ef60c74 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -421,6 +421,10 @@ msgstr "xóa" msgid "Click to load" msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Click to retry" +msgstr "" + #. Button to close popup #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte @@ -2310,6 +2314,10 @@ msgstr "Hệ thống" msgid "Tap to load" msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Tap to retry" +msgstr "" + #. Page title with task name #: src/project/tasks/TasksView.svelte msgid "Task {0}" From b7b0ef47f9c4f1459eab0ad74ce6f412e0d8561e Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 21 Jul 2026 13:47:28 +0700 Subject: [PATCH 15/32] Reset viewer caption collapse when the viewer opens collapsed was only reset in showPrevious/showNext, so opening the viewer directly (via openViewer) after collapsing and closing reopened the picture collapsed. Reset collapsed whenever the viewer opens, so every newly opened picture starts expanded; the navigation resets (which fire while the viewer stays open) are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../field-editors/PictureViewerDialog.svelte | 8 ++++++- .../viewer/tests/ui/sense-pictures.test.ts | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte index 00987f6c43..c4d19eaaae 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte @@ -34,8 +34,14 @@ if (open && pictureId !== undefined && !current) open = false; }); - // Captions collapse to just the first non-empty one on click; each picture starts expanded. + // Captions collapse to just the first non-empty one on click; each picture starts expanded — reset + // whenever the viewer opens (below) and on navigation (showPrevious/showNext). let collapsed = $state(false); + // Start expanded every time the viewer opens, so a collapse left over from a previous open doesn't + // carry into the next picture opened directly (not via the arrows). + $effect(() => { + if (open) collapsed = false; + }); const hasMultiple = $derived(pictures.length > 1); function showPrevious() { diff --git a/frontend/viewer/tests/ui/sense-pictures.test.ts b/frontend/viewer/tests/ui/sense-pictures.test.ts index 4d14107a10..e7b16decda 100644 --- a/frontend/viewer/tests/ui/sense-pictures.test.ts +++ b/frontend/viewer/tests/ui/sense-pictures.test.ts @@ -414,6 +414,30 @@ test.describe('Sense pictures', () => { await expect(chevron).toHaveClass(/rotate-180/); }); + test('reopening the viewer starts with captions expanded', async ({page}) => { + const projectPage = new DemoProjectPage(page); + await projectPage.goto(); + await projectPage.selectEntryByFilter('nyumba'); + const picturesField = page.locator('[style*="grid-area: pictures"]').first(); + await loadFirstPicture(picturesField); + await picturesField.getByRole('button', {name: 'View Picture'}).first().click(); + const viewer = page.getByRole('dialog'); + await expect(viewer).toBeVisible({timeout: 5000}); + + // Collapse, then close the viewer. + await viewer.getByRole('button', {name: 'Toggle captions'}).click(); + await expect(viewer.getByText('Uma casa tradicional')).toHaveCount(0); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).toHaveCount(0); + + // Reopening (the direct flow, not via the arrows) starts expanded again — both captions show. + await picturesField.getByRole('button', {name: 'View Picture'}).first().click(); + const reopened = page.getByRole('dialog'); + await expect(reopened).toBeVisible({timeout: 5000}); + await expect(reopened.getByText('A traditional house')).toBeVisible(); + await expect(reopened.getByText('Uma casa tradicional')).toBeVisible(); + }); + test('a loaded image stays cached (project-scoped) after navigating to another entry and back', async ({page}) => { const projectPage = new DemoProjectPage(page); await projectPage.goto(); From f18c7aa9c5519e9422d6c63e156eeac33e37b3b1 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 21 Jul 2026 13:50:48 +0700 Subject: [PATCH 16/32] Better Svelte code than what Claude produced This was CodeRabbit's suggestion, and it's better than what Claude produced. Claude wrote a good E2E test for the scenario, though, so we'll keep Claude's test. --- .../entry-editor/field-editors/PictureViewerDialog.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte index c4d19eaaae..dc35cc37de 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte @@ -40,20 +40,20 @@ // Start expanded every time the viewer opens, so a collapse left over from a previous open doesn't // carry into the next picture opened directly (not via the arrows). $effect(() => { - if (open) collapsed = false; + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + pictureId; // Rerun the $effect when this changes + collapsed = false; }); const hasMultiple = $derived(pictures.length > 1); function showPrevious() { if (currentIndex > 0) { pictureId = pictures[currentIndex - 1].id; - collapsed = false; } } function showNext() { if (currentIndex >= 0 && currentIndex < pictures.length - 1) { pictureId = pictures[currentIndex + 1].id; - collapsed = false; } } From 013efc7241dde886ddd25c239db4a05425408df6 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 21 Jul 2026 13:59:48 +0700 Subject: [PATCH 17/32] Delete E2E test since spec decision changed Decided that if they collapse captions then reopen the same picture, the captions should stay collapsed. So we deleted the Claude-generated E2E test that was checking for the opposite. The test now fails because it is testing for UI flow that we have chosen to remove. --- .../field-editors/PictureViewerDialog.svelte | 6 ++--- .../viewer/tests/ui/sense-pictures.test.ts | 24 ------------------- 2 files changed, 3 insertions(+), 27 deletions(-) diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte index dc35cc37de..c4d19eaaae 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte @@ -40,20 +40,20 @@ // Start expanded every time the viewer opens, so a collapse left over from a previous open doesn't // carry into the next picture opened directly (not via the arrows). $effect(() => { - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - pictureId; // Rerun the $effect when this changes - collapsed = false; + if (open) collapsed = false; }); const hasMultiple = $derived(pictures.length > 1); function showPrevious() { if (currentIndex > 0) { pictureId = pictures[currentIndex - 1].id; + collapsed = false; } } function showNext() { if (currentIndex >= 0 && currentIndex < pictures.length - 1) { pictureId = pictures[currentIndex + 1].id; + collapsed = false; } } diff --git a/frontend/viewer/tests/ui/sense-pictures.test.ts b/frontend/viewer/tests/ui/sense-pictures.test.ts index e7b16decda..4d14107a10 100644 --- a/frontend/viewer/tests/ui/sense-pictures.test.ts +++ b/frontend/viewer/tests/ui/sense-pictures.test.ts @@ -414,30 +414,6 @@ test.describe('Sense pictures', () => { await expect(chevron).toHaveClass(/rotate-180/); }); - test('reopening the viewer starts with captions expanded', async ({page}) => { - const projectPage = new DemoProjectPage(page); - await projectPage.goto(); - await projectPage.selectEntryByFilter('nyumba'); - const picturesField = page.locator('[style*="grid-area: pictures"]').first(); - await loadFirstPicture(picturesField); - await picturesField.getByRole('button', {name: 'View Picture'}).first().click(); - const viewer = page.getByRole('dialog'); - await expect(viewer).toBeVisible({timeout: 5000}); - - // Collapse, then close the viewer. - await viewer.getByRole('button', {name: 'Toggle captions'}).click(); - await expect(viewer.getByText('Uma casa tradicional')).toHaveCount(0); - await page.keyboard.press('Escape'); - await expect(page.getByRole('dialog')).toHaveCount(0); - - // Reopening (the direct flow, not via the arrows) starts expanded again — both captions show. - await picturesField.getByRole('button', {name: 'View Picture'}).first().click(); - const reopened = page.getByRole('dialog'); - await expect(reopened).toBeVisible({timeout: 5000}); - await expect(reopened.getByText('A traditional house')).toBeVisible(); - await expect(reopened.getByText('Uma casa tradicional')).toBeVisible(); - }); - test('a loaded image stays cached (project-scoped) after navigating to another entry and back', async ({page}) => { const projectPage = new DemoProjectPage(page); await projectPage.goto(); From 984a9caf83975d493538fc1411adf6b248af9cbe Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 21 Jul 2026 14:14:05 +0700 Subject: [PATCH 18/32] Add back in change that got accidentally removed While deleting the E2E test, I didn't realize the PictureViewerDialog change hadn't been saved, so its code was wrong. It's right now. --- .../entry-editor/field-editors/PictureViewerDialog.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte index c4d19eaaae..dc35cc37de 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte @@ -40,20 +40,20 @@ // Start expanded every time the viewer opens, so a collapse left over from a previous open doesn't // carry into the next picture opened directly (not via the arrows). $effect(() => { - if (open) collapsed = false; + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + pictureId; // Rerun the $effect when this changes + collapsed = false; }); const hasMultiple = $derived(pictures.length > 1); function showPrevious() { if (currentIndex > 0) { pictureId = pictures[currentIndex - 1].id; - collapsed = false; } } function showNext() { if (currentIndex >= 0 && currentIndex < pictures.length - 1) { pictureId = pictures[currentIndex + 1].id; - collapsed = false; } } From dac2122f330b597937dfd7d85f7c9ac0781e0c83 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 21 Jul 2026 14:44:02 +0700 Subject: [PATCH 19/32] Address minor CodeRabbit review comment --- frontend/viewer/src/locales/en.po | 2 +- frontend/viewer/src/locales/es.po | 2 +- frontend/viewer/src/locales/fr.po | 2 +- frontend/viewer/src/locales/id.po | 2 +- frontend/viewer/src/locales/ko.po | 2 +- frontend/viewer/src/locales/ms.po | 2 +- frontend/viewer/src/locales/sw.po | 2 +- frontend/viewer/src/locales/vi.po | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/viewer/src/locales/en.po b/frontend/viewer/src/locales/en.po index 8e92439fdc..d2b089618d 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -1828,7 +1828,7 @@ msgstr "Pending" msgid "Pick a Part of Speech" msgstr "Pick a Part of Speech" -#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor +#. Three uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor; (3) title for fullscreen picture viewer dialog #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte diff --git a/frontend/viewer/src/locales/es.po b/frontend/viewer/src/locales/es.po index d4bed0068c..d095d7891d 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -1832,7 +1832,7 @@ msgstr "Pendiente" msgid "Pick a Part of Speech" msgstr "Escoge una parte de la voz" -#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor +#. Three uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor; (3) title for fullscreen picture viewer dialog #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte diff --git a/frontend/viewer/src/locales/fr.po b/frontend/viewer/src/locales/fr.po index 7c1cda0bf8..22cd11261d 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -1832,7 +1832,7 @@ msgstr "En attente" msgid "Pick a Part of Speech" msgstr "Choisir une partie du discours" -#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor +#. Three uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor; (3) title for fullscreen picture viewer dialog #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte diff --git a/frontend/viewer/src/locales/id.po b/frontend/viewer/src/locales/id.po index cd6f6dcc8d..2f6f688c3f 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -1832,7 +1832,7 @@ msgstr "Tertunda" msgid "Pick a Part of Speech" msgstr "Pilih Bagian dari Pidato" -#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor +#. Three uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor; (3) title for fullscreen picture viewer dialog #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte diff --git a/frontend/viewer/src/locales/ko.po b/frontend/viewer/src/locales/ko.po index c7bc856f7a..8a2e37bb31 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -1832,7 +1832,7 @@ msgstr "보류 중" msgid "Pick a Part of Speech" msgstr "품사 선택" -#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor +#. Three uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor; (3) title for fullscreen picture viewer dialog #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte diff --git a/frontend/viewer/src/locales/ms.po b/frontend/viewer/src/locales/ms.po index dded8010fa..bcd63a552e 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -1832,7 +1832,7 @@ msgstr "Tertunda" msgid "Pick a Part of Speech" msgstr "Pilih Bahagian Pertuturan" -#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor +#. Three uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor; (3) title for fullscreen picture viewer dialog #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte diff --git a/frontend/viewer/src/locales/sw.po b/frontend/viewer/src/locales/sw.po index 78a01cd33a..0b6658a905 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -1832,7 +1832,7 @@ msgstr "Inasubiri" msgid "Pick a Part of Speech" msgstr "Chagua Sehemu ya Mazungumzo" -#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor +#. Three uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor; (3) title for fullscreen picture viewer dialog #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte diff --git a/frontend/viewer/src/locales/vi.po b/frontend/viewer/src/locales/vi.po index e32ef60c74..3893ee731a 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -1832,7 +1832,7 @@ msgstr "Đang chờ xử lý" msgid "Pick a Part of Speech" msgstr "Chọn một Loại từ" -#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor +#. Three uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor; (3) title for fullscreen picture viewer dialog #: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte #: src/lib/entry-editor/field-editors/PicturesEditor.svelte From da8d9cb4395a007d881bbe7a6073aedbfd496429 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 21 Jul 2026 14:53:16 +0700 Subject: [PATCH 20/32] Rename function to avoid name conflicts Both locations where the `downloadPicture` function was used were locally renaming it in the import to `downloadPictureFile`. Simpler to just call it `downloadPictureFile` in the first place. --- .../src/lib/entry-editor/field-editors/EditPictureDialog.svelte | 2 +- .../src/lib/entry-editor/field-editors/PicturesEditor.svelte | 2 +- .../src/lib/entry-editor/field-editors/picture-actions.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte index d10025acf4..b56673ef5b 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte @@ -6,7 +6,7 @@ import {Button} from '$lib/components/ui/button'; import PictureImage from './PictureImage.svelte'; import {ACCEPTED_PICTURE_TYPES} from './picture-formats'; - import {downloadPicture as downloadPictureFile} from './picture-actions'; + import {downloadPictureFile} from './picture-actions'; import {t} from 'svelte-i18n-lingui'; import {useLexboxApi} from '$lib/services/service-provider'; import {AppNotification} from '$lib/notifications/notifications'; diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte index 6508fc6cc2..391a54ee1c 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -6,7 +6,7 @@ import EditPictureDialog from './EditPictureDialog.svelte'; import PictureViewerDialog from './PictureViewerDialog.svelte'; import {ACCEPTED_PICTURE_TYPES, isLosslessImage} from './picture-formats'; - import {downloadPicture as downloadPictureFile} from './picture-actions'; + import {downloadPictureFile} from './picture-actions'; import {t} from 'svelte-i18n-lingui'; import {useLexboxApi} from '$lib/services/service-provider'; import {useDialogsService} from '$lib/services/dialogs-service'; diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/picture-actions.ts b/frontend/viewer/src/lib/entry-editor/field-editors/picture-actions.ts index a2240d5efd..cde842ec7d 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/picture-actions.ts +++ b/frontend/viewer/src/lib/entry-editor/field-editors/picture-actions.ts @@ -10,7 +10,7 @@ export type DownloadPictureResult = {success: true} | {success: false; errorMess * getFileStream reports failure via the response (not an exception), so the caller decides how to * surface `errorMessage` — this keeps the notification/translation in the calling component. */ -export async function downloadPicture(api: IMiniLcmJsInvokable, mediaUri: string): Promise { +export async function downloadPictureFile(api: IMiniLcmJsInvokable, mediaUri: string): Promise { const file = await api.getFileStream(mediaUri, true); if (!file.stream) return {success: false, errorMessage: file.errorMessage ?? undefined}; const blob = await new Response(await file.stream.stream()).blob(); From fbd06180a833572e86929ba89fb6757c1f508588 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 22 Jul 2026 14:53:25 +0700 Subject: [PATCH 21/32] Scope the picture image cache to the entry again, relying on downloadIfMissing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 36a0db38 moved the cache to project scope so a downloaded picture wouldn't revert to "click to load" after navigating away and back (EntryEditor remounts per entry via {#key entry.id}, tearing the cache down). The downloadIfMissing mechanism added since then makes that unnecessary: ensureLocal() (downloadIfMissing=false) probes for a locally-available file without touching the network, and the backend keeps a once-downloaded remote file locally — so a fresh entry-scoped cache re-loads it on its own when you return, no re-click and no extra download. So go back to the simpler entry-view Context scope (initImageService in EntryEditor) while keeping the click-to-download flow: images available locally show immediately, but a remote-only image stays a placeholder until the user clicks, keeping bandwidth under their control (metered connections). Update the navigation test accordingly: the object URL now differs (a fresh cache mints a new one), so it asserts the first picture re-displays as an with no "click to load" placeholder rather than an identical src. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../field-editors/PictureImage.svelte | 11 ++--- .../field-editors/image-service.svelte.ts | 42 ++++++++++--------- .../object-editors/EntryEditor.svelte | 6 +++ .../viewer/tests/ui/sense-pictures.test.ts | 18 ++++---- 4 files changed, 45 insertions(+), 32 deletions(-) diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte index 74757c52c4..325d41d3b1 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte @@ -85,9 +85,10 @@ // systems first, then analysis — which is exactly the default order of allWritingSystems(). const caption = $derived(writingSystemService.first(picture.caption) ?? ''); - // Load through the project-scoped image cache so a mediaUri shared by several pictures — shown in - // a dialog, or revisited after navigating entries — is fetched once. On surfaces without a project - // scope (stories), fall back to a component-local cache disposed with the component. + // Load through the entry-view image cache so a mediaUri shared by several pictures — or the same + // picture shown in both the field and a dialog — is fetched once. On surfaces that render pictures + // without an entry-view scope (new-entry dialog, activity/subject previews, stories), fall back to + // a component-local cache disposed with the component. const sharedImageService = useImageService(); const localImageService = sharedImageService ? undefined : new ImageService(() => projectContext?.maybeApi); const imageService = sharedImageService ?? localImageService!; @@ -95,8 +96,8 @@ // A picture already available locally loads automatically; one that would have to be downloaded // from the remote media service shows a "click/tap to load" placeholder instead and is fetched - // only when clicked. The cache is shared, so a mediaUri loaded once (here, in a dialog, or in - // another entry) displays immediately everywhere. + // only when clicked. The cache is shared within the entry view, so a mediaUri loaded once (here or + // in a dialog) displays immediately across the entry's pictures. const mediaUri = $derived(picture.mediaUri); $effect(() => { imageService.ensureLocal(mediaUri); diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts b/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts index ab68325a82..04236807df 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts +++ b/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts @@ -1,7 +1,8 @@ +import {Context} from 'runed'; +import {onDestroy} from 'svelte'; import {SvelteMap} from 'svelte/reactivity'; import type {IMiniLcmJsInvokable} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable'; import {ReadFileResult} from '$lib/dotnet-types/generated-types/MiniLcm/Media/ReadFileResult'; -import {useProjectContext} from '$project/project-context.svelte'; export type ImageLoadState = | {status: 'loading'} @@ -10,15 +11,16 @@ export type ImageLoadState = | {status: 'error'; reason: 'not-found' | 'offline' | 'unknown'; detail?: string}; /** - * Loads picture images and hands out a shared blob object URL, cached per mediaUri. Scoped to the - * open project (see useImageService), so identical mediaUris across a sense's pictures — the same - * picture shown in a dialog, or revisited after navigating to another entry and back — are fetched - * and decoded a single time. Cached object URLs live until the project is closed, then dispose() - * revokes them. + * Loads picture images and hands out a shared blob object URL, cached per mediaUri. Scoped (via + * context) to the entry view, so identical mediaUris across a sense's pictures — or the same picture + * shown in both the field and a dialog — are fetched and decoded a single time. The cached object + * URLs live until the entry view is torn down (see initImageService), then dispose() revokes them. * * ensureLocal() shows a picture that's already available locally without touching the network; a * picture that would have to be fetched from the remote media service stays 'not-downloaded' until - * download() is called (wired to a "click/tap to load" placeholder). + * download() is called (wired to a "click/tap to load" placeholder). Because ensureLocal only reads + * what's already local, a picture downloaded once stays visible when you navigate away and back: the + * fresh entry-scoped cache re-loads it locally, no re-click needed. */ export class ImageService { readonly #getApi: () => IMiniLcmJsInvokable | undefined; @@ -104,21 +106,21 @@ export class ImageService { } } -const imageServiceKey = Symbol('ImageService'); +const imageServiceContext = new Context('image-service'); /** - * The project-scoped image cache (one per open project), or undefined when rendered outside a - * project (e.g. stories). Created lazily on first use and cached on the project context, so an image - * loaded in one entry stays cached when you navigate to another entry and back. Its object URLs are - * revoked when the project context is destroyed (project closed). + * Creates an entry-view-scoped image cache and publishes it to descendants (pictures, the edit + * dialog, the fullscreen viewer). Call once from the entry view; its object URLs are revoked when + * that view is destroyed. */ +export function initImageService(getApi: () => IMiniLcmJsInvokable | undefined): ImageService { + const service = new ImageService(getApi); + imageServiceContext.set(service); + onDestroy(() => service.dispose()); + return service; +} + +/** The entry-view image service, or undefined when rendered outside an entry view. */ export function useImageService(): ImageService | undefined { - const projectContext = useProjectContext(); - if (!projectContext) return undefined; - return projectContext.getOrAdd(imageServiceKey, () => { - const service = new ImageService(() => projectContext.maybeApi); - // getOrAdd runs this inside the project context's $effect.root; its teardown revokes the URLs. - $effect(() => () => service.dispose()); - return service; - }); + return imageServiceContext.getOr(undefined); } diff --git a/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte b/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte index aaa36c051b..3694bc8eea 100644 --- a/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte @@ -15,6 +15,8 @@ - - - + + {$t`Edit`} {$t`Download`} diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte index 325d41d3b1..831168d64b 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte @@ -10,18 +10,14 @@ type Props = { picture: IPicture; - /** When provided (and not readonly), clicking the picture opens the fullscreen viewer and a - three-dots actions menu is shown; the menu also opens on a long-press of the picture. */ + /** Opens the fullscreen viewer; without it (or when readonly) the picture isn't interactive + and no actions menu is offered. */ onView?: () => void; - /** Opens the edit dialog; wired into the actions menu (the "Edit" item). */ onEdit?: () => void; - /** Downloads the picture; wired into the actions menu (and long-press menu). */ onDownload?: () => void; - /** Deletes the picture (with confirmation); wired into the actions menu. */ onDelete?: () => void; - /** Disables the actions affordance while an operation is in flight. */ + /** Disables the actions while an operation is in flight. */ busy?: boolean; - /** Whether to render the caption beneath the picture (hidden inside the edit/viewer dialogs). */ showCaption?: boolean; /** 'thumbnail' (default) is the fixed-height field size; 'full' fills the viewer up to the viewport while never exceeding the image's native size. */ @@ -30,8 +26,6 @@ }; const {picture, onView, onEdit, onDownload, onDelete, busy = false, showCaption = true, size = 'thumbnail', readonly = false}: Props = $props(); - // With a view handler wired (and not readonly) the picture is interactive: clicking opens the - // viewer and a three-dots menu offers the actions. const interactive = $derived(!readonly && !!onView); const imageClass = $derived( size === 'full' @@ -39,36 +33,7 @@ : 'h-40 w-auto rounded-md object-contain', ); - // Long-press opens the actions menu, so touch users don't have to hit the small three-dots target. - let menuOpen = $state(false); - let longPressTimer: ReturnType | undefined; - let longPressed = false; - - function startLongPress() { - cancelLongPress(); - longPressed = false; - longPressTimer = setTimeout(() => { - longPressed = true; - menuOpen = true; - }, 500); - } - - function cancelLongPress() { - if (longPressTimer !== undefined) { - clearTimeout(longPressTimer); - longPressTimer = undefined; - } - } - - function handleImageClick(event: MouseEvent) { - // A long-press already opened the menu; swallow the click it would otherwise fire. - if (longPressed) { - longPressed = false; - event.preventDefault(); - return; - } - // A picture not available locally downloads on click; one that errored retries on click; once - // loaded, a click opens the viewer. + function handleImageClick() { if (loadState.status === 'not-downloaded' || loadState.status === 'error') { imageService.download(mediaUri); return; @@ -76,8 +41,6 @@ onView?.(); } - onDestroy(cancelLongPress); - const projectContext = useProjectContext(); const writingSystemService = useWritingSystemService(); @@ -85,27 +48,19 @@ // systems first, then analysis — which is exactly the default order of allWritingSystems(). const caption = $derived(writingSystemService.first(picture.caption) ?? ''); - // Load through the entry-view image cache so a mediaUri shared by several pictures — or the same - // picture shown in both the field and a dialog — is fetched once. On surfaces that render pictures - // without an entry-view scope (new-entry dialog, activity/subject previews, stories), fall back to - // a component-local cache disposed with the component. + // On surfaces that render pictures without an entry-view scope (new-entry dialog, activity/subject + // previews, stories), fall back to a component-local cache disposed with the component. const sharedImageService = useImageService(); const localImageService = sharedImageService ? undefined : new ImageService(() => projectContext?.maybeApi); const imageService = sharedImageService ?? localImageService!; onDestroy(() => localImageService?.dispose()); - // A picture already available locally loads automatically; one that would have to be downloaded - // from the remote media service shows a "click/tap to load" placeholder instead and is fetched - // only when clicked. The cache is shared within the entry view, so a mediaUri loaded once (here or - // in a dialog) displays immediately across the entry's pictures. const mediaUri = $derived(picture.mediaUri); $effect(() => { imageService.ensureLocal(mediaUri); }); const loadState = $derived(imageService.get(mediaUri)); - // Clickable to download (not available locally), to retry (after an error), or to open the viewer - // (loaded and interactive). const needsDownload = $derived(loadState.status === 'not-downloaded'); const hasError = $derived(loadState.status === 'error'); const clickable = $derived(needsDownload || hasError || (loadState.status === 'loaded' && interactive)); @@ -121,17 +76,15 @@ case 'offline': return $t`Offline, unable to download image`; default: - return state.detail ?? $t`Unable to load image`; + return $t`Unable to load image`; } } {#snippet imageContent()} {#if loadState.status === 'loaded'} - {caption {:else if loadState.status === 'not-downloaded'} -
{loadLabel} @@ -141,7 +94,6 @@
{:else} -
{errorText(loadState)} @@ -150,34 +102,38 @@ {/if} {/snippet} +{#snippet pictureArea()} + {#if clickable} + + {:else} + {@render imageContent()} + {/if} +{/snippet} +
- {#if clickable} - - {:else} - {@render imageContent()} - {/if} - {#if showMenu} - + {@render pictureArea()} +
onEdit?.()} @@ -185,6 +141,8 @@ onDelete={() => onDelete?.()} />
+ {:else} + {@render pictureArea()} {/if}
{#if showCaption && caption} diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte index dc35cc37de..bd6c14ee2a 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte @@ -24,28 +24,41 @@ useBackHandler({addToStack: () => open, onBack: () => (open = false), key: 'picture-viewer-dialog'}); const writingSystemService = useWritingSystemService(); - // Track the shown picture by id (not index) so it survives reordering; the index is derived for - // the prev/next arrows against the live list. + // Track the shown picture by id (not index) so it survives reordering. const currentIndex = $derived(pictures.findIndex((p) => p.id === pictureId)); const current = $derived(currentIndex >= 0 ? pictures[currentIndex] : undefined); - // If the shown picture disappears (e.g. deleted from the menu), close the dialog. + // If the shown picture disappears (e.g. deleted from the menu), move to the nearest remaining + // picture; close only when none are left. + let lastIndex = 0; $effect(() => { - if (open && pictureId !== undefined && !current) open = false; + if (currentIndex >= 0) lastIndex = currentIndex; + }); + $effect(() => { + if (!open || pictureId === undefined || current) return; + if (pictures.length > 0) { + pictureId = pictures[Math.min(lastIndex, pictures.length - 1)].id; + } else { + open = false; + } }); - // Captions collapse to just the first non-empty one on click; each picture starts expanded — reset - // whenever the viewer opens (below) and on navigation (showPrevious/showNext). + // Captions collapse to just the first non-empty one on click; every shown picture starts expanded. let collapsed = $state(false); - // Start expanded every time the viewer opens, so a collapse left over from a previous open doesn't - // carry into the next picture opened directly (not via the arrows). $effect(() => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions - pictureId; // Rerun the $effect when this changes + pictureId; // rerun when the shown picture changes collapsed = false; }); const hasMultiple = $derived(pictures.length > 1); + // Window-level so arrows keep working when nothing in the dialog has focus (e.g. after a nav + // button disables itself at the end of the list, dropping focus to the body). + function onWindowKeydown(e: KeyboardEvent) { + if (!open) return; + if (e.key === 'ArrowLeft') showPrevious(); + else if (e.key === 'ArrowRight') showNext(); + } function showPrevious() { if (currentIndex > 0) { pictureId = pictures[currentIndex - 1].id; @@ -57,7 +70,6 @@ } } - // The current picture's non-empty captions, in the project's writing-system order. const captions = $derived.by(() => { if (!current) return []; const caption = current.caption; @@ -66,19 +78,23 @@ .map((ws) => ({ws, text: asString(caption[ws.wsId]) ?? ''})) .filter((c) => c.text.length > 0); }); - // Collapsed shows only the first caption (its text clamped to one line); expanded shows all. const shownCaptions = $derived(collapsed ? captions.slice(0, 1) : captions); + + - e.preventDefault()} - > + - {$t`Picture`} + + {#if hasMultiple && currentIndex >= 0} + {$t`Picture ${currentIndex + 1} of ${pictures.length}`} + {:else} + {$t`Picture`} + {/if} + {#if current} @@ -118,9 +134,7 @@
{#if captions.length > 0} - +
- (); let viewerOpen = $state(false); @@ -146,7 +145,6 @@ } } - // The edit dialog deletes whatever picture it currently has open. function deleteEditingPicture(): Promise { return editingPicture ? deletePicture(editingPicture.id) : Promise.resolve(); } @@ -168,9 +166,6 @@
{#if pictures.length > 0} -
{#each pictures as picture (picture.id)} { - // Hand off from the viewer to the edit dialog. viewerOpen = false; openEditor(picture); }} diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts b/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts index 04236807df..b61e3988dc 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts +++ b/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts @@ -8,7 +8,7 @@ export type ImageLoadState = | {status: 'loading'} | {status: 'loaded'; url: string} | {status: 'not-downloaded'} - | {status: 'error'; reason: 'not-found' | 'offline' | 'unknown'; detail?: string}; + | {status: 'error'; reason: 'not-found' | 'offline' | 'unknown'}; /** * Loads picture images and hands out a shared blob object URL, cached per mediaUri. Scoped (via @@ -82,7 +82,7 @@ export class ImageService { file.result === ReadFileResult.NotFound ? 'not-found' : file.result === ReadFileResult.Offline ? 'offline' : 'unknown'; - this.#cache.set(mediaUri, {status: 'error', reason, detail: file.errorMessage ?? undefined}); + this.#cache.set(mediaUri, {status: 'error', reason}); return; } const blob = await new Response(await file.stream.stream()).blob(); @@ -91,7 +91,7 @@ export class ImageService { this.#cache.set(mediaUri, {status: 'loaded', url: URL.createObjectURL(blob)}); } catch (error) { if (!this.#disposed) { - this.#cache.set(mediaUri, {status: 'error', reason: 'unknown', detail: error instanceof Error ? error.message : undefined}); + this.#cache.set(mediaUri, {status: 'error', reason: 'unknown'}); } throw error; } diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/picture-actions.ts b/frontend/viewer/src/lib/entry-editor/field-editors/picture-actions.ts index cde842ec7d..df38d735bc 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/picture-actions.ts +++ b/frontend/viewer/src/lib/entry-editor/field-editors/picture-actions.ts @@ -4,11 +4,8 @@ export type DownloadPictureResult = {success: true} | {success: false; errorMess /** * Downloads the image behind a picture's mediaUri, saved under the filename the media server - * reports for it. Shared by the picture field, the edit dialog, and the fullscreen viewer so the - * "Download" action behaves identically wherever it's offered. - * - * getFileStream reports failure via the response (not an exception), so the caller decides how to - * surface `errorMessage` — this keeps the notification/translation in the calling component. + * reports for it. getFileStream reports failure via the response (not an exception); the caller + * decides how to surface `errorMessage`, keeping notification/translation in the component. */ export async function downloadPictureFile(api: IMiniLcmJsInvokable, mediaUri: string): Promise { const file = await api.getFileStream(mediaUri, true); diff --git a/frontend/viewer/src/locales/en.po b/frontend/viewer/src/locales/en.po index d2b089618d..58eb7c013a 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -1836,6 +1836,11 @@ msgstr "Pick a Part of Speech" msgid "Picture" msgstr "Picture" +#. Fullscreen picture viewer title showing the position in the sense's pictures, e.g. "Picture 2 of 5" +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Picture {0} of {1}" +msgstr "Picture {0} of {1}" + #. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) #: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Picture actions" diff --git a/frontend/viewer/src/locales/es.po b/frontend/viewer/src/locales/es.po index d095d7891d..01b11043a2 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -421,6 +421,7 @@ msgstr "borrar" msgid "Click to load" msgstr "" +#. Shown on a picture whose image failed to load (desktop); clicking it retries the load #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Click to retry" msgstr "" @@ -1840,6 +1841,11 @@ msgstr "Escoge una parte de la voz" msgid "Picture" msgstr "" +#. Fullscreen picture viewer title showing the position in the sense's pictures, e.g. "Picture 2 of 5" +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Picture {0} of {1}" +msgstr "" + #. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) #: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Picture actions" @@ -2314,6 +2320,7 @@ msgstr "Sistema" msgid "Tap to load" msgstr "" +#. Shown on a picture whose image failed to load (mobile/touch); tapping it retries the load #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Tap to retry" msgstr "" diff --git a/frontend/viewer/src/locales/fr.po b/frontend/viewer/src/locales/fr.po index 22cd11261d..e64149ffe8 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -421,6 +421,7 @@ msgstr "effacer" msgid "Click to load" msgstr "" +#. Shown on a picture whose image failed to load (desktop); clicking it retries the load #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Click to retry" msgstr "" @@ -1840,6 +1841,11 @@ msgstr "Choisir une partie du discours" msgid "Picture" msgstr "" +#. Fullscreen picture viewer title showing the position in the sense's pictures, e.g. "Picture 2 of 5" +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Picture {0} of {1}" +msgstr "" + #. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) #: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Picture actions" @@ -2314,6 +2320,7 @@ msgstr "Système" msgid "Tap to load" msgstr "" +#. Shown on a picture whose image failed to load (mobile/touch); tapping it retries the load #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Tap to retry" msgstr "" diff --git a/frontend/viewer/src/locales/id.po b/frontend/viewer/src/locales/id.po index 2f6f688c3f..d796616d87 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -421,6 +421,7 @@ msgstr "jelas" msgid "Click to load" msgstr "" +#. Shown on a picture whose image failed to load (desktop); clicking it retries the load #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Click to retry" msgstr "" @@ -1840,6 +1841,11 @@ msgstr "Pilih Bagian dari Pidato" msgid "Picture" msgstr "" +#. Fullscreen picture viewer title showing the position in the sense's pictures, e.g. "Picture 2 of 5" +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Picture {0} of {1}" +msgstr "" + #. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) #: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Picture actions" @@ -2314,6 +2320,7 @@ msgstr "Sistem" msgid "Tap to load" msgstr "" +#. Shown on a picture whose image failed to load (mobile/touch); tapping it retries the load #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Tap to retry" msgstr "" diff --git a/frontend/viewer/src/locales/ko.po b/frontend/viewer/src/locales/ko.po index 8a2e37bb31..1a107007c2 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -421,6 +421,7 @@ msgstr "clear" msgid "Click to load" msgstr "" +#. Shown on a picture whose image failed to load (desktop); clicking it retries the load #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Click to retry" msgstr "" @@ -1840,6 +1841,11 @@ msgstr "품사 선택" msgid "Picture" msgstr "" +#. Fullscreen picture viewer title showing the position in the sense's pictures, e.g. "Picture 2 of 5" +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Picture {0} of {1}" +msgstr "" + #. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) #: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Picture actions" @@ -2314,6 +2320,7 @@ msgstr "시스템" msgid "Tap to load" msgstr "" +#. Shown on a picture whose image failed to load (mobile/touch); tapping it retries the load #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Tap to retry" msgstr "" diff --git a/frontend/viewer/src/locales/ms.po b/frontend/viewer/src/locales/ms.po index bcd63a552e..293dbd0153 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -421,6 +421,7 @@ msgstr "padam" msgid "Click to load" msgstr "" +#. Shown on a picture whose image failed to load (desktop); clicking it retries the load #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Click to retry" msgstr "" @@ -1840,6 +1841,11 @@ msgstr "Pilih Bahagian Pertuturan" msgid "Picture" msgstr "" +#. Fullscreen picture viewer title showing the position in the sense's pictures, e.g. "Picture 2 of 5" +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Picture {0} of {1}" +msgstr "" + #. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) #: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Picture actions" @@ -2314,6 +2320,7 @@ msgstr "Sistem" msgid "Tap to load" msgstr "" +#. Shown on a picture whose image failed to load (mobile/touch); tapping it retries the load #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Tap to retry" msgstr "" diff --git a/frontend/viewer/src/locales/sw.po b/frontend/viewer/src/locales/sw.po index 0b6658a905..162b65028f 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -421,6 +421,7 @@ msgstr "futa" msgid "Click to load" msgstr "" +#. Shown on a picture whose image failed to load (desktop); clicking it retries the load #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Click to retry" msgstr "" @@ -1840,6 +1841,11 @@ msgstr "Chagua Sehemu ya Mazungumzo" msgid "Picture" msgstr "" +#. Fullscreen picture viewer title showing the position in the sense's pictures, e.g. "Picture 2 of 5" +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Picture {0} of {1}" +msgstr "" + #. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) #: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Picture actions" @@ -2314,6 +2320,7 @@ msgstr "Mfumo" msgid "Tap to load" msgstr "" +#. Shown on a picture whose image failed to load (mobile/touch); tapping it retries the load #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Tap to retry" msgstr "" diff --git a/frontend/viewer/src/locales/vi.po b/frontend/viewer/src/locales/vi.po index 3893ee731a..c113a83c6b 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -421,6 +421,7 @@ msgstr "xóa" msgid "Click to load" msgstr "" +#. Shown on a picture whose image failed to load (desktop); clicking it retries the load #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Click to retry" msgstr "" @@ -1840,6 +1841,11 @@ msgstr "Chọn một Loại từ" msgid "Picture" msgstr "" +#. Fullscreen picture viewer title showing the position in the sense's pictures, e.g. "Picture 2 of 5" +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Picture {0} of {1}" +msgstr "" + #. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) #: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Picture actions" @@ -2314,6 +2320,7 @@ msgstr "Hệ thống" msgid "Tap to load" msgstr "" +#. Shown on a picture whose image failed to load (mobile/touch); tapping it retries the load #: src/lib/entry-editor/field-editors/PictureImage.svelte msgid "Tap to retry" msgstr "" diff --git a/frontend/viewer/src/project/demo/in-memory-demo-api.ts b/frontend/viewer/src/project/demo/in-memory-demo-api.ts index 64a2c609d1..43d1a4a1b7 100644 --- a/frontend/viewer/src/project/demo/in-memory-demo-api.ts +++ b/frontend/viewer/src/project/demo/in-memory-demo-api.ts @@ -729,7 +729,6 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { getFileStream(mediaUri: string, downloadIfMissing: boolean): Promise { const uploaded = this.#uploadedFiles.get(mediaUri); if (uploaded) { - // Files uploaded this session live locally, so they're available without a download. return Promise.resolve({ result: ReadFileResult.Success, fileName: mediaUri.split('/').pop() ?? 'demo-upload', @@ -741,8 +740,6 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { } const svg = demoPictureSvgs[mediaUri]; if (!svg) return Promise.resolve({result: ReadFileResult.NotFound}); - // Pre-seeded demo pictures stand in for a remote media service: not available locally until - // downloaded once (mirroring how a synced project fetches images on demand). if (!this.#downloadedRemoteFiles.has(mediaUri)) { if (!downloadIfMissing) return Promise.resolve({result: ReadFileResult.NotFound}); this.#downloadedRemoteFiles.add(mediaUri); diff --git a/frontend/viewer/tests/ui/sense-pictures.test.ts b/frontend/viewer/tests/ui/sense-pictures.test.ts index e648121057..20d26769e8 100644 --- a/frontend/viewer/tests/ui/sense-pictures.test.ts +++ b/frontend/viewer/tests/ui/sense-pictures.test.ts @@ -179,6 +179,15 @@ test.describe('Sense pictures', () => { await expect(picturesField.getByRole('button', {name: 'Picture actions'})).toBeVisible(); }); + test('right-clicking a picture opens the actions menu', async ({page}) => { + const picturesField = await addOnePicture(page); + + await picturesField.getByRole('button', {name: 'View Picture'}).click({button: 'right'}); + await expect(page.getByRole('menuitem', {name: 'Edit'})).toBeVisible({timeout: 5000}); + await expect(page.getByRole('menuitem', {name: 'Download'})).toBeVisible(); + await expect(page.getByRole('menuitem', {name: 'Delete'})).toBeVisible(); + }); + test('the three-dots menu Delete removes the picture after confirmation', async ({page}) => { const picturesField = await addOnePicture(page); @@ -304,6 +313,9 @@ test.describe('Sense pictures', () => { await expect(viewer.locator('img')).toHaveAttribute('src', /^blob:/, {timeout: 5000}); await expect(viewer.getByRole('button', {name: 'Picture actions'})).toBeVisible(); + // Focus must move into the dialog on open (APG dialog pattern). + await expect.poll(() => page.evaluate(() => !!document.activeElement?.closest('[role="dialog"]'))).toBe(true); + // A freshly-uploaded picture has no caption, and a single picture has no navigation arrows. await expect(viewer.getByRole('button', {name: 'Previous picture'})).toHaveCount(0); await expect(viewer.getByRole('button', {name: 'Next picture'})).toHaveCount(0); @@ -322,6 +334,8 @@ test.describe('Sense pictures', () => { const viewer = page.getByRole('dialog'); await expect(viewer).toBeVisible({timeout: 5000}); + await expect(viewer.getByRole('heading', {name: 'Picture 1 of 2'})).toBeVisible(); + // Both non-empty captions of the first picture are shown. await expect(viewer.getByText('A traditional house')).toBeVisible(); await expect(viewer.getByText('Uma casa tradicional')).toBeVisible(); @@ -336,6 +350,7 @@ test.describe('Sense pictures', () => { await next.click(); await expect(viewer.getByText('A modern house')).toBeVisible(); await expect(viewer.getByText('A traditional house')).toHaveCount(0); + await expect(viewer.getByRole('heading', {name: 'Picture 2 of 2'})).toBeVisible(); await expect(next).toBeDisabled(); await expect(previous).toBeEnabled(); @@ -347,6 +362,37 @@ test.describe('Sense pictures', () => { await previous.click(); await expect(viewer.getByText('A traditional house')).toBeVisible(); await expect(previous).toBeDisabled(); + + // Arrow keys must work even now, when the just-disabled Previous button has dropped focus to the body. + await page.keyboard.press('ArrowRight'); + await expect(viewer.getByText('A modern house')).toBeVisible(); + await page.keyboard.press('ArrowLeft'); + await expect(viewer.getByText('A traditional house')).toBeVisible(); + }); + + test('deleting the current picture in the viewer advances to the next', async ({page}) => { + const projectPage = new DemoProjectPage(page); + await projectPage.goto(); + // "nyumba" has two pictures. + await projectPage.selectEntryByFilter('nyumba'); + const picturesField = page.locator('[style*="grid-area: pictures"]').first(); + await loadFirstPicture(picturesField); + await picturesField.getByRole('button', {name: 'View Picture'}).first().click(); + const viewer = page.getByRole('dialog'); + await expect(viewer).toBeVisible({timeout: 5000}); + await expect(viewer.getByText('A traditional house')).toBeVisible(); + + await viewer.getByRole('button', {name: 'Picture actions'}).click(); + await page.getByRole('menuitem', {name: 'Delete'}).click(); + await page.getByRole('alertdialog').getByRole('button', {name: 'Delete Picture', exact: true}).click(); + await expect(viewer.getByText('A modern house')).toBeVisible({timeout: 5000}); + await expect(viewer.getByText('A traditional house')).toHaveCount(0); + + // Deleting the last picture closes the viewer. + await viewer.getByRole('button', {name: 'Picture actions'}).click(); + await page.getByRole('menuitem', {name: 'Delete'}).click(); + await page.getByRole('alertdialog').getByRole('button', {name: 'Delete Picture', exact: true}).click(); + await expect(viewer).toHaveCount(0, {timeout: 5000}); }); test('the fullscreen viewer Edit hands off to the edit dialog', async ({page}) => { From 038f52b18c3b03eeb7b70b9471860add06e3182b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 14:48:28 +0000 Subject: [PATCH 23/32] Gate viewer arrow keys via runed and slim the counter to "1 / 4" useEventListener with a reactive target replaces the svelte:window + early-return guard: the keydown listener now only exists while the viewer is open (one viewer is mounted per sense's pictures field). The position counter becomes a language-neutral "1 / 4" beside the stable "Picture" title, dropping the just-added "Picture {0} of {1}" msgid from all catalogs. Top-right placement was rejected: the dialog's close button lives there. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015hT9fAFxFEXNkMDEUTYNNV --- .../field-editors/PictureViewerDialog.svelte | 27 ++++++++++--------- frontend/viewer/src/locales/en.po | 5 ---- frontend/viewer/src/locales/es.po | 5 ---- frontend/viewer/src/locales/fr.po | 5 ---- frontend/viewer/src/locales/id.po | 5 ---- frontend/viewer/src/locales/ko.po | 5 ---- frontend/viewer/src/locales/ms.po | 5 ---- frontend/viewer/src/locales/sw.po | 5 ---- frontend/viewer/src/locales/vi.po | 5 ---- .../viewer/tests/ui/sense-pictures.test.ts | 4 +-- 10 files changed, 16 insertions(+), 55 deletions(-) diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte index bd6c14ee2a..d51bf517ba 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte @@ -6,6 +6,7 @@ import PictureActionsMenu from './PictureActionsMenu.svelte'; import {useWritingSystemService, asString} from '$project/data'; import {useBackHandler} from '$lib/utils/back-handler.svelte'; + import {useEventListener} from 'runed'; import {t} from 'svelte-i18n-lingui'; type Props = { @@ -52,13 +53,16 @@ }); const hasMultiple = $derived(pictures.length > 1); - // Window-level so arrows keep working when nothing in the dialog has focus (e.g. after a nav - // button disables itself at the end of the list, dropping focus to the body). - function onWindowKeydown(e: KeyboardEvent) { - if (!open) return; - if (e.key === 'ArrowLeft') showPrevious(); - else if (e.key === 'ArrowRight') showNext(); - } + // Window-level (not on the dialog) so arrows keep working when nothing has focus — e.g. after a + // nav button disables itself at the end of the list, dropping focus to the body. + useEventListener( + () => (open ? window : null), + 'keydown', + (e) => { + if (e.key === 'ArrowLeft') showPrevious(); + else if (e.key === 'ArrowRight') showNext(); + }, + ); function showPrevious() { if (currentIndex > 0) { pictureId = pictures[currentIndex - 1].id; @@ -81,18 +85,15 @@ const shownCaptions = $derived(collapsed ? captions.slice(0, 1) : captions); - - - + + {$t`Picture`} {#if hasMultiple && currentIndex >= 0} - {$t`Picture ${currentIndex + 1} of ${pictures.length}`} - {:else} - {$t`Picture`} + {currentIndex + 1} / {pictures.length} {/if} diff --git a/frontend/viewer/src/locales/en.po b/frontend/viewer/src/locales/en.po index 58eb7c013a..d2b089618d 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -1836,11 +1836,6 @@ msgstr "Pick a Part of Speech" msgid "Picture" msgstr "Picture" -#. Fullscreen picture viewer title showing the position in the sense's pictures, e.g. "Picture 2 of 5" -#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte -msgid "Picture {0} of {1}" -msgstr "Picture {0} of {1}" - #. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) #: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Picture actions" diff --git a/frontend/viewer/src/locales/es.po b/frontend/viewer/src/locales/es.po index 01b11043a2..4d02833601 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -1841,11 +1841,6 @@ msgstr "Escoge una parte de la voz" msgid "Picture" msgstr "" -#. Fullscreen picture viewer title showing the position in the sense's pictures, e.g. "Picture 2 of 5" -#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte -msgid "Picture {0} of {1}" -msgstr "" - #. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) #: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Picture actions" diff --git a/frontend/viewer/src/locales/fr.po b/frontend/viewer/src/locales/fr.po index e64149ffe8..6ca773429f 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -1841,11 +1841,6 @@ msgstr "Choisir une partie du discours" msgid "Picture" msgstr "" -#. Fullscreen picture viewer title showing the position in the sense's pictures, e.g. "Picture 2 of 5" -#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte -msgid "Picture {0} of {1}" -msgstr "" - #. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) #: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Picture actions" diff --git a/frontend/viewer/src/locales/id.po b/frontend/viewer/src/locales/id.po index d796616d87..cba3ce397d 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -1841,11 +1841,6 @@ msgstr "Pilih Bagian dari Pidato" msgid "Picture" msgstr "" -#. Fullscreen picture viewer title showing the position in the sense's pictures, e.g. "Picture 2 of 5" -#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte -msgid "Picture {0} of {1}" -msgstr "" - #. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) #: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Picture actions" diff --git a/frontend/viewer/src/locales/ko.po b/frontend/viewer/src/locales/ko.po index 1a107007c2..eb4f768794 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -1841,11 +1841,6 @@ msgstr "품사 선택" msgid "Picture" msgstr "" -#. Fullscreen picture viewer title showing the position in the sense's pictures, e.g. "Picture 2 of 5" -#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte -msgid "Picture {0} of {1}" -msgstr "" - #. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) #: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Picture actions" diff --git a/frontend/viewer/src/locales/ms.po b/frontend/viewer/src/locales/ms.po index 293dbd0153..222a654a22 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -1841,11 +1841,6 @@ msgstr "Pilih Bahagian Pertuturan" msgid "Picture" msgstr "" -#. Fullscreen picture viewer title showing the position in the sense's pictures, e.g. "Picture 2 of 5" -#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte -msgid "Picture {0} of {1}" -msgstr "" - #. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) #: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Picture actions" diff --git a/frontend/viewer/src/locales/sw.po b/frontend/viewer/src/locales/sw.po index 162b65028f..c3b92650d1 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -1841,11 +1841,6 @@ msgstr "Chagua Sehemu ya Mazungumzo" msgid "Picture" msgstr "" -#. Fullscreen picture viewer title showing the position in the sense's pictures, e.g. "Picture 2 of 5" -#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte -msgid "Picture {0} of {1}" -msgstr "" - #. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) #: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Picture actions" diff --git a/frontend/viewer/src/locales/vi.po b/frontend/viewer/src/locales/vi.po index c113a83c6b..c1c5c7e4b5 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -1841,11 +1841,6 @@ msgstr "Chọn một Loại từ" msgid "Picture" msgstr "" -#. Fullscreen picture viewer title showing the position in the sense's pictures, e.g. "Picture 2 of 5" -#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte -msgid "Picture {0} of {1}" -msgstr "" - #. Accessible label for the three-dots button that opens a picture's actions menu (Edit/Download/Delete) #: src/lib/entry-editor/field-editors/PictureActionsMenu.svelte msgid "Picture actions" diff --git a/frontend/viewer/tests/ui/sense-pictures.test.ts b/frontend/viewer/tests/ui/sense-pictures.test.ts index 20d26769e8..472454058a 100644 --- a/frontend/viewer/tests/ui/sense-pictures.test.ts +++ b/frontend/viewer/tests/ui/sense-pictures.test.ts @@ -334,7 +334,7 @@ test.describe('Sense pictures', () => { const viewer = page.getByRole('dialog'); await expect(viewer).toBeVisible({timeout: 5000}); - await expect(viewer.getByRole('heading', {name: 'Picture 1 of 2'})).toBeVisible(); + await expect(viewer.getByRole('heading', {name: 'Picture 1 / 2'})).toBeVisible(); // Both non-empty captions of the first picture are shown. await expect(viewer.getByText('A traditional house')).toBeVisible(); @@ -350,7 +350,7 @@ test.describe('Sense pictures', () => { await next.click(); await expect(viewer.getByText('A modern house')).toBeVisible(); await expect(viewer.getByText('A traditional house')).toHaveCount(0); - await expect(viewer.getByRole('heading', {name: 'Picture 2 of 2'})).toBeVisible(); + await expect(viewer.getByRole('heading', {name: 'Picture 2 / 2'})).toBeVisible(); await expect(next).toBeDisabled(); await expect(previous).toBeEnabled(); From e705e2fae8a038ddb53b63a95bdd27eaa62bb2c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 15:00:33 +0000 Subject: [PATCH 24/32] Fold the on-demand-fetch intent back into the demo field doc The earlier comment consolidation kept the mechanism but dropped the "mirrors a synced project fetching images on demand" rationale. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015hT9fAFxFEXNkMDEUTYNNV --- frontend/viewer/src/project/demo/in-memory-demo-api.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/viewer/src/project/demo/in-memory-demo-api.ts b/frontend/viewer/src/project/demo/in-memory-demo-api.ts index 43d1a4a1b7..a7c7a19971 100644 --- a/frontend/viewer/src/project/demo/in-memory-demo-api.ts +++ b/frontend/viewer/src/project/demo/in-memory-demo-api.ts @@ -723,7 +723,8 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { // filename (LcmMediaService keys by the file name within the project's resource cache). #uploadedFilenames = new Map(); // Pre-seeded demo pictures that have been "downloaded" from the (simulated) remote media service, - // so a later local-only request returns them instead of NotFound. + // so a later local-only request returns them instead of NotFound — mirroring how a synced project + // fetches images on demand. #downloadedRemoteFiles = new Set(); getFileStream(mediaUri: string, downloadIfMissing: boolean): Promise { From 413af222a63b940e3beed5b430ef327fbd943f40 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 22 Jul 2026 20:46:27 +0200 Subject: [PATCH 25/32] Give the picture viewer a stable stage and tidy its chrome - Fix the dialog to a fixed-height stage so paging between pictures no longer resizes it or moves the prev/next arrows. - Letterbox the image (object-contain) into a definite-height figure so a tall/large image caps to the stage instead of overflowing the dialog. - Reserve the caption band so caption-count differences don't shift the stage. - Move the actions menu out of the on-image overlay into the top-right chrome, paired with the close button in one flex cluster (both icon-xs) so they align without mirroring the close button's position. - Add a size prop to the responsive menu trigger for that pairing. Co-Authored-By: Claude Opus 4.8 --- .../responsive-menu-trigger.svelte | 7 +- .../field-editors/PictureActionsMenu.svelte | 7 +- .../field-editors/PictureImage.svelte | 74 +++++++++----- .../field-editors/PictureViewerDialog.svelte | 99 +++++++++++-------- .../field-editors/PicturesEditor.svelte | 4 + 5 files changed, 120 insertions(+), 71 deletions(-) diff --git a/frontend/viewer/src/lib/components/responsive-menu/responsive-menu-trigger.svelte b/frontend/viewer/src/lib/components/responsive-menu/responsive-menu-trigger.svelte index daa4b2d22e..ada3b3dd24 100644 --- a/frontend/viewer/src/lib/components/responsive-menu/responsive-menu-trigger.svelte +++ b/frontend/viewer/src/lib/components/responsive-menu/responsive-menu-trigger.svelte @@ -9,17 +9,20 @@ import type {Snippet} from 'svelte'; import type {ContextMenuTriggerProps, DropdownMenuTriggerProps} from 'bits-ui'; import type {DrawerTriggerProps} from 'vaul-svelte'; + import type {VariantProps} from 'tailwind-variants'; import {cn} from '$lib/utils'; type Props = { children?: Snippet; + /** Trigger button size (matches the shared Button sizes); ignored in `contextMenu` mode. */ + size?: VariantProps['size']; } & ContextMenuTriggerProps & DropdownMenuTriggerProps & DrawerTriggerProps; - let {children, class: className, ref = $bindable(null), ...rest}: Props = $props(); + let {children, class: className, size = 'icon', ref = $bindable(null), ...rest}: Props = $props(); - const triggerVariant = buttonVariants({variant: 'ghost', size: 'icon'}); + const triggerVariant = $derived(buttonVariants({variant: 'ghost', size})); const state = useResponsiveMenuTrigger(); diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureActionsMenu.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureActionsMenu.svelte index 817b75e70c..cf82843e66 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureActionsMenu.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureActionsMenu.svelte @@ -1,7 +1,7 @@ void; + /** Opens the edit dialog; wired into the actions menu (the "Edit" item). */ onEdit?: () => void; + /** Downloads the picture; wired into the actions menu (and long-press menu). */ onDownload?: () => void; + /** Deletes the picture (with confirmation); wired into the actions menu. */ onDelete?: () => void; - /** Disables the actions while an operation is in flight. */ + /** Disables the actions affordance while an operation is in flight. */ busy?: boolean; + /** Whether to render the caption beneath the picture (hidden inside the edit/viewer dialogs). */ showCaption?: boolean; - /** 'thumbnail' (default) is the fixed-height field size; 'full' fills the viewer up to the - viewport while never exceeding the image's native size. */ + /** 'thumbnail' (default) is the fixed-height field size; 'full' fills its container (the + viewer's fixed stage) while never exceeding the image's native size. */ size?: 'thumbnail' | 'full'; readonly?: boolean; }; @@ -29,7 +33,7 @@ const interactive = $derived(!readonly && !!onView); const imageClass = $derived( size === 'full' - ? 'max-h-[80dvh] w-auto max-w-full rounded-md object-contain' + ? 'max-h-full max-w-full h-auto w-auto rounded-md object-contain' : 'h-40 w-auto rounded-md object-contain', ); @@ -55,12 +59,18 @@ const imageService = sharedImageService ?? localImageService!; onDestroy(() => localImageService?.dispose()); + // A picture already available locally loads automatically; one that would have to be downloaded + // from the remote media service shows a "click/tap to load" placeholder instead and is fetched + // only when clicked. The cache is shared within the entry view, so a mediaUri loaded once (here or + // in a dialog) displays immediately across the entry's pictures. const mediaUri = $derived(picture.mediaUri); $effect(() => { imageService.ensureLocal(mediaUri); }); const loadState = $derived(imageService.get(mediaUri)); + // Clickable to download (not available locally), to retry (after an error), or to open the viewer + // (loaded and interactive). const needsDownload = $derived(loadState.status === 'not-downloaded'); const hasError = $derived(loadState.status === 'error'); const clickable = $derived(needsDownload || hasError || (loadState.status === 'loaded' && interactive)); @@ -83,8 +93,10 @@ {#snippet imageContent()} {#if loadState.status === 'loaded'} + {caption {:else if loadState.status === 'not-downloaded'} +
{loadLabel} @@ -94,6 +106,7 @@
{:else} +
{errorText(loadState)} @@ -118,37 +131,44 @@ {/if} {/snippet} -
- -
- {#if showMenu} - - onEdit?.()} - onDownload={() => onDownload?.()} - onDelete={() => onDelete?.()} - > - {@render pictureArea()} - -
+
+ {#if size === 'full'} + + {@render pictureArea()} + {:else} + +
+ {#if showMenu} + onEdit?.()} onDownload={() => onDownload?.()} onDelete={() => onDelete?.()} - /> -
- {:else} - {@render pictureArea()} - {/if} -
+ > + {@render pictureArea()} + +
+ onEdit?.()} + onDownload={() => onDownload?.()} + onDelete={() => onDelete?.()} + /> +
+ {:else} + {@render pictureArea()} + {/if} +
+ {/if} {#if showCaption && caption} -
+
{caption}
{/if} diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte index d51bf517ba..cf13ed71ac 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte @@ -1,7 +1,7 @@ - - - + + + + {$t`Picture`} {#if hasMultiple && currentIndex >= 0} @@ -98,8 +103,27 @@ + +
+ {#if current} + current && onEdit(current)} + onDownload={() => current && onDownload(current)} + onDelete={() => current && onDelete(current)} + /> + {/if} + + {#snippet child({props})} + + {/snippet} + +
+ {#if current} -
+
{#if hasMultiple} - {/if} + +
+ {#if captions.length > 0} + + + {/if} +
{/if} diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte index 9d8bc6e5bc..ef3d1fd292 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -43,6 +43,7 @@ if (editingPicture) lastEditedPicture = editingPicture; }); + // The fullscreen viewer, tracked by id so prev/next and (direct) deletion stay in sync with `pictures`. let viewerPictureId = $state(); let viewerOpen = $state(false); @@ -166,6 +167,9 @@
{#if pictures.length > 0} +
{#each pictures as picture (picture.id)} Date: Thu, 23 Jul 2026 12:12:52 +0200 Subject: [PATCH 26/32] Polish picture chrome and viewer layout, guard touch ghost-clicks Overlay the viewer's prev/next arrows on the stage edges (anchored to the stage, not the image, so they hold across aspect ratios), show the caption band only when captions exist, and let the stage bleed to the screen edges on mobile. Simplify the placeholder/error wording ("Load picture", "Try again", "You're offline"). Ignore image taps while an actions menu is open so a touch ghost-click doesn't also open the viewer, covered by a new touch test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../field-editors/PictureActionsMenu.svelte | 7 ++- .../field-editors/PictureImage.svelte | 28 +++++---- .../field-editors/PictureViewerDialog.svelte | 36 ++++++----- .../field-editors/PicturesEditor.svelte | 2 +- frontend/viewer/src/locales/en.po | 61 +++++++------------ frontend/viewer/src/locales/es.po | 57 ++++++----------- frontend/viewer/src/locales/fr.po | 57 ++++++----------- frontend/viewer/src/locales/id.po | 57 ++++++----------- frontend/viewer/src/locales/ko.po | 57 ++++++----------- frontend/viewer/src/locales/ms.po | 57 ++++++----------- frontend/viewer/src/locales/sw.po | 57 ++++++----------- frontend/viewer/src/locales/vi.po | 57 ++++++----------- .../tests/ui/sense-pictures-touch.test.ts | 41 +++++++++++++ .../viewer/tests/ui/sense-pictures.test.ts | 28 ++++----- 14 files changed, 253 insertions(+), 349 deletions(-) create mode 100644 frontend/viewer/tests/ui/sense-pictures-touch.test.ts diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureActionsMenu.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureActionsMenu.svelte index cf82843e66..d3808b1e78 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureActionsMenu.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureActionsMenu.svelte @@ -13,14 +13,17 @@ triggerClass?: string; /** Trigger button size; forwarded to the menu trigger (defaults to a 40px icon button). */ size?: ComponentProps['size']; + /** Notified when the menu opens/closes (lets a parent ignore the stray tap that a touch + device can fire on the element behind the trigger while the menu is open). */ + onOpenChange?: (open: boolean) => void; onEdit: () => void; onDownload: () => void; onDelete: () => void; }; - let {contextMenu = false, children, disabled = false, triggerClass, size, onEdit, onDownload, onDelete}: Props = $props(); + let {contextMenu = false, children, disabled = false, triggerClass, size, onOpenChange, onEdit, onDownload, onDelete}: Props = $props(); - + localImageService?.dispose()); // A picture already available locally loads automatically; one that would have to be downloaded - // from the remote media service shows a "click/tap to load" placeholder instead and is fetched + // from the remote media service shows a "Load picture" placeholder instead and is fetched // only when clicked. The cache is shared within the entry view, so a mediaUri loaded once (here or // in a dialog) displays immediately across the entry's pictures. const mediaUri = $derived(picture.mediaUri); @@ -75,18 +79,18 @@ const hasError = $derived(loadState.status === 'error'); const clickable = $derived(needsDownload || hasError || (loadState.status === 'loaded' && interactive)); const showMenu = $derived(interactive && !!onEdit && !!onDownload && !!onDelete); - const loadLabel = $derived(IsMobile.value ? $t`Tap to load` : $t`Click to load`); - const retryLabel = $derived(IsMobile.value ? $t`Tap to retry` : $t`Click to retry`); + const loadLabel = $derived($t`Load picture`); + const retryLabel = $derived($t`Try again`); const clickLabel = $derived(needsDownload ? loadLabel : hasError ? retryLabel : $t`View Picture`); function errorText(state: Extract): string { switch (state.reason) { case 'not-found': - return $t`Image not found`; + return $t`Picture not found`; case 'offline': - return $t`Offline, unable to download image`; + return $t`You're offline`; default: - return $t`Unable to load image`; + return $t`Unable to load picture`; } } @@ -97,7 +101,7 @@ {caption {:else if loadState.status === 'not-downloaded'} -
+
{loadLabel}
@@ -107,7 +111,7 @@
{:else} -
+
{errorText(loadState)} {retryLabel} @@ -145,6 +149,7 @@ (menuOpen = o)} onEdit={() => onEdit?.()} onDownload={() => onDownload?.()} onDelete={() => onDelete?.()} @@ -154,7 +159,8 @@
(menuOpen = o)} onEdit={() => onEdit?.()} onDownload={() => onDownload?.()} onDelete={() => onDelete?.()} diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte index cf13ed71ac..caecc55b7f 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte @@ -90,11 +90,13 @@ dialog or the prev/next arrows. Mobile is already full-screen via the base dialog. --> + - + {$t`Picture`} {#if hasMultiple && currentIndex >= 0} @@ -123,7 +125,13 @@
{#if current} -
+ +
+ {#if hasMultiple}
- -
- {#if captions.length > 0} + {#if captions.length > 0} + +
- {/if} -
+
+ {/if} {/if} diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte index ef3d1fd292..bf91cc5f62 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -160,7 +160,7 @@ // The server rejects files above its size limit; the advice differs by format. function tooBigMessage(file: File): string { return isLosslessImage(file) - ? $t`This picture is too large to upload. Try reducing the image resolution and uploading again.` + ? $t`This picture is too large to upload. Try reducing the resolution and uploading again.` : $t`This picture is too large to upload. Try saving it at a lower JPEG quality and uploading again.`; } diff --git a/frontend/viewer/src/locales/en.po b/frontend/viewer/src/locales/en.po index d2b089618d..1ff28fdd4d 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -411,16 +411,6 @@ msgstr "Classic FieldWorks Projects" msgid "clear" msgstr "clear" -#. Placeholder on a not-yet-loaded picture (desktop); clicking it loads and displays the image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Click to load" -msgstr "Click to load" - -#. Shown on a picture whose image failed to load (desktop); clicking it retries the load -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Click to retry" -msgstr "Click to retry" - #. Button to close popup #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte @@ -1205,11 +1195,6 @@ msgstr "I understand that this can't be undone" msgid "ID" msgstr "ID" -#. Error displayed in place of a picture when the media file is missing from local storage -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Image not found" -msgstr "Image not found" - #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1316,6 +1301,10 @@ msgstr "Load all" msgid "Load it to preview or export." msgstr "Load it to preview or export." +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Load picture" +msgstr "Load picture" + #: src/lib/media-manager/MediaManagerView.svelte msgid "Loaded {0} file(s)" msgstr "Loaded {0} file(s)" @@ -1716,11 +1705,6 @@ msgstr "Offline" msgid "Offline, unable to download" msgstr "Offline, unable to download" -#. Error when offline and trying to download image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Offline, unable to download image" -msgstr "Offline, unable to download image" - #: src/lib/media-manager/MediaFileDetail.svelte msgid "Offline, unable to open file" msgstr "Offline, unable to open file" @@ -1841,6 +1825,10 @@ msgstr "Picture" msgid "Picture actions" msgstr "Picture actions" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Picture not found" +msgstr "Picture not found" + #. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) #: src/lib/views/entity-config.ts msgid "Pictures" @@ -2173,6 +2161,10 @@ msgstr "Show" msgid "Show {0} more..." msgstr "Show {0} more..." +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Show or hide captions" +msgstr "Show or hide captions" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Sign in to add comments." msgstr "Sign in to add comments." @@ -2305,16 +2297,6 @@ msgstr "Syncing..." msgid "System" msgstr "System" -#. Placeholder on a not-yet-loaded picture (mobile/touch); tapping it loads and displays the image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Tap to load" -msgstr "Tap to load" - -#. Shown on a picture whose image failed to load (mobile/touch); tapping it retries the load -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Tap to retry" -msgstr "Tap to retry" - #. Page title with task name #: src/project/tasks/TasksView.svelte msgid "Task {0}" @@ -2394,8 +2376,8 @@ msgid "This file is only available remotely." msgstr "This file is only available remotely." #: src/lib/entry-editor/field-editors/PicturesEditor.svelte -msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." -msgstr "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgid "This picture is too large to upload. Try reducing the resolution and uploading again." +msgstr "This picture is too large to upload. Try reducing the resolution and uploading again." #: src/lib/entry-editor/field-editors/PicturesEditor.svelte msgid "This picture is too large to upload. Try saving it at a lower JPEG quality and uploading again." @@ -2430,11 +2412,6 @@ msgstr "This will synchronize the FieldWorks Lite and FieldWorks Classic copies msgid "Thread" msgstr "Thread" -#. Accessible label for the caption block in the fullscreen picture viewer; clicking it collapses to the first caption or expands to show all -#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte -msgid "Toggle captions" -msgstr "Toggle captions" - #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" @@ -2470,6 +2447,7 @@ msgstr "Translation {0}" msgid "Troubleshoot" msgstr "Troubleshoot" +#: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/layout/ViewErrorBoundary.svelte msgid "Try again" msgstr "Try again" @@ -2512,10 +2490,9 @@ msgstr "Unable to export file" msgid "Unable to load audio" msgstr "Unable to load audio" -#. Generic error displayed in place of a picture when loading fails for an unspecified reason #: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Unable to load image" -msgstr "Unable to load image" +msgid "Unable to load picture" +msgstr "Unable to load picture" #: src/lib/media-manager/MediaFileDetail.svelte #: src/lib/media-manager/MediaFileDetail.svelte @@ -2791,6 +2768,10 @@ msgstr "You don't have permission to download project {0} from {1}" msgid "You have already downloaded the {0} project" msgstr "You have already downloaded the {0} project" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "You're offline" +msgstr "You're offline" + #: src/project/dashboard/DashboardView.svelte msgid "You've passed every milestone. What an achievement!" msgstr "You've passed every milestone. What an achievement!" diff --git a/frontend/viewer/src/locales/es.po b/frontend/viewer/src/locales/es.po index 4d02833601..b6d936a8bc 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -416,16 +416,6 @@ msgstr "Proyectos clásicos de FieldWorks" msgid "clear" msgstr "borrar" -#. Placeholder on a not-yet-loaded picture (desktop); clicking it loads and displays the image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Click to load" -msgstr "" - -#. Shown on a picture whose image failed to load (desktop); clicking it retries the load -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Click to retry" -msgstr "" - #. Button to close popup #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte @@ -1210,11 +1200,6 @@ msgstr "Entiendo que esto no se puede deshacer" msgid "ID" msgstr "" -#. Error displayed in place of a picture when the media file is missing from local storage -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Image not found" -msgstr "" - #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1321,6 +1306,10 @@ msgstr "" msgid "Load it to preview or export." msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Load picture" +msgstr "" + #: src/lib/media-manager/MediaManagerView.svelte msgid "Loaded {0} file(s)" msgstr "" @@ -1721,11 +1710,6 @@ msgstr "Fuera de línea" msgid "Offline, unable to download" msgstr "Desconectado, no se puede descargar" -#. Error when offline and trying to download image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Offline, unable to download image" -msgstr "" - #: src/lib/media-manager/MediaFileDetail.svelte msgid "Offline, unable to open file" msgstr "" @@ -1846,6 +1830,10 @@ msgstr "" msgid "Picture actions" msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Picture not found" +msgstr "" + #. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) #: src/lib/views/entity-config.ts msgid "Pictures" @@ -2178,6 +2166,10 @@ msgstr "Mostrar" msgid "Show {0} more..." msgstr "Mostrar {0} más..." +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Show or hide captions" +msgstr "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Sign in to add comments." msgstr "" @@ -2310,16 +2302,6 @@ msgstr "Sincronizando..." msgid "System" msgstr "Sistema" -#. Placeholder on a not-yet-loaded picture (mobile/touch); tapping it loads and displays the image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Tap to load" -msgstr "" - -#. Shown on a picture whose image failed to load (mobile/touch); tapping it retries the load -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Tap to retry" -msgstr "" - #. Page title with task name #: src/project/tasks/TasksView.svelte msgid "Task {0}" @@ -2399,7 +2381,7 @@ msgid "This file is only available remotely." msgstr "" #: src/lib/entry-editor/field-editors/PicturesEditor.svelte -msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgid "This picture is too large to upload. Try reducing the resolution and uploading again." msgstr "" #: src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -2435,11 +2417,6 @@ msgstr "Esto sincronizará las copias de FieldWorks Lite y FieldWorks Classic de msgid "Thread" msgstr "" -#. Accessible label for the caption block in the fullscreen picture viewer; clicking it collapses to the first caption or expands to show all -#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte -msgid "Toggle captions" -msgstr "" - #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" @@ -2475,6 +2452,7 @@ msgstr "Traducción {0}" msgid "Troubleshoot" msgstr "Solución de problemas" +#: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/layout/ViewErrorBoundary.svelte msgid "Try again" msgstr "" @@ -2517,9 +2495,8 @@ msgstr "" msgid "Unable to load audio" msgstr "" -#. Generic error displayed in place of a picture when loading fails for an unspecified reason #: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Unable to load image" +msgid "Unable to load picture" msgstr "" #: src/lib/media-manager/MediaFileDetail.svelte @@ -2796,6 +2773,10 @@ msgstr "No tiene permiso para descargar el proyecto {0} desde {1}" msgid "You have already downloaded the {0} project" msgstr "Ya has descargado el proyecto {0}" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "You're offline" +msgstr "" + #: src/project/dashboard/DashboardView.svelte msgid "You've passed every milestone. What an achievement!" msgstr "" diff --git a/frontend/viewer/src/locales/fr.po b/frontend/viewer/src/locales/fr.po index 6ca773429f..824ab6fb89 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -416,16 +416,6 @@ msgstr "Projets FieldWorks classiques" msgid "clear" msgstr "effacer" -#. Placeholder on a not-yet-loaded picture (desktop); clicking it loads and displays the image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Click to load" -msgstr "" - -#. Shown on a picture whose image failed to load (desktop); clicking it retries the load -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Click to retry" -msgstr "" - #. Button to close popup #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte @@ -1210,11 +1200,6 @@ msgstr "Je comprends que cela ne peut pas être annulé" msgid "ID" msgstr "" -#. Error displayed in place of a picture when the media file is missing from local storage -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Image not found" -msgstr "" - #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1321,6 +1306,10 @@ msgstr "" msgid "Load it to preview or export." msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Load picture" +msgstr "" + #: src/lib/media-manager/MediaManagerView.svelte msgid "Loaded {0} file(s)" msgstr "" @@ -1721,11 +1710,6 @@ msgstr "Déconnecté" msgid "Offline, unable to download" msgstr "Déconnecté, impossible de télécharger" -#. Error when offline and trying to download image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Offline, unable to download image" -msgstr "Déconnecté, impossible de télécharger l'image" - #: src/lib/media-manager/MediaFileDetail.svelte msgid "Offline, unable to open file" msgstr "" @@ -1846,6 +1830,10 @@ msgstr "" msgid "Picture actions" msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Picture not found" +msgstr "" + #. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) #: src/lib/views/entity-config.ts msgid "Pictures" @@ -2178,6 +2166,10 @@ msgstr "Afficher" msgid "Show {0} more..." msgstr "Afficher {0} plus..." +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Show or hide captions" +msgstr "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Sign in to add comments." msgstr "" @@ -2310,16 +2302,6 @@ msgstr "Synchronisation en cours..." msgid "System" msgstr "Système" -#. Placeholder on a not-yet-loaded picture (mobile/touch); tapping it loads and displays the image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Tap to load" -msgstr "" - -#. Shown on a picture whose image failed to load (mobile/touch); tapping it retries the load -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Tap to retry" -msgstr "" - #. Page title with task name #: src/project/tasks/TasksView.svelte msgid "Task {0}" @@ -2399,7 +2381,7 @@ msgid "This file is only available remotely." msgstr "" #: src/lib/entry-editor/field-editors/PicturesEditor.svelte -msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgid "This picture is too large to upload. Try reducing the resolution and uploading again." msgstr "" #: src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -2435,11 +2417,6 @@ msgstr "Cela synchronisera les copies FieldWorks Lite et FieldWorks Classic de v msgid "Thread" msgstr "" -#. Accessible label for the caption block in the fullscreen picture viewer; clicking it collapses to the first caption or expands to show all -#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte -msgid "Toggle captions" -msgstr "" - #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" @@ -2475,6 +2452,7 @@ msgstr "Traduction {0}" msgid "Troubleshoot" msgstr "Dépannage" +#: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/layout/ViewErrorBoundary.svelte msgid "Try again" msgstr "" @@ -2517,9 +2495,8 @@ msgstr "" msgid "Unable to load audio" msgstr "" -#. Generic error displayed in place of a picture when loading fails for an unspecified reason #: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Unable to load image" +msgid "Unable to load picture" msgstr "" #: src/lib/media-manager/MediaFileDetail.svelte @@ -2796,6 +2773,10 @@ msgstr "Vous n'êtes pas autorisé(e) de télécharger le projet {0} depuis {1}" msgid "You have already downloaded the {0} project" msgstr "Vous avez déjà téléchargé le projet {0}" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "You're offline" +msgstr "" + #: src/project/dashboard/DashboardView.svelte msgid "You've passed every milestone. What an achievement!" msgstr "" diff --git a/frontend/viewer/src/locales/id.po b/frontend/viewer/src/locales/id.po index cba3ce397d..1906f93ce4 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -416,16 +416,6 @@ msgstr "Proyek FieldWorks Klasik" msgid "clear" msgstr "jelas" -#. Placeholder on a not-yet-loaded picture (desktop); clicking it loads and displays the image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Click to load" -msgstr "" - -#. Shown on a picture whose image failed to load (desktop); clicking it retries the load -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Click to retry" -msgstr "" - #. Button to close popup #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte @@ -1210,11 +1200,6 @@ msgstr "Saya memahami bahwa hal ini tidak dapat dibatalkan" msgid "ID" msgstr "" -#. Error displayed in place of a picture when the media file is missing from local storage -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Image not found" -msgstr "" - #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1321,6 +1306,10 @@ msgstr "" msgid "Load it to preview or export." msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Load picture" +msgstr "" + #: src/lib/media-manager/MediaManagerView.svelte msgid "Loaded {0} file(s)" msgstr "" @@ -1721,11 +1710,6 @@ msgstr "Offline" msgid "Offline, unable to download" msgstr "Offline, tidak dapat mengunduh" -#. Error when offline and trying to download image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Offline, unable to download image" -msgstr "" - #: src/lib/media-manager/MediaFileDetail.svelte msgid "Offline, unable to open file" msgstr "" @@ -1846,6 +1830,10 @@ msgstr "" msgid "Picture actions" msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Picture not found" +msgstr "" + #. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) #: src/lib/views/entity-config.ts msgid "Pictures" @@ -2178,6 +2166,10 @@ msgstr "Tampilkan" msgid "Show {0} more..." msgstr "Tampilkan {0} selengkapnya..." +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Show or hide captions" +msgstr "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Sign in to add comments." msgstr "" @@ -2310,16 +2302,6 @@ msgstr "Menyinkronkan..." msgid "System" msgstr "Sistem" -#. Placeholder on a not-yet-loaded picture (mobile/touch); tapping it loads and displays the image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Tap to load" -msgstr "" - -#. Shown on a picture whose image failed to load (mobile/touch); tapping it retries the load -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Tap to retry" -msgstr "" - #. Page title with task name #: src/project/tasks/TasksView.svelte msgid "Task {0}" @@ -2399,7 +2381,7 @@ msgid "This file is only available remotely." msgstr "" #: src/lib/entry-editor/field-editors/PicturesEditor.svelte -msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgid "This picture is too large to upload. Try reducing the resolution and uploading again." msgstr "" #: src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -2435,11 +2417,6 @@ msgstr "Ini akan menyinkronkan salinan FieldWorks Lite dan FieldWorks Classic da msgid "Thread" msgstr "" -#. Accessible label for the caption block in the fullscreen picture viewer; clicking it collapses to the first caption or expands to show all -#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte -msgid "Toggle captions" -msgstr "" - #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" @@ -2475,6 +2452,7 @@ msgstr "Terjemahan {0}" msgid "Troubleshoot" msgstr "Memecahkan masalah" +#: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/layout/ViewErrorBoundary.svelte msgid "Try again" msgstr "" @@ -2517,9 +2495,8 @@ msgstr "" msgid "Unable to load audio" msgstr "" -#. Generic error displayed in place of a picture when loading fails for an unspecified reason #: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Unable to load image" +msgid "Unable to load picture" msgstr "" #: src/lib/media-manager/MediaFileDetail.svelte @@ -2796,6 +2773,10 @@ msgstr "Anda tidak memiliki izin untuk mengunduh proyek {0} dari {1}" msgid "You have already downloaded the {0} project" msgstr "Anda telah mengunduh proyek {0}" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "You're offline" +msgstr "" + #: src/project/dashboard/DashboardView.svelte msgid "You've passed every milestone. What an achievement!" msgstr "" diff --git a/frontend/viewer/src/locales/ko.po b/frontend/viewer/src/locales/ko.po index eb4f768794..af05a30125 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -416,16 +416,6 @@ msgstr "클래식 FieldWorks 프로젝트" msgid "clear" msgstr "clear" -#. Placeholder on a not-yet-loaded picture (desktop); clicking it loads and displays the image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Click to load" -msgstr "" - -#. Shown on a picture whose image failed to load (desktop); clicking it retries the load -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Click to retry" -msgstr "" - #. Button to close popup #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte @@ -1210,11 +1200,6 @@ msgstr "이 작업은 되돌릴 수 없다는 것을 이해합니다." msgid "ID" msgstr "" -#. Error displayed in place of a picture when the media file is missing from local storage -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Image not found" -msgstr "" - #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1321,6 +1306,10 @@ msgstr "" msgid "Load it to preview or export." msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Load picture" +msgstr "" + #: src/lib/media-manager/MediaManagerView.svelte msgid "Loaded {0} file(s)" msgstr "" @@ -1721,11 +1710,6 @@ msgstr "오프라인" msgid "Offline, unable to download" msgstr "오프라인 상태, 다운로드할 수 없음" -#. Error when offline and trying to download image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Offline, unable to download image" -msgstr "" - #: src/lib/media-manager/MediaFileDetail.svelte msgid "Offline, unable to open file" msgstr "" @@ -1846,6 +1830,10 @@ msgstr "" msgid "Picture actions" msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Picture not found" +msgstr "" + #. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) #: src/lib/views/entity-config.ts msgid "Pictures" @@ -2178,6 +2166,10 @@ msgstr "표시" msgid "Show {0} more..." msgstr "보기 {0} 더보기..." +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Show or hide captions" +msgstr "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Sign in to add comments." msgstr "" @@ -2310,16 +2302,6 @@ msgstr "동기화..." msgid "System" msgstr "시스템" -#. Placeholder on a not-yet-loaded picture (mobile/touch); tapping it loads and displays the image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Tap to load" -msgstr "" - -#. Shown on a picture whose image failed to load (mobile/touch); tapping it retries the load -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Tap to retry" -msgstr "" - #. Page title with task name #: src/project/tasks/TasksView.svelte msgid "Task {0}" @@ -2399,7 +2381,7 @@ msgid "This file is only available remotely." msgstr "" #: src/lib/entry-editor/field-editors/PicturesEditor.svelte -msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgid "This picture is too large to upload. Try reducing the resolution and uploading again." msgstr "" #: src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -2435,11 +2417,6 @@ msgstr "이렇게 하면 렉스박스에서 프로젝트의 FieldWorks Lite 및 msgid "Thread" msgstr "" -#. Accessible label for the caption block in the fullscreen picture viewer; clicking it collapses to the first caption or expands to show all -#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte -msgid "Toggle captions" -msgstr "" - #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" @@ -2475,6 +2452,7 @@ msgstr "번역 {0}" msgid "Troubleshoot" msgstr "문제 해결" +#: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/layout/ViewErrorBoundary.svelte msgid "Try again" msgstr "" @@ -2517,9 +2495,8 @@ msgstr "" msgid "Unable to load audio" msgstr "" -#. Generic error displayed in place of a picture when loading fails for an unspecified reason #: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Unable to load image" +msgid "Unable to load picture" msgstr "" #: src/lib/media-manager/MediaFileDetail.svelte @@ -2796,6 +2773,10 @@ msgstr "{1}에서 {0} 프로젝트를 다운로드할 수 있는 권한이 없 msgid "You have already downloaded the {0} project" msgstr "이미 {0} 프로젝트를 다운로드하셨습니다." +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "You're offline" +msgstr "" + #: src/project/dashboard/DashboardView.svelte msgid "You've passed every milestone. What an achievement!" msgstr "" diff --git a/frontend/viewer/src/locales/ms.po b/frontend/viewer/src/locales/ms.po index 222a654a22..bed8916fe9 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -416,16 +416,6 @@ msgstr "Projek FieldWorks Klasik" msgid "clear" msgstr "padam" -#. Placeholder on a not-yet-loaded picture (desktop); clicking it loads and displays the image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Click to load" -msgstr "" - -#. Shown on a picture whose image failed to load (desktop); clicking it retries the load -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Click to retry" -msgstr "" - #. Button to close popup #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte @@ -1210,11 +1200,6 @@ msgstr "Saya faham bahawa ini tidak boleh dibuat asal" msgid "ID" msgstr "" -#. Error displayed in place of a picture when the media file is missing from local storage -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Image not found" -msgstr "" - #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1321,6 +1306,10 @@ msgstr "" msgid "Load it to preview or export." msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Load picture" +msgstr "" + #: src/lib/media-manager/MediaManagerView.svelte msgid "Loaded {0} file(s)" msgstr "" @@ -1721,11 +1710,6 @@ msgstr "Luar talian" msgid "Offline, unable to download" msgstr "Luar talian, tidak dapat memuat turun" -#. Error when offline and trying to download image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Offline, unable to download image" -msgstr "" - #: src/lib/media-manager/MediaFileDetail.svelte msgid "Offline, unable to open file" msgstr "" @@ -1846,6 +1830,10 @@ msgstr "" msgid "Picture actions" msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Picture not found" +msgstr "" + #. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) #: src/lib/views/entity-config.ts msgid "Pictures" @@ -2178,6 +2166,10 @@ msgstr "Tunjukkan" msgid "Show {0} more..." msgstr "Tunjukkan {0} lagi..." +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Show or hide captions" +msgstr "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Sign in to add comments." msgstr "" @@ -2310,16 +2302,6 @@ msgstr "Sedang disegerakkan..." msgid "System" msgstr "Sistem" -#. Placeholder on a not-yet-loaded picture (mobile/touch); tapping it loads and displays the image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Tap to load" -msgstr "" - -#. Shown on a picture whose image failed to load (mobile/touch); tapping it retries the load -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Tap to retry" -msgstr "" - #. Page title with task name #: src/project/tasks/TasksView.svelte msgid "Task {0}" @@ -2399,7 +2381,7 @@ msgid "This file is only available remotely." msgstr "" #: src/lib/entry-editor/field-editors/PicturesEditor.svelte -msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgid "This picture is too large to upload. Try reducing the resolution and uploading again." msgstr "" #: src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -2435,11 +2417,6 @@ msgstr "Ini akan menyegerakkan salinan FieldWorks Lite dan FieldWorks Classic pr msgid "Thread" msgstr "" -#. Accessible label for the caption block in the fullscreen picture viewer; clicking it collapses to the first caption or expands to show all -#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte -msgid "Toggle captions" -msgstr "" - #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" @@ -2475,6 +2452,7 @@ msgstr "Terjemahan {0}" msgid "Troubleshoot" msgstr "Selesaikan masalah" +#: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/layout/ViewErrorBoundary.svelte msgid "Try again" msgstr "" @@ -2517,9 +2495,8 @@ msgstr "" msgid "Unable to load audio" msgstr "" -#. Generic error displayed in place of a picture when loading fails for an unspecified reason #: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Unable to load image" +msgid "Unable to load picture" msgstr "" #: src/lib/media-manager/MediaFileDetail.svelte @@ -2796,6 +2773,10 @@ msgstr "Anda tidak mempunyai kebenaran untuk memuat turun projek {0} daripada {1 msgid "You have already downloaded the {0} project" msgstr "Anda telah pun memuat turun projek {0}" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "You're offline" +msgstr "" + #: src/project/dashboard/DashboardView.svelte msgid "You've passed every milestone. What an achievement!" msgstr "" diff --git a/frontend/viewer/src/locales/sw.po b/frontend/viewer/src/locales/sw.po index c3b92650d1..5f52ecbc75 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -416,16 +416,6 @@ msgstr "Miradi ya FieldWorks Classic" msgid "clear" msgstr "futa" -#. Placeholder on a not-yet-loaded picture (desktop); clicking it loads and displays the image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Click to load" -msgstr "" - -#. Shown on a picture whose image failed to load (desktop); clicking it retries the load -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Click to retry" -msgstr "" - #. Button to close popup #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte @@ -1210,11 +1200,6 @@ msgstr "Ninaelewa kuwa hii haiwezi kurudishwa nyuma" msgid "ID" msgstr "" -#. Error displayed in place of a picture when the media file is missing from local storage -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Image not found" -msgstr "" - #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1321,6 +1306,10 @@ msgstr "" msgid "Load it to preview or export." msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Load picture" +msgstr "" + #: src/lib/media-manager/MediaManagerView.svelte msgid "Loaded {0} file(s)" msgstr "" @@ -1721,11 +1710,6 @@ msgstr "Nyuma ya mtandao" msgid "Offline, unable to download" msgstr "Nyuma ya mtandao, haiwezi kupakua" -#. Error when offline and trying to download image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Offline, unable to download image" -msgstr "" - #: src/lib/media-manager/MediaFileDetail.svelte msgid "Offline, unable to open file" msgstr "" @@ -1846,6 +1830,10 @@ msgstr "" msgid "Picture actions" msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Picture not found" +msgstr "" + #. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) #: src/lib/views/entity-config.ts msgid "Pictures" @@ -2178,6 +2166,10 @@ msgstr "Onyesha" msgid "Show {0} more..." msgstr "Onyesha {0} zaidi..." +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Show or hide captions" +msgstr "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Sign in to add comments." msgstr "" @@ -2310,16 +2302,6 @@ msgstr "Inaoanisha..." msgid "System" msgstr "Mfumo" -#. Placeholder on a not-yet-loaded picture (mobile/touch); tapping it loads and displays the image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Tap to load" -msgstr "" - -#. Shown on a picture whose image failed to load (mobile/touch); tapping it retries the load -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Tap to retry" -msgstr "" - #. Page title with task name #: src/project/tasks/TasksView.svelte msgid "Task {0}" @@ -2399,7 +2381,7 @@ msgid "This file is only available remotely." msgstr "" #: src/lib/entry-editor/field-editors/PicturesEditor.svelte -msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgid "This picture is too large to upload. Try reducing the resolution and uploading again." msgstr "" #: src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -2435,11 +2417,6 @@ msgstr "Hii itaoanisha kopia za FieldWorks Lite na FieldWorks Classic za mradi w msgid "Thread" msgstr "" -#. Accessible label for the caption block in the fullscreen picture viewer; clicking it collapses to the first caption or expands to show all -#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte -msgid "Toggle captions" -msgstr "" - #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" @@ -2475,6 +2452,7 @@ msgstr "Tafsiri {0}" msgid "Troubleshoot" msgstr "Tatanua" +#: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/layout/ViewErrorBoundary.svelte msgid "Try again" msgstr "" @@ -2517,9 +2495,8 @@ msgstr "" msgid "Unable to load audio" msgstr "" -#. Generic error displayed in place of a picture when loading fails for an unspecified reason #: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Unable to load image" +msgid "Unable to load picture" msgstr "" #: src/lib/media-manager/MediaFileDetail.svelte @@ -2796,6 +2773,10 @@ msgstr "Huna ruhusa ya kupakua mradi {0} kutoka {1}" msgid "You have already downloaded the {0} project" msgstr "Umeshapakua mradi wa {0}" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "You're offline" +msgstr "" + #: src/project/dashboard/DashboardView.svelte msgid "You've passed every milestone. What an achievement!" msgstr "" diff --git a/frontend/viewer/src/locales/vi.po b/frontend/viewer/src/locales/vi.po index c1c5c7e4b5..1f7ab66434 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -416,16 +416,6 @@ msgstr "Dự án FieldWorks Classic" msgid "clear" msgstr "xóa" -#. Placeholder on a not-yet-loaded picture (desktop); clicking it loads and displays the image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Click to load" -msgstr "" - -#. Shown on a picture whose image failed to load (desktop); clicking it retries the load -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Click to retry" -msgstr "" - #. Button to close popup #: src/lib/about/AboutDialog.svelte #: src/lib/components/field-editors/multi-select.svelte @@ -1210,11 +1200,6 @@ msgstr "Tôi hiểu rằng điều này không thể hoàn tác" msgid "ID" msgstr "" -#. Error displayed in place of a picture when the media file is missing from local storage -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Image not found" -msgstr "" - #. Dev-mode only icon button tooltip on a Classic FieldWorks project list item. Imports the fwdata project into FwLite. #: src/home/HomeView.svelte msgid "Import" @@ -1321,6 +1306,10 @@ msgstr "" msgid "Load it to preview or export." msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Load picture" +msgstr "" + #: src/lib/media-manager/MediaManagerView.svelte msgid "Loaded {0} file(s)" msgstr "" @@ -1721,11 +1710,6 @@ msgstr "Ngoại tuyến" msgid "Offline, unable to download" msgstr "Ngoại tuyến, không thể tải xuống" -#. Error when offline and trying to download image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Offline, unable to download image" -msgstr "" - #: src/lib/media-manager/MediaFileDetail.svelte msgid "Offline, unable to open file" msgstr "" @@ -1846,6 +1830,10 @@ msgstr "" msgid "Picture actions" msgstr "" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "Picture not found" +msgstr "" + #. Sense-level field label in the entry editor for the FieldWorks Pictures field (attached images) #: src/lib/views/entity-config.ts msgid "Pictures" @@ -2178,6 +2166,10 @@ msgstr "Hiển thị" msgid "Show {0} more..." msgstr "Hiển thị thêm {0}..." +#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte +msgid "Show or hide captions" +msgstr "" + #: src/lib/entry-editor/CommentDialog.svelte msgid "Sign in to add comments." msgstr "" @@ -2310,16 +2302,6 @@ msgstr "Đang đồng bộ..." msgid "System" msgstr "Hệ thống" -#. Placeholder on a not-yet-loaded picture (mobile/touch); tapping it loads and displays the image -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Tap to load" -msgstr "" - -#. Shown on a picture whose image failed to load (mobile/touch); tapping it retries the load -#: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Tap to retry" -msgstr "" - #. Page title with task name #: src/project/tasks/TasksView.svelte msgid "Task {0}" @@ -2399,7 +2381,7 @@ msgid "This file is only available remotely." msgstr "" #: src/lib/entry-editor/field-editors/PicturesEditor.svelte -msgid "This picture is too large to upload. Try reducing the image resolution and uploading again." +msgid "This picture is too large to upload. Try reducing the resolution and uploading again." msgstr "" #: src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -2435,11 +2417,6 @@ msgstr "Điều này sẽ đồng bộ các bản sao FieldWorks Lite và FieldW msgid "Thread" msgstr "" -#. Accessible label for the caption block in the fullscreen picture viewer; clicking it collapses to the first caption or expands to show all -#: src/lib/entry-editor/field-editors/PictureViewerDialog.svelte -msgid "Toggle captions" -msgstr "" - #. Button to show/hide filter panel #: src/project/browse/SearchFilter.svelte msgid "Toggle filters" @@ -2475,6 +2452,7 @@ msgstr "Bản dịch {0}" msgid "Troubleshoot" msgstr "Khắc phục sự cố" +#: src/lib/entry-editor/field-editors/PictureImage.svelte #: src/lib/layout/ViewErrorBoundary.svelte msgid "Try again" msgstr "" @@ -2517,9 +2495,8 @@ msgstr "" msgid "Unable to load audio" msgstr "" -#. Generic error displayed in place of a picture when loading fails for an unspecified reason #: src/lib/entry-editor/field-editors/PictureImage.svelte -msgid "Unable to load image" +msgid "Unable to load picture" msgstr "" #: src/lib/media-manager/MediaFileDetail.svelte @@ -2796,6 +2773,10 @@ msgstr "Bạn không có quyền tải dự án {0} từ {1}" msgid "You have already downloaded the {0} project" msgstr "Bạn đã tải xuống dự án {0}" +#: src/lib/entry-editor/field-editors/PictureImage.svelte +msgid "You're offline" +msgstr "" + #: src/project/dashboard/DashboardView.svelte msgid "You've passed every milestone. What an achievement!" msgstr "" diff --git a/frontend/viewer/tests/ui/sense-pictures-touch.test.ts b/frontend/viewer/tests/ui/sense-pictures-touch.test.ts new file mode 100644 index 0000000000..e1ae562817 --- /dev/null +++ b/frontend/viewer/tests/ui/sense-pictures-touch.test.ts @@ -0,0 +1,41 @@ +import {expect, test} from '@playwright/test'; +import {DemoProjectPage} from './demo-project.page'; + +// A valid 96x96 PNG (same one used by sense-pictures.test.ts) so the upload flow yields a real, +// clickable rendered image. +const TEST_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAIAAABt+uBvAAAAjklEQVR42u3QMQ0AAAgDsKlDGJoQiANOriZV0FQPhygQJEiQIEGCBAlCkCBBggQJEiQIQYIECRIkSJAgBAkSJEiQIEGCBCFIkCBBggQJEoQgQYIECRIkSBCCBAkSJEiQIEGCECRIkCBBggQJQpAgQYIECRIkCEGCBAkSJEiQIEEIEiRIkCBBggQhSJCgPwuoEXMcuO2DAAAAAABJRU5ErkJggg==', + 'base64', +); + +// Touch-specific: a tap on the corner actions menu can fire a stray click on the image behind it +// once the menu is open. The image must ignore taps while a menu is open so it doesn't also open +// the viewer. (A real device fires that stray click; here we dispatch it directly to reproduce it +// deterministically — Playwright's own tap() is too clean to trigger the ghost click.) +test.use({hasTouch: true}); + +test('the image ignores a click while the actions menu is open (touch ghost-click)', async ({page}) => { + await page.setViewportSize({width: 390, height: 844}); + const projectPage = new DemoProjectPage(page); + await projectPage.goto(); + await projectPage.selectEntryByFilter('ambuka'); + const field = page.locator('[style*="grid-area: pictures"]').first(); + await field.locator('input[type="file"]').setInputFiles({name: 'photo.png', mimeType: 'image/png', buffer: TEST_PNG}); + await expect(field.locator('img').first()).toHaveAttribute('src', /^blob:/, {timeout: 5000}); + + const viewButton = field.getByRole('button', {name: 'View Picture'}); + const viewer = page.getByRole('dialog').filter({has: page.getByRole('heading', {name: 'Picture'})}); + + // Positive control: with no menu open, a click on the image opens the viewer (proves the click + // reaches the handler, so the guarded case below can't pass vacuously). + await viewButton.dispatchEvent('click'); + await expect(viewer).toBeVisible({timeout: 5000}); + await viewer.getByRole('button', {name: 'Close'}).tap(); + await expect(viewer).toHaveCount(0); + + // Open the actions menu, then fire the stray image click: the viewer must NOT open. + await field.getByRole('button', {name: 'Picture actions'}).first().tap(); + await expect(page.getByRole('button', {name: 'Edit'})).toBeVisible({timeout: 5000}); + await viewButton.dispatchEvent('click'); + await expect(viewer).toHaveCount(0); +}); diff --git a/frontend/viewer/tests/ui/sense-pictures.test.ts b/frontend/viewer/tests/ui/sense-pictures.test.ts index 472454058a..806e859078 100644 --- a/frontend/viewer/tests/ui/sense-pictures.test.ts +++ b/frontend/viewer/tests/ui/sense-pictures.test.ts @@ -29,8 +29,8 @@ test.describe('Sense pictures', () => { await expect(picturesField).toBeVisible({timeout: 5000}); // The demo's pre-seeded pictures stand in for a remote media service, so they aren't available - // locally: the field shows a "Click to load" placeholder rather than auto-loading. - await expect(picturesField.getByRole('button', {name: 'Click to load'}).first()).toBeVisible({timeout: 5000}); + // locally: the field shows a "Load picture" placeholder rather than auto-loading. + await expect(picturesField.getByRole('button', {name: 'Load picture'}).first()).toBeVisible({timeout: 5000}); await expect(picturesField.locator('img')).toHaveCount(0); // Clicking downloads it — a blob: src proves the full pipeline ran (getFileStream -> Blob -> object url). @@ -74,12 +74,12 @@ test.describe('Sense pictures', () => { buffer: TEST_PNG, }); - // An uploaded picture is available locally, so it loads automatically (no "click to load" + // An uploaded picture is available locally, so it loads automatically (no "Load picture" // placeholder) and renders into a blob url. const image = picturesField.locator('img').first(); await expect(image).toBeVisible({timeout: 5000}); await expect(image).toHaveAttribute('src', /^blob:/); - await expect(picturesField.getByRole('button', {name: 'Click to load'})).toHaveCount(0); + await expect(picturesField.getByRole('button', {name: 'Load picture'})).toHaveCount(0); }); test('re-uploading an existing file adds a second picture that reuses it', async ({page}) => { @@ -133,9 +133,9 @@ test.describe('Sense pictures', () => { await expect(page.getByRole('menuitem', {name: 'Edit'})).toBeVisible({timeout: 5000}); } - /** Clicks the first "Click to load" placeholder in a scope and waits for its image to load. */ + /** Clicks the first "Load picture" placeholder in a scope and waits for its image to load. */ async function loadFirstPicture(scope: Locator) { - await scope.getByRole('button', {name: 'Click to load'}).first().click(); + await scope.getByRole('button', {name: 'Load picture'}).first().click(); await expect(scope.locator('img').first()).toHaveAttribute('src', /^blob:/, {timeout: 5000}); } @@ -274,7 +274,7 @@ test.describe('Sense pictures', () => { await projectPage.selectEntryByFilter('nyumba'); const picturesField = page.locator('[style*="grid-area: pictures"]').first(); // Download works without loading the image; act on the (unloaded) placeholder's actions menu. - await expect(picturesField.getByRole('button', {name: 'Click to load'}).first()).toBeVisible({timeout: 5000}); + await expect(picturesField.getByRole('button', {name: 'Load picture'}).first()).toBeVisible({timeout: 5000}); await openPictureMenu(page, picturesField); await page.getByRole('menuitem', {name: 'Edit'}).click(); @@ -292,7 +292,7 @@ test.describe('Sense pictures', () => { await projectPage.goto(); await projectPage.selectEntryByFilter('nyumba'); const picturesField = page.locator('[style*="grid-area: pictures"]').first(); - await expect(picturesField.getByRole('button', {name: 'Click to load'}).first()).toBeVisible({timeout: 5000}); + await expect(picturesField.getByRole('button', {name: 'Load picture'}).first()).toBeVisible({timeout: 5000}); const downloadPromise = page.waitForEvent('download'); await openPictureMenu(page, picturesField); @@ -354,8 +354,8 @@ test.describe('Sense pictures', () => { await expect(next).toBeDisabled(); await expect(previous).toBeEnabled(); - // Picture 2 wasn't pre-loaded, so the viewer shows its own "click to load"; loading works here too. - await expect(viewer.getByRole('button', {name: 'Click to load'})).toBeVisible(); + // Picture 2 wasn't pre-loaded, so the viewer shows its own "Load picture" placeholder; loading works here too. + await expect(viewer.getByRole('button', {name: 'Load picture'})).toBeVisible(); await loadFirstPicture(viewer); // Previous returns to the first picture. @@ -442,7 +442,7 @@ test.describe('Sense pictures', () => { await expect(viewer.getByText('A traditional house')).toBeVisible(); await expect(viewer.getByText('Uma casa tradicional')).toBeVisible(); - const toggle = viewer.getByRole('button', {name: 'Toggle captions'}); + const toggle = viewer.getByRole('button', {name: 'Show or hide captions'}); // A disclosure chevron signals the toggle; it points up (rotated) while expanded. const chevron = toggle.locator('.i-mdi-chevron-down'); await expect(chevron).toBeVisible(); @@ -464,7 +464,7 @@ test.describe('Sense pictures', () => { const projectPage = new DemoProjectPage(page); await projectPage.goto(); - // "nyumba"'s first picture is remote-only, so it starts as a "Click to load" placeholder. Click + // "nyumba"'s first picture is remote-only, so it starts as a "Load picture" placeholder. Click // it to download the file, which then lives locally (server-side cache). await projectPage.selectEntryByFilter('nyumba'); let picturesField = page.locator('[style*="grid-area: pictures"]').first(); @@ -475,12 +475,12 @@ test.describe('Sense pictures', () => { await projectPage.selectEntryByFilter('ambuka'); await projectPage.selectEntryByFilter('nyumba'); - // ...so that picture displays again on its own — no "click to load" placeholder, no re-click and + // ...so that picture displays again on its own — no "Load picture" placeholder, no re-click and // no extra remote download. (The object URL differs: a fresh cache minted a new one.) Scope to // the first picture: "nyumba"'s other pictures were never downloaded, so they stay placeholders. picturesField = page.locator('[style*="grid-area: pictures"]').first(); const firstPicture = picturesField.locator('figure').first(); await expect(firstPicture.locator('img')).toHaveAttribute('src', /^blob:/, {timeout: 5000}); - await expect(firstPicture.getByRole('button', {name: 'Click to load'})).toHaveCount(0); + await expect(firstPicture.getByRole('button', {name: 'Load picture'})).toHaveCount(0); }); }); From 86fdf243f3ca8aeeb74a9e323276b2504dce466f Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 23 Jul 2026 12:20:26 +0200 Subject: [PATCH 27/32] Simplify ImageService to a promise-returning loadImage Address Kevin's review: replace the ensureLocal/download/get reactive-cache surface with loadImage(uri, {downloadIfMissing, bypassCache}) that resolves to the final ImageState (no 'loading' variant, that's the unresolved promise) and keeps the object-URL cache internal. The api is now required (a getter that throws if the project isn't ready) instead of silently caching an error state, and the local fallback is only built when a project context actually exists. PictureImage owns its own loading UI and awaits loadImage. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../field-editors/PictureImage.svelte | 56 ++++--- .../field-editors/image-service.svelte.ts | 137 ++++++++---------- .../object-editors/EntryEditor.svelte | 2 +- 3 files changed, 96 insertions(+), 99 deletions(-) diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte index 8fb04e3ae9..9fada535e0 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte @@ -5,7 +5,7 @@ import {t} from 'svelte-i18n-lingui'; import {onDestroy} from 'svelte'; import PictureActionsMenu from './PictureActionsMenu.svelte'; - import {ImageService, useImageService, type ImageLoadState} from './image-service.svelte'; + import {ImageService, useImageService, type ImageState} from './image-service.svelte'; type Props = { picture: IPicture; @@ -40,15 +40,6 @@ // is open; ignore image taps while a menu is open so they don't also open the viewer/download. let menuOpen = $state(false); - function handleImageClick() { - if (menuOpen) return; - if (loadState.status === 'not-downloaded' || loadState.status === 'error') { - imageService.download(mediaUri); - return; - } - onView?.(); - } - const projectContext = useProjectContext(); const writingSystemService = useWritingSystemService(); @@ -56,22 +47,47 @@ // systems first, then analysis — which is exactly the default order of allWritingSystems(). const caption = $derived(writingSystemService.first(picture.caption) ?? ''); - // On surfaces that render pictures without an entry-view scope (new-entry dialog, activity/subject - // previews, stories), fall back to a component-local cache disposed with the component. + // Prefer the entry-view cache so a mediaUri loaded once (here or in a dialog) shows immediately + // across the entry's pictures. Outside an entry view (edit/new-entry dialog, previews) fall back + // to a component-local cache disposed with the component. Both need a project api, so without a + // project context there's nothing to load from. const sharedImageService = useImageService(); - const localImageService = sharedImageService ? undefined : new ImageService(() => projectContext?.maybeApi); - const imageService = sharedImageService ?? localImageService!; + const localImageService = + sharedImageService || !projectContext ? undefined : new ImageService(() => projectContext.api); + const imageService = sharedImageService ?? localImageService; onDestroy(() => localImageService?.dispose()); + type DisplayState = {status: 'loading'} | ImageState; + let loadState = $state({status: 'loading'}); + // A picture already available locally loads automatically; one that would have to be downloaded - // from the remote media service shows a "Load picture" placeholder instead and is fetched - // only when clicked. The cache is shared within the entry view, so a mediaUri loaded once (here or - // in a dialog) displays immediately across the entry's pictures. + // from the remote media service resolves to 'not-downloaded' (the "Load picture" placeholder) and + // is fetched only when clicked (download=true). A click on an errored picture retries the same way. const mediaUri = $derived(picture.mediaUri); + function load(download: boolean) { + if (!imageService) { + loadState = {status: 'error', reason: 'unknown'}; + return; + } + const uri = mediaUri; + loadState = {status: 'loading'}; + void imageService.loadImage(uri, {downloadIfMissing: download}).then((state) => { + // Ignore a resolution for a picture we've since navigated away from. + if (uri === picture.mediaUri) loadState = state; + }); + } $effect(() => { - imageService.ensureLocal(mediaUri); + load(false); }); - const loadState = $derived(imageService.get(mediaUri)); + + function handleImageClick() { + if (menuOpen) return; + if (loadState.status === 'not-downloaded' || loadState.status === 'error') { + load(true); + return; + } + onView?.(); + } // Clickable to download (not available locally), to retry (after an error), or to open the viewer // (loaded and interactive). @@ -83,7 +99,7 @@ const retryLabel = $derived($t`Try again`); const clickLabel = $derived(needsDownload ? loadLabel : hasError ? retryLabel : $t`View Picture`); - function errorText(state: Extract): string { + function errorText(state: Extract): string { switch (state.reason) { case 'not-found': return $t`Picture not found`; diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts b/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts index b61e3988dc..d6ebd65775 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts +++ b/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts @@ -1,107 +1,88 @@ import {Context} from 'runed'; import {onDestroy} from 'svelte'; -import {SvelteMap} from 'svelte/reactivity'; import type {IMiniLcmJsInvokable} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable'; import {ReadFileResult} from '$lib/dotnet-types/generated-types/MiniLcm/Media/ReadFileResult'; -export type ImageLoadState = - | {status: 'loading'} +export type ImageState = | {status: 'loaded'; url: string} | {status: 'not-downloaded'} | {status: 'error'; reason: 'not-found' | 'offline' | 'unknown'}; +export type LoadImageOptions = { + /** Fetch from the remote media service if the file isn't cached locally. Off by default, so an + initial load only probes what's already local and a remote-only picture resolves to + 'not-downloaded' (the "click to load" placeholder); the click passes this to force the fetch. */ + downloadIfMissing?: boolean; + /** Ignore any cached object URL and re-fetch (e.g. after the underlying file was replaced). */ + bypassCache?: boolean; +}; + /** - * Loads picture images and hands out a shared blob object URL, cached per mediaUri. Scoped (via - * context) to the entry view, so identical mediaUris across a sense's pictures — or the same picture - * shown in both the field and a dialog — are fetched and decoded a single time. The cached object - * URLs live until the entry view is torn down (see initImageService), then dispose() revokes them. + * Loads picture images and hands back a shared blob object URL per mediaUri. Scoped (via context) to + * the entry view, so identical mediaUris across a sense's pictures — or the same picture shown in + * both the field and a dialog — are fetched and decoded once. Cached object URLs live until the + * entry view is torn down (see initImageService), then dispose() revokes them. * - * ensureLocal() shows a picture that's already available locally without touching the network; a - * picture that would have to be fetched from the remote media service stays 'not-downloaded' until - * download() is called (wired to a "click/tap to load" placeholder). Because ensureLocal only reads - * what's already local, a picture downloaded once stays visible when you navigate away and back: the - * fresh entry-scoped cache re-loads it locally, no re-click needed. + * loadImage() resolves to the final state (no 'loading' — that's the unresolved promise); callers + * own their own pending UI. Only 'loaded' results are cached: a 'not-downloaded' or 'error' outcome + * is cheap to re-derive and must stay re-attemptable, so a later loadImage on the same uri (e.g. a + * retry click with downloadIfMissing) probes again rather than replaying the stale outcome. */ export class ImageService { - readonly #getApi: () => IMiniLcmJsInvokable | undefined; - readonly #cache = new SvelteMap(); + readonly #getApi: () => IMiniLcmJsInvokable; + readonly #cache = new Map>(); + readonly #inFlight = new Map>(); #disposed = false; - constructor(getApi: () => IMiniLcmJsInvokable | undefined) { + constructor(getApi: () => IMiniLcmJsInvokable) { this.#getApi = getApi; } - /** - * Loads `mediaUri` if it's already cached locally, without downloading a remote file. Runs once - * per mediaUri (call from an $effect); a file that isn't local resolves to 'not-downloaded'. - */ - ensureLocal(mediaUri: string): void { - if (this.#cache.has(mediaUri)) return; - this.#cache.set(mediaUri, {status: 'loading'}); - void this.#load(mediaUri, false); - } - - /** - * Loads `mediaUri`, downloading it from the remote media service if it isn't cached locally. - * Backs both the "click to load" placeholder and retry-after-error: it proceeds from any state - * except in-flight or already-loaded, so a click on an errored (or not-downloaded) picture - * re-attempts the load. - */ - download(mediaUri: string): void { - const current = this.#cache.get(mediaUri); - if (current?.status === 'loading' || current?.status === 'loaded') return; - this.#cache.set(mediaUri, {status: 'loading'}); - void this.#load(mediaUri, true); - } - - /** Reactive load-state for `mediaUri`. Call from a $derived; 'loading' until ensureLocal resolves. */ - get(mediaUri: string): ImageLoadState { - return this.#cache.get(mediaUri) ?? {status: 'loading'}; + loadImage(mediaUri: string, options: LoadImageOptions = {}): Promise { + const {downloadIfMissing = false, bypassCache = false} = options; + if (!bypassCache) { + const cached = this.#cache.get(mediaUri); + if (cached) return Promise.resolve(cached); + const inFlight = this.#inFlight.get(mediaUri); + if (inFlight) return inFlight; + } + const promise = this.#load(mediaUri, downloadIfMissing).finally(() => { + if (this.#inFlight.get(mediaUri) === promise) this.#inFlight.delete(mediaUri); + }); + if (!bypassCache) this.#inFlight.set(mediaUri, promise); + return promise; } - // getFileStream reports expected failures via the response (branched on below). An unexpected - // thrown error (e.g. from blob()) is surfaced as an error state — else the picture would spin - // forever on 'loading' — and then re-thrown so the global handler still reports it. - async #load(mediaUri: string, downloadIfMissing: boolean): Promise { + // getFileStream reports expected failures via the response (branched on below); an unexpected + // thrown error (e.g. from blob()) bubbles to the global handler, and #getApi() throws if the + // project api isn't ready yet — both are surfaced there, not swallowed into an error state. + async #load(mediaUri: string, downloadIfMissing: boolean): Promise { const api = this.#getApi(); - if (!api) { - this.#cache.set(mediaUri, {status: 'error', reason: 'unknown'}); - return; - } - try { - const file = await api.getFileStream(mediaUri, downloadIfMissing); - if (this.#disposed) return; - if (!file.stream) { - // A local-only probe (downloadIfMissing=false) reports NotFound when the file simply isn't - // cached yet — that's the click-to-download case, not an error. - if (!downloadIfMissing && file.result === ReadFileResult.NotFound) { - this.#cache.set(mediaUri, {status: 'not-downloaded'}); - return; - } - const reason = - file.result === ReadFileResult.NotFound ? 'not-found' - : file.result === ReadFileResult.Offline ? 'offline' - : 'unknown'; - this.#cache.set(mediaUri, {status: 'error', reason}); - return; + const file = await api.getFileStream(mediaUri, downloadIfMissing); + if (!file.stream) { + // A local-only probe (downloadIfMissing=false) reports NotFound when the file simply isn't + // cached yet — that's the click-to-download case, not an error. + if (!downloadIfMissing && file.result === ReadFileResult.NotFound) { + return {status: 'not-downloaded'}; } - const blob = await new Response(await file.stream.stream()).blob(); - // Bail before minting a URL if the service was torn down mid-load, else it would never be revoked. - if (this.#disposed) return; - this.#cache.set(mediaUri, {status: 'loaded', url: URL.createObjectURL(blob)}); - } catch (error) { - if (!this.#disposed) { - this.#cache.set(mediaUri, {status: 'error', reason: 'unknown'}); - } - throw error; + const reason = + file.result === ReadFileResult.NotFound ? 'not-found' + : file.result === ReadFileResult.Offline ? 'offline' + : 'unknown'; + return {status: 'error', reason}; } + const blob = await new Response(await file.stream.stream()).blob(); + // Don't mint a URL if the service was torn down mid-load: dispose() has already run, so this one + // would never be revoked. The awaiting caller is gone too, so the returned state is discarded. + if (this.#disposed) return {status: 'error', reason: 'unknown'}; + const state = {status: 'loaded', url: URL.createObjectURL(blob)} as const; + this.#cache.set(mediaUri, state); + return state; } dispose(): void { this.#disposed = true; - for (const state of this.#cache.values()) { - if (state.status === 'loaded') URL.revokeObjectURL(state.url); - } + for (const state of this.#cache.values()) URL.revokeObjectURL(state.url); this.#cache.clear(); } } @@ -113,7 +94,7 @@ const imageServiceContext = new Context('image-service'); * dialog, the fullscreen viewer). Call once from the entry view; its object URLs are revoked when * that view is destroyed. */ -export function initImageService(getApi: () => IMiniLcmJsInvokable | undefined): ImageService { +export function initImageService(getApi: () => IMiniLcmJsInvokable): ImageService { const service = new ImageService(getApi); imageServiceContext.set(service); onDestroy(() => service.dispose()); diff --git a/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte b/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte index 3694bc8eea..3565cc1984 100644 --- a/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte @@ -54,7 +54,7 @@ // Entry-scoped image cache: pictures (and the edit/viewer dialogs) share one load per mediaUri. const projectContext = useProjectContext(); - initImageService(() => projectContext?.maybeApi); + initImageService(() => projectContext.api); let editor = $state(); From 10e1571b3064eb03e07c3497d53b0050ff9db75b Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 23 Jul 2026 18:03:31 +0200 Subject: [PATCH 28/32] Watch mediaUri to reload the picture, and tidy image-service setup Swap the load $effect for runed's watch so the reload trigger (mediaUri) is explicit rather than relying on load() transitively reading it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../field-editors/PictureImage.svelte | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte index 9fada535e0..64bd376239 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte @@ -4,6 +4,7 @@ import {useWritingSystemService} from '$project/data'; import {t} from 'svelte-i18n-lingui'; import {onDestroy} from 'svelte'; + import {watch} from 'runed'; import PictureActionsMenu from './PictureActionsMenu.svelte'; import {ImageService, useImageService, type ImageState} from './image-service.svelte'; @@ -36,26 +37,23 @@ : 'h-40 w-auto rounded-md object-contain', ); - // A touch on the corner actions menu can fire a stray click on the image behind it once the menu - // is open; ignore image taps while a menu is open so they don't also open the viewer/download. - let menuOpen = $state(false); - const projectContext = useProjectContext(); const writingSystemService = useWritingSystemService(); + const imageService = getImageService(); // Show a single writing system: the first non-empty caption searching vernacular writing // systems first, then analysis — which is exactly the default order of allWritingSystems(). const caption = $derived(writingSystemService.first(picture.caption) ?? ''); - // Prefer the entry-view cache so a mediaUri loaded once (here or in a dialog) shows immediately - // across the entry's pictures. Outside an entry view (edit/new-entry dialog, previews) fall back - // to a component-local cache disposed with the component. Both need a project api, so without a - // project context there's nothing to load from. - const sharedImageService = useImageService(); - const localImageService = - sharedImageService || !projectContext ? undefined : new ImageService(() => projectContext.api); - const imageService = sharedImageService ?? localImageService; - onDestroy(() => localImageService?.dispose()); + function getImageService() { + const sharedImageService = useImageService(); + // prefer the shared service, which can cache across components + if (sharedImageService) return sharedImageService; + if (!projectContext) return undefined; + const localImageService = new ImageService(() => projectContext.api); + onDestroy(() => localImageService.dispose()); + return localImageService; + } type DisplayState = {status: 'loading'} | ImageState; let loadState = $state({status: 'loading'}); @@ -76,9 +74,11 @@ if (uri === picture.mediaUri) loadState = state; }); } - $effect(() => { - load(false); - }); + watch(() => mediaUri, () => load(false)); + + // A touch on the corner actions menu can fire a stray click on the image behind it once the menu + // is open; ignore image taps while a menu is open so they don't also open the viewer/download. + let menuOpen = $state(false); function handleImageClick() { if (menuOpen) return; From 2463a0239296fe1ce961a5347f296d9d3714aa28 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 23 Jul 2026 20:51:40 +0200 Subject: [PATCH 29/32] Use SvelteMap in ImageService to satisfy prefer-svelte-reactivity lint Co-Authored-By: Claude Opus 4.8 --- .../lib/entry-editor/field-editors/image-service.svelte.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts b/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts index d6ebd65775..a2b0c7f366 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts +++ b/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts @@ -1,5 +1,6 @@ import {Context} from 'runed'; import {onDestroy} from 'svelte'; +import {SvelteMap} from 'svelte/reactivity'; import type {IMiniLcmJsInvokable} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable'; import {ReadFileResult} from '$lib/dotnet-types/generated-types/MiniLcm/Media/ReadFileResult'; @@ -30,8 +31,8 @@ export type LoadImageOptions = { */ export class ImageService { readonly #getApi: () => IMiniLcmJsInvokable; - readonly #cache = new Map>(); - readonly #inFlight = new Map>(); + readonly #cache = new SvelteMap>(); + readonly #inFlight = new SvelteMap>(); #disposed = false; constructor(getApi: () => IMiniLcmJsInvokable) { From a149c9281ff5e38dc4a06eb7fb15b2f3151475aa Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 24 Jul 2026 12:32:32 +0700 Subject: [PATCH 30/32] Fix cache-invalidation bug in PictureImage Now if the picture is clicked in a dialog, the PictureImage component in the editor view will be notified that the picture has updated, and will pick up that newly-loaded picture. --- .../entry-editor/field-editors/PictureImage.svelte | 12 +++++++----- .../field-editors/image-service.svelte.ts | 7 +++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte index 64bd376239..f219dee8fd 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte @@ -56,22 +56,24 @@ } type DisplayState = {status: 'loading'} | ImageState; - let loadState = $state({status: 'loading'}); + let displayState = $state({status: 'loading'}); + const mediaUri = $derived(picture.mediaUri); + const cached = $derived(imageService?.cached(mediaUri)); + const loadState = $derived(cached ?? displayState); // A picture already available locally loads automatically; one that would have to be downloaded // from the remote media service resolves to 'not-downloaded' (the "Load picture" placeholder) and // is fetched only when clicked (download=true). A click on an errored picture retries the same way. - const mediaUri = $derived(picture.mediaUri); function load(download: boolean) { if (!imageService) { - loadState = {status: 'error', reason: 'unknown'}; + displayState = {status: 'error', reason: 'unknown'}; return; } const uri = mediaUri; - loadState = {status: 'loading'}; + displayState = {status: 'loading'}; void imageService.loadImage(uri, {downloadIfMissing: download}).then((state) => { // Ignore a resolution for a picture we've since navigated away from. - if (uri === picture.mediaUri) loadState = state; + if (uri === picture.mediaUri) displayState = state; }); } watch(() => mediaUri, () => load(false)); diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts b/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts index a2b0c7f366..0a8279fd6b 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts +++ b/frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts @@ -54,6 +54,13 @@ export class ImageService { return promise; } + /** Reactive: the cached loaded state for this mediaUri, or undefined if not yet loaded. Read inside + a $derived/$effect it subscribes to cache population by any sibling sharing this service, so one + component loading a picture (e.g. the edit dialog) lights it up everywhere it's shown. */ + cached(mediaUri: string): Extract | undefined { + return this.#cache.get(mediaUri); + } + // getFileStream reports expected failures via the response (branched on below); an unexpected // thrown error (e.g. from blob()) bubbles to the global handler, and #getApi() throws if the // project api isn't ready yet — both are surfaced there, not swallowed into an error state. From e89bc35f516370c54ff2553eee85fb2e08b16e2b Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 24 Jul 2026 12:43:52 +0700 Subject: [PATCH 31/32] Claude-written test for picture loading E2E test to verify that the cache bug fixed by the previous commit will stay fixed. --- .../viewer/tests/ui/sense-pictures.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/frontend/viewer/tests/ui/sense-pictures.test.ts b/frontend/viewer/tests/ui/sense-pictures.test.ts index 806e859078..171a002023 100644 --- a/frontend/viewer/tests/ui/sense-pictures.test.ts +++ b/frontend/viewer/tests/ui/sense-pictures.test.ts @@ -427,6 +427,30 @@ test.describe('Sense pictures', () => { await expect(viewer.locator('img')).toHaveAttribute('src', thumbnailSrc ?? '', {timeout: 5000}); }); + test('loading a remote-only picture from inside the edit dialog updates the field thumbnail', async ({page}) => { + const projectPage = new DemoProjectPage(page); + await projectPage.goto(); + // "nyumba"'s pictures are remote-only, so the field starts showing a "Load picture" placeholder. + await projectPage.selectEntryByFilter('nyumba'); + const picturesField = page.locator('[style*="grid-area: pictures"]').first(); + const firstPicture = picturesField.locator('figure').first(); + await expect(firstPicture.getByRole('button', {name: 'Load picture'})).toBeVisible({timeout: 5000}); + + // Open the edit dialog on the (still unloaded) picture and load it from *inside* the dialog. + await openPictureMenu(page, picturesField); + await page.getByRole('menuitem', {name: 'Edit'}).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({timeout: 5000}); + await loadFirstPicture(dialog); + + // The field and dialog share one entry-scoped image cache, so loading in the dialog must light up + // the field thumbnail too — no placeholder, no second download — even once the dialog is closed. + await dialog.getByRole('button', {name: 'Cancel'}).click(); + await expect(dialog).toHaveCount(0); + await expect(firstPicture.locator('img')).toHaveAttribute('src', /^blob:/, {timeout: 5000}); + await expect(firstPicture.getByRole('button', {name: 'Load picture'})).toHaveCount(0); + }); + test('clicking the viewer captions collapses to the first caption and expands again', async ({page}) => { const projectPage = new DemoProjectPage(page); await projectPage.goto(); From 022f2c38d1400d06098641a745b791f10dd28fe3 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 24 Jul 2026 13:55:23 +0700 Subject: [PATCH 32/32] MUCH less chatty comments Removed most of Claude's overly-chatty comments; kept only the ones that were actually non-obvious to someone skimming the code. --- .../responsive-menu-trigger.svelte | 1 - .../field-editors/PictureActionsMenu.svelte | 9 ++--- .../field-editors/PictureImage.svelte | 28 +------------- .../field-editors/PictureViewerDialog.svelte | 37 ++++--------------- .../field-editors/PicturesEditor.svelte | 25 ++----------- .../field-editors/image-service.svelte.ts | 32 ++-------------- .../field-editors/picture-actions.ts | 5 --- 7 files changed, 19 insertions(+), 118 deletions(-) diff --git a/frontend/viewer/src/lib/components/responsive-menu/responsive-menu-trigger.svelte b/frontend/viewer/src/lib/components/responsive-menu/responsive-menu-trigger.svelte index ada3b3dd24..e114d20d4e 100644 --- a/frontend/viewer/src/lib/components/responsive-menu/responsive-menu-trigger.svelte +++ b/frontend/viewer/src/lib/components/responsive-menu/responsive-menu-trigger.svelte @@ -14,7 +14,6 @@ type Props = { children?: Snippet; - /** Trigger button size (matches the shared Button sizes); ignored in `contextMenu` mode. */ size?: VariantProps['size']; } & ContextMenuTriggerProps & DropdownMenuTriggerProps & diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureActionsMenu.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureActionsMenu.svelte index d3808b1e78..20dc30b51c 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureActionsMenu.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureActionsMenu.svelte @@ -4,17 +4,14 @@ import type {ComponentProps, Snippet} from 'svelte'; type Props = { - /** Renders as a context menu on `children` (right-click / touch long-press) instead of a - three-dots trigger button. */ + /** Render as right-click / long-press instead of a three-dots button. */ contextMenu?: boolean; children?: Snippet; disabled?: boolean; - /** Extra classes for the three-dots trigger (e.g. to make it legible over an image). */ + /** Extra classes for the three-dots trigger, if needed. */ triggerClass?: string; - /** Trigger button size; forwarded to the menu trigger (defaults to a 40px icon button). */ + /** Size defaults to a 40px icon button. */ size?: ComponentProps['size']; - /** Notified when the menu opens/closes (lets a parent ignore the stray tap that a touch - device can fire on the element behind the trigger while the menu is open). */ onOpenChange?: (open: boolean) => void; onEdit: () => void; onDownload: () => void; diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte index f219dee8fd..b6c0c60ac7 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte @@ -10,21 +10,12 @@ type Props = { picture: IPicture; - /** Opens the fullscreen viewer; without it (or when readonly) the picture isn't interactive - and no actions menu is offered. */ onView?: () => void; - /** Opens the edit dialog; wired into the actions menu (the "Edit" item). */ onEdit?: () => void; - /** Downloads the picture; wired into the actions menu (and long-press menu). */ onDownload?: () => void; - /** Deletes the picture (with confirmation); wired into the actions menu. */ onDelete?: () => void; - /** Disables the actions affordance while an operation is in flight. */ busy?: boolean; - /** Whether to render the caption beneath the picture (hidden inside the edit/viewer dialogs). */ showCaption?: boolean; - /** 'thumbnail' (default) is the fixed-height field size; 'full' fills its container (the - viewer's fixed stage) while never exceeding the image's native size. */ size?: 'thumbnail' | 'full'; readonly?: boolean; }; @@ -41,8 +32,6 @@ const writingSystemService = useWritingSystemService(); const imageService = getImageService(); - // Show a single writing system: the first non-empty caption searching vernacular writing - // systems first, then analysis — which is exactly the default order of allWritingSystems(). const caption = $derived(writingSystemService.first(picture.caption) ?? ''); function getImageService() { @@ -61,9 +50,6 @@ const cached = $derived(imageService?.cached(mediaUri)); const loadState = $derived(cached ?? displayState); - // A picture already available locally loads automatically; one that would have to be downloaded - // from the remote media service resolves to 'not-downloaded' (the "Load picture" placeholder) and - // is fetched only when clicked (download=true). A click on an errored picture retries the same way. function load(download: boolean) { if (!imageService) { displayState = {status: 'error', reason: 'unknown'}; @@ -78,11 +64,10 @@ } watch(() => mediaUri, () => load(false)); - // A touch on the corner actions menu can fire a stray click on the image behind it once the menu - // is open; ignore image taps while a menu is open so they don't also open the viewer/download. let menuOpen = $state(false); - function handleImageClick() { + // A touch on the corner actions menu can fire a stray click on the image behind it once the menu + // is open; ignore image taps while a menu is open so they don't also open the viewer/download. if (menuOpen) return; if (loadState.status === 'not-downloaded' || loadState.status === 'error') { load(true); @@ -91,8 +76,6 @@ onView?.(); } - // Clickable to download (not available locally), to retry (after an error), or to open the viewer - // (loaded and interactive). const needsDownload = $derived(loadState.status === 'not-downloaded'); const hasError = $derived(loadState.status === 'error'); const clickable = $derived(needsDownload || hasError || (loadState.status === 'loaded' && interactive)); @@ -115,10 +98,8 @@ {#snippet imageContent()} {#if loadState.status === 'loaded'} - {caption {:else if loadState.status === 'not-downloaded'} -
{loadLabel} @@ -128,7 +109,6 @@
{:else} -
{errorText(loadState)} @@ -155,15 +135,11 @@
{#if size === 'full'} - {@render pictureArea()} {:else}
{#if showMenu} - void; onDownload: (picture: IPicture) => void; @@ -25,12 +23,9 @@ useBackHandler({addToStack: () => open, onBack: () => (open = false), key: 'picture-viewer-dialog'}); const writingSystemService = useWritingSystemService(); - // Track the shown picture by id (not index) so it survives reordering. const currentIndex = $derived(pictures.findIndex((p) => p.id === pictureId)); const current = $derived(currentIndex >= 0 ? pictures[currentIndex] : undefined); - // If the shown picture disappears (e.g. deleted from the menu), move to the nearest remaining - // picture; close only when none are left. let lastIndex = 0; $effect(() => { if (currentIndex >= 0) lastIndex = currentIndex; @@ -44,12 +39,11 @@ } }); - // Captions collapse to just the first non-empty one on click; every shown picture starts expanded. - let collapsed = $state(false); + let captionsCollapsed = $state(false); $effect(() => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions pictureId; // rerun when the shown picture changes - collapsed = false; + captionsCollapsed = false; }); const hasMultiple = $derived(pictures.length > 1); @@ -82,16 +76,10 @@ .map((ws) => ({ws, text: asString(caption[ws.wsId]) ?? ''})) .filter((c) => c.text.length > 0); }); - const shownCaptions = $derived(collapsed ? captions.slice(0, 1) : captions); + const shownCaptions = $derived(captionsCollapsed ? captions.slice(0, 1) : captions); - - - -
{#if current} {#if current} -
{#if hasMultiple} @@ -155,29 +136,25 @@
{#if captions.length > 0} -
- diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte index bf91cc5f62..cfbc5315b6 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte @@ -19,20 +19,14 @@ senseId: string; readonly?: boolean; }; - // `pictures` is bindable so add/delete/update mutate the sense directly (immediate UI) rather than - // waiting for the change to round-trip back through an entry reload. let {pictures = $bindable(), entryId, senseId, readonly = false}: Props = $props(); const api = useLexboxApi(); const dialogsService = useDialogsService(); let fileInputElement = $state(); - // Which operation is in flight: 'add' drives the add-button spinner; 'edit' covers the - // replace/delete/caption operations invoked from the edit dialog. let busyAction = $state<'add' | 'edit' | null>(null); - // The picture currently open in the edit dialog, tracked by id so the dialog reflects live edits - // to `pictures` (e.g. its image updates after a replace). let editingPictureId = $state(); const editingPicture = $derived(editingPictureId ? pictures.find((p) => p.id === editingPictureId) : undefined); let editDialogOpen = $state(false); @@ -43,7 +37,6 @@ if (editingPicture) lastEditedPicture = editingPicture; }); - // The fullscreen viewer, tracked by id so prev/next and (direct) deletion stay in sync with `pictures`. let viewerPictureId = $state(); let viewerOpen = $state(false); @@ -65,19 +58,13 @@ if (file) void addPicture(file); } - // Uploads the chosen file and returns its mediaUri, or null if the upload was rejected - // (a notification is shown for the rejection). saveFile reports outcome via `result`, not - // exceptions, so we branch on it. We intentionally do NOT pre-check the file size: the size - // limit lives on the server and may change, so we let the server decide and handle `TooBig`. async function uploadFile(file: File): Promise { const response = await api.saveFile(file, {filename: file.name, mimeType: file.type, extraFields: {}}); switch (response.result) { case UploadFileResult.SavedLocally: case UploadFileResult.SavedToLexbox: case UploadFileResult.AlreadyExists: - // AlreadyExists is not an error here: one image file (mediaUri) can back many Picture - // objects across different senses/entries. The server returns the existing file's - // mediaUri, which we reuse to point a Picture at that same image. + // AlreadyExists is not an error here: multiple Picture objects might share one mediaUri break; case UploadFileResult.TooBig: AppNotification.display(tooBigMessage(file), {type: 'error', timeout: 'long'}); @@ -108,8 +95,7 @@ // --- Edit dialog operations (act on the picture currently open in the dialog) --- - // Uploads a replacement file and returns its mediaUri, WITHOUT touching the model — the dialog - // previews it and only commits on Submit (via submitEdits). + // Uploads replacement file and returns its mediaUri, WITHOUT touching the model until dialog is submitted async function uploadReplacement(file: File): Promise { busyAction = 'edit'; try { @@ -119,7 +105,6 @@ } } - // Applies the dialog's buffered edits (new caption and/or replaced image) in one update. async function submitEdits(after: IPicture): Promise { const before = editingPicture ? $state.snapshot(editingPicture) : undefined; if (!before) return; @@ -167,9 +152,7 @@
{#if pictures.length > 0} - +
{#each pictures as picture (picture.id)}
- + IMiniLcmJsInvokable; @@ -54,22 +43,15 @@ export class ImageService { return promise; } - /** Reactive: the cached loaded state for this mediaUri, or undefined if not yet loaded. Read inside - a $derived/$effect it subscribes to cache population by any sibling sharing this service, so one - component loading a picture (e.g. the edit dialog) lights it up everywhere it's shown. */ + /** Read inside $derived in order to react to other components loading a mediaUri */ cached(mediaUri: string): Extract | undefined { return this.#cache.get(mediaUri); } - // getFileStream reports expected failures via the response (branched on below); an unexpected - // thrown error (e.g. from blob()) bubbles to the global handler, and #getApi() throws if the - // project api isn't ready yet — both are surfaced there, not swallowed into an error state. async #load(mediaUri: string, downloadIfMissing: boolean): Promise { const api = this.#getApi(); const file = await api.getFileStream(mediaUri, downloadIfMissing); if (!file.stream) { - // A local-only probe (downloadIfMissing=false) reports NotFound when the file simply isn't - // cached yet — that's the click-to-download case, not an error. if (!downloadIfMissing && file.result === ReadFileResult.NotFound) { return {status: 'not-downloaded'}; } @@ -80,8 +62,6 @@ export class ImageService { return {status: 'error', reason}; } const blob = await new Response(await file.stream.stream()).blob(); - // Don't mint a URL if the service was torn down mid-load: dispose() has already run, so this one - // would never be revoked. The awaiting caller is gone too, so the returned state is discarded. if (this.#disposed) return {status: 'error', reason: 'unknown'}; const state = {status: 'loaded', url: URL.createObjectURL(blob)} as const; this.#cache.set(mediaUri, state); @@ -97,11 +77,6 @@ export class ImageService { const imageServiceContext = new Context('image-service'); -/** - * Creates an entry-view-scoped image cache and publishes it to descendants (pictures, the edit - * dialog, the fullscreen viewer). Call once from the entry view; its object URLs are revoked when - * that view is destroyed. - */ export function initImageService(getApi: () => IMiniLcmJsInvokable): ImageService { const service = new ImageService(getApi); imageServiceContext.set(service); @@ -109,7 +84,6 @@ export function initImageService(getApi: () => IMiniLcmJsInvokable): ImageServic return service; } -/** The entry-view image service, or undefined when rendered outside an entry view. */ export function useImageService(): ImageService | undefined { return imageServiceContext.getOr(undefined); } diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/picture-actions.ts b/frontend/viewer/src/lib/entry-editor/field-editors/picture-actions.ts index df38d735bc..8081ddf48a 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/picture-actions.ts +++ b/frontend/viewer/src/lib/entry-editor/field-editors/picture-actions.ts @@ -2,11 +2,6 @@ import type {IMiniLcmJsInvokable} from '$lib/dotnet-types/generated-types/FwLite export type DownloadPictureResult = {success: true} | {success: false; errorMessage?: string}; -/** - * Downloads the image behind a picture's mediaUri, saved under the filename the media server - * reports for it. getFileStream reports failure via the response (not an exception); the caller - * decides how to surface `errorMessage`, keeping notification/translation in the component. - */ export async function downloadPictureFile(api: IMiniLcmJsInvokable, mediaUri: string): Promise { const file = await api.getFileStream(mediaUri, true); if (!file.stream) return {success: false, errorMessage: file.errorMessage ?? undefined};