Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
add8161
Add a three-dots actions menu to sense pictures
rmunn Jul 18, 2026
1a29c91
Open a fullscreen viewer when a picture is clicked
rmunn Jul 18, 2026
22a6130
Load picture images once per entry via a shared cache
rmunn Jul 20, 2026
58f6f4e
Deal with `picture` being mutated with same URI
rmunn Jul 20, 2026
d2fd602
Defer picture loading behind a click-to-load placeholder
rmunn Jul 20, 2026
36a0db3
Scope the picture image cache to the project, not the entry
rmunn Jul 20, 2026
81fbc1c
Make the viewer captions click to collapse/expand
rmunn Jul 20, 2026
646a0c7
Merge branch 'develop' into feat/picture-ui-improvements
rmunn Jul 20, 2026
f113dd2
Show "click to load" only for pictures not available locally
rmunn Jul 20, 2026
7a1db76
Delete planning document that shouldn't be merged
rmunn Jul 20, 2026
e014ec8
Make PicturesEditor.pictures bindable and update the sense directly
rmunn Jul 21, 2026
e658693
Fix Claude's misunderstanding of MiniLcm API
rmunn Jul 21, 2026
d530038
Add a disclosure chevron to the viewer caption toggle
rmunn Jul 21, 2026
20b977a
Merge branch 'develop' into feat/picture-ui-improvements
rmunn Jul 21, 2026
d8f2dde
Surface image-load exceptions as an error state
rmunn Jul 21, 2026
385e351
Let users click an errored picture to retry loading
rmunn Jul 21, 2026
b7b0ef4
Reset viewer caption collapse when the viewer opens
rmunn Jul 21, 2026
f18c7aa
Better Svelte code than what Claude produced
rmunn Jul 21, 2026
013efc7
Delete E2E test since spec decision changed
rmunn Jul 21, 2026
984a9ca
Add back in change that got accidentally removed
rmunn Jul 21, 2026
dac2122
Address minor CodeRabbit review comment
rmunn Jul 21, 2026
da8d9cb
Rename function to avoid name conflicts
rmunn Jul 21, 2026
fbd0618
Scope the picture image cache to the entry again, relying on download…
rmunn Jul 22, 2026
8ca91c3
Address picture UI review feedback
claude Jul 22, 2026
038f52b
Gate viewer arrow keys via runed and slim the counter to "1 / 4"
claude Jul 22, 2026
e705e2f
Fold the on-demand-fetch intent back into the demo field doc
claude Jul 22, 2026
1ef4b52
Merge remote-tracking branch 'origin/develop' into feat/picture-ui-im…
myieye Jul 22, 2026
413af22
Give the picture viewer a stable stage and tidy its chrome
myieye Jul 22, 2026
623060c
Polish picture chrome and viewer layout, guard touch ghost-clicks
myieye Jul 23, 2026
86fdf24
Simplify ImageService to a promise-returning loadImage
myieye Jul 23, 2026
10e1571
Watch mediaUri to reload the picture, and tidy image-service setup
myieye Jul 23, 2026
2463a02
Use SvelteMap in ImageService to satisfy prefer-svelte-reactivity lint
myieye Jul 23, 2026
a149c92
Fix cache-invalidation bug in PictureImage
rmunn Jul 24, 2026
e89bc35
Claude-written test for picture loading
rmunn Jul 24, 2026
022f2c3
MUCH less chatty comments
rmunn Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions frontend/viewer/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,13 @@ Add new language: Edit `lingui.config.ts`, then run extract.
npx shadcn-svelte@next add context-menu
```

## Error Handling

Unexpected errors should reach the global error handler (`src/lib/errors/global-errors.ts`): let them
throw (or rethrow) rather than catching and rendering raw messages inline. It shows a persistent toast
with a copy-error button and logs to .NET. Inline UI error states are for *expected*, actionable
failures (offline, not-found, retry) with plain, translated messages.

## Key Concepts

- **MiniLcm**: Lightweight dictionary API (entries, senses, definitions)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,19 @@
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;
size?: VariantProps<typeof buttonVariants>['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();
</script>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import {Button} from '$lib/components/ui/button';
import PictureImage from './PictureImage.svelte';
import {ACCEPTED_PICTURE_TYPES} from './picture-formats';
import {downloadPictureFile} from './picture-actions';
import {t} from 'svelte-i18n-lingui';
import {useLexboxApi} from '$lib/services/service-provider';
import {AppNotification} from '$lib/notifications/notifications';
Expand Down Expand Up @@ -63,25 +64,15 @@
}
}

// Downloads the currently-shown image, saved under the filename the media server reports for it.
let downloading = $state(false);
async function downloadPicture() {
if (downloading) return;
downloading = true;
try {
const file = await api.getFileStream(mediaUri, true);
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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<script lang="ts">
import * as ResponsiveMenu from '$lib/components/responsive-menu';
import {t} from 'svelte-i18n-lingui';
import type {ComponentProps, Snippet} from 'svelte';

type Props = {
/** 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, if needed. */
triggerClass?: string;
/** Size defaults to a 40px icon button. */
size?: ComponentProps<typeof ResponsiveMenu.Trigger>['size'];
onOpenChange?: (open: boolean) => void;
onEdit: () => void;
onDownload: () => void;
onDelete: () => void;
};
let {contextMenu = false, children, disabled = false, triggerClass, size, onOpenChange, onEdit, onDownload, onDelete}: Props = $props();
</script>

<ResponsiveMenu.Root {contextMenu} {onOpenChange}>
<ResponsiveMenu.Trigger
class={triggerClass}
{size}
{disabled}
aria-label={contextMenu ? undefined : $t`Picture actions`}
{children}
/>
<ResponsiveMenu.Content>
<ResponsiveMenu.Item icon="i-mdi-pencil" onSelect={onEdit}>{$t`Edit`}</ResponsiveMenu.Item>
<ResponsiveMenu.Item icon="i-mdi-download" onSelect={onDownload}>{$t`Download`}</ResponsiveMenu.Item>
<ResponsiveMenu.Item icon="i-mdi-delete" onSelect={onDelete}>{$t`Delete`}</ResponsiveMenu.Item>
</ResponsiveMenu.Content>
</ResponsiveMenu.Root>
208 changes: 130 additions & 78 deletions frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte
Original file line number Diff line number Diff line change
@@ -1,122 +1,174 @@
<script lang="ts">
import type {IPicture} from '$lib/dotnet-types';
import {ReadFileResult} from '$lib/dotnet-types/generated-types/MiniLcm/Media/ReadFileResult';
import {useProjectContext} from '$project/project-context.svelte';
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';

type Props = {
picture: IPicture;
/** When provided, the whole picture is clickable (with a pencil hint) to open the edit dialog. */
onView?: () => void;
onEdit?: () => void;
/** Disables the edit affordance while an operation is in flight. */
onDownload?: () => void;
onDelete?: () => void;
busy?: boolean;
/** Whether to render the caption beneath the picture (hidden inside the edit dialog). */
showCaption?: boolean;
size?: 'thumbnail' | 'full';
readonly?: boolean;
};
const {picture, onEdit, busy = false, showCaption = true, readonly = false}: Props = $props();
const {picture, onView, onEdit, onDownload, onDelete, busy = false, showCaption = true, size = 'thumbnail', 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);
const interactive = $derived(!readonly && !!onView);
const imageClass = $derived(
size === 'full'
? 'max-h-full max-w-full h-auto w-auto object-contain'
: 'h-40 w-auto rounded-md object-contain',
);

const projectContext = useProjectContext();
const api = $derived(projectContext?.maybeApi);
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) ?? '');

type LoadState =
| {status: 'loading'}
| {status: 'loaded'; url: string}
| {status: 'error'; message: string};
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 displayState = $state<DisplayState>({status: 'loading'});
const mediaUri = $derived(picture.mediaUri);
const cached = $derived(imageService?.cached(mediaUri));
const loadState = $derived(cached ?? displayState);

function load(download: boolean) {
if (!imageService) {
displayState = {status: 'error', reason: 'unknown'};
return;
}
const uri = mediaUri;
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) displayState = state;
});
}
watch(() => mediaUri, () => load(false));

// getFileStream signals failures via the `result` enum (not exceptions), so we
// branch on it rather than wrapping in try/catch (the global handler covers throws).
async function loadImage(mediaUri: string): Promise<Exclude<LoadState, {status: 'loading'}>> {
if (!api) return {status: 'error', message: $t`Unable to load image`};
const file = await api.getFileStream(mediaUri, true);
if (!file.stream) {
switch (file.result) {
case ReadFileResult.NotFound:
return {status: 'error', message: $t`Image not found`};
case ReadFileResult.Offline:
return {status: 'error', message: $t`Offline, unable to download image`};
default:
return {status: 'error', message: file.errorMessage ?? $t`Unable to load image`};
}
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);
return;
}
const blob = await new Response(await file.stream.stream()).blob();
return {status: 'loaded', url: URL.createObjectURL(blob)};
onView?.();
}

let state = $state<LoadState>({status: 'loading'});
const needsDownload = $derived(loadState.status === 'not-downloaded');
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($t`Load picture`);
const retryLabel = $derived($t`Try again`);
const clickLabel = $derived(needsDownload ? loadLabel : hasError ? retryLabel : $t`View Picture`);

const mediaUri = $derived(picture.mediaUri);
$effect(() => {
state = {status: 'loading'};
let revoked = false;
let createdUrl: string | undefined;
void loadImage(mediaUri).then((result) => {
if (revoked) {
// Component/effect was torn down before the image finished loading.
if (result.status === 'loaded') URL.revokeObjectURL(result.url);
return;
}
if (result.status === 'loaded') createdUrl = result.url;
state = result;
});
return () => {
revoked = true;
if (createdUrl) URL.revokeObjectURL(createdUrl);
};
});
function errorText(state: Extract<ImageState, {status: 'error'}>): string {
switch (state.reason) {
case 'not-found':
return $t`Picture not found`;
case 'offline':
return $t`You're offline`;
default:
return $t`Unable to load picture`;
}
}
</script>

{#snippet imageContent()}
{#if state.status === 'loaded'}
<!-- Fixed height, flexible width: the image keeps its aspect ratio and its width varies. -->
<img src={state.url} alt={caption || $t`Picture`} class="h-40 w-auto rounded-md object-contain" />
{:else if state.status === 'loading'}
{#if loadState.status === 'loaded'}
<img src={loadState.url} alt={caption || $t`Picture`} class={imageClass} />
{:else if loadState.status === 'not-downloaded'}
<div class="bg-muted text-muted-foreground hover:text-foreground flex h-40 w-40 flex-col items-center justify-center gap-1 rounded-md transition-colors">
<span class="i-mdi-download size-6"></span>
<span class="text-sm">{loadLabel}</span>
</div>
{:else if loadState.status === 'loading'}
<div class="bg-muted text-muted-foreground flex h-40 w-40 items-center justify-center rounded-md">
<span class="i-mdi-loading size-6 animate-spin"></span>
</div>
{:else}
<div class="bg-muted text-muted-foreground flex h-40 w-40 flex-col items-center justify-center gap-1 rounded-md">
<div class="bg-muted text-muted-foreground hover:text-foreground flex h-40 w-40 flex-col items-center justify-center gap-1 rounded-md text-center transition-colors">
<span class="i-mdi-image-broken-variant size-6"></span>
<span class="text-sm">{state.message}</span>
<span class="text-sm">{errorText(loadState)}</span>
<span class="text-xs">{retryLabel}</span>
</div>
{/if}
{/snippet}

<figure class="flex flex-col items-start gap-1">
<!-- `w-fit` shrinks the box to the image so the trash button sits on the image's corner. -->
<div class="relative w-fit">
{#if editable}
<button
type="button"
class="block cursor-pointer appearance-none rounded-md border-0 bg-transparent p-0 focus-visible:outline-2 disabled:cursor-default"
aria-label={$t`Edit Picture`}
disabled={busy}
onclick={() => onEdit?.()}
>
{@render imageContent()}
<!-- Pencil hint that the picture is clickable; the click is handled by the button itself. -->
<span class="text-foreground bg-background/70 pointer-events-none absolute right-1 top-1 z-10 rounded-full p-1 shadow-sm">
<span class="i-mdi-pencil block size-5"></span>
</span>
</button>
{:else}
{#snippet pictureArea()}
{#if clickable}
<button
type="button"
class="block cursor-pointer appearance-none rounded-md border-0 bg-transparent p-0 focus-visible:outline-2 disabled:cursor-default"
aria-label={clickLabel}
disabled={busy}
onclick={handleImageClick}
>
{@render imageContent()}
{/if}
</div>
</button>
{:else}
{@render imageContent()}
{/if}
{/snippet}

<figure class={size === 'full' ? 'flex size-full min-h-0 min-w-0 items-center justify-center' : 'flex flex-col items-start gap-1'}>
{#if size === 'full'}
{@render pictureArea()}
{:else}
<!-- `w-fit` hugs the box to the image so the actions menu sits on its corner. -->
<div class="relative w-fit">
{#if showMenu}
<PictureActionsMenu
contextMenu
disabled={busy}
onOpenChange={(o) => (menuOpen = o)}
onEdit={() => onEdit?.()}
onDownload={() => onDownload?.()}
onDelete={() => onDelete?.()}
>
{@render pictureArea()}
</PictureActionsMenu>
<div class="absolute right-1 top-1">
<PictureActionsMenu
disabled={busy}
triggerClass="rounded-full not-hover:bg-background/50 shadow-sm backdrop-blur-sm hover:bg-background/90"
onOpenChange={(o) => (menuOpen = o)}
onEdit={() => onEdit?.()}
onDownload={() => onDownload?.()}
onDelete={() => onDelete?.()}
/>
</div>
{:else}
{@render pictureArea()}
{/if}
</div>
{/if}
{#if showCaption && caption}
<!-- `w-0 min-w-full` pins the caption to the image's width (the box above), so its `max-width`
is the picture width; line-clamp-2 caps it at two lines with an ellipsis. -->
<figcaption class="text-muted-foreground line-clamp-2 w-0 min-w-full break-words text-center text-sm">
<figcaption class="text-muted-foreground line-clamp-2 w-0 min-w-full wrap-break-word text-center text-sm">
{caption}
</figcaption>
{/if}
Expand Down
Loading
Loading