Skip to content

Commit d144690

Browse files
myieyehahn-kev
andauthored
Usability fixes - Load more entries, virtual scrolling, preserve scrolling pos on mobile (#1714)
* Usability fixes * replace the placeholder blank row at the bottom with an entry skeleton * create IfOnce and use it to ensure the entry list is hidden and not destroyed when an entry is opened, this ensures the scroll position isn't reset and we don't need to reload the list * implement virtualized scrolling for the entry list * Fix new entries not added to list and optimize event handling. * Fix resizing panes always inverted * Tweak padding * Fix bottom padding causes flicker when scrolling up * Give new entry fab more space on desktop * increase overscan of VList so test items are still rendered * Restore pixel-perfect spacing around headword badge and standardize margins/padding * Improve refresh-button positioning * Stop bypassing entry fetch debouncing --------- Co-authored-by: Kevin Hahn <kevin_hahn@sil.org>
1 parent 0cdfd99 commit d144690

10 files changed

Lines changed: 160 additions & 62 deletions

File tree

frontend/pnpm-lock.yaml

Lines changed: 27 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/lib/i18n/locales/en.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ Lexbox is free and [open source](https://github.com/sillsdev/languageforge-lexbo
271271
"sync_result": "{fwdataChanges} changes synced to FieldWorks. {crdtChanges} changes synced to FieldWorks Lite.",
272272
"try_fw_lite": "Try FieldWorks Lite?",
273273
"try_info": "This will make your project available in [FieldWorks Lite](https://lexbox.org/fw-lite). \
274-
This is still experimental and it will affect your FieldWorks project data. \
274+
This feature is still in beta and it will affect your FieldWorks project data. \
275275
However, **all your data is safe and backed up** here on Lexbox. \
276276
If you run into problems with your project please tell support that you are using FieldWorks Lite. \n\n\
277277
Are you ready to try FieldWorks Lite?",

frontend/viewer/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
"svelte-routing": "^2.12.0",
101101
"svelte-ux": "^0.76.0",
102102
"tabbable": "^6.2.0",
103-
"type-fest": "^4.18.2"
103+
"type-fest": "^4.18.2",
104+
"virtua": "^0.41.2"
104105
}
105106
}

frontend/viewer/src/lib/components/fab/fab-container.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@
1313
}: Props = $props();
1414
</script>
1515

16-
<div class={cn('absolute bottom-4 right-4 flex flex-col items-end z-10', className)}>
16+
<div class={cn('absolute bottom-4 right-4 md:bottom-6 md:right-6 flex flex-col items-end z-10 gap-4', className)}>
1717
{@render children?.()}
1818
</div>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<script lang="ts">
2+
import {type Snippet, untrack} from 'svelte';
3+
4+
const {show, children}: { show: boolean, children: Snippet } = $props();
5+
let render = $state(show);
6+
$effect(() => {
7+
//this should cleanup the effect so it only runs once
8+
if (untrack(() => render)) return;
9+
render = show;
10+
});
11+
</script>
12+
{#if render}
13+
<div class="contents" class:hidden={!show}>
14+
{@render children()}
15+
</div>
16+
{/if}

frontend/viewer/src/lib/sandbox/Sandbox.svelte

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import {useBackHandler} from '$lib/utils/back-handler.svelte';
3030
import * as Dialog from '$lib/components/ui/dialog';
3131
import {T} from 'svelte-i18n-lingui';
32+
import IfOnce from '$lib/components/if-once/if-once.svelte';
3233
3334
3435
const testingService = tryUseService(DotnetService.TestingService);
@@ -116,6 +117,9 @@
116117
buttonsLoading = false;
117118
}, 1000);
118119
}
120+
121+
let show = $state(false);
122+
let reseter = $state(0);
119123
</script>
120124
<DialogsProvider/>
121125
<div class="p-6 shadcn-root">
@@ -266,6 +270,21 @@
266270
click count: {count}
267271
</div>
268272
</div>
273+
<div class="flex flex-col gap-2 border p-4 justify-between">
274+
<div class="flex flex-col gap-2">
275+
IfOnce
276+
{#key reseter}
277+
<IfOnce show={show}>
278+
content
279+
</IfOnce>
280+
{/key}
281+
<label>
282+
<Checkbox bind:checked={show}/>
283+
Show
284+
</label>
285+
<Button onclick={() => reseter++}>Reset</Button>
286+
</div>
287+
</div>
269288
<div class="border grid" style="grid-template-columns: auto 1fr">
270289
<div class="col-span-2">
271290
<h3>Override Fields</h3>

frontend/viewer/src/project/ProjectSidebar.svelte

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,6 @@
9292
</Sidebar.GroupContent>
9393
</Sidebar.Group>
9494
<div class="grow"></div>
95-
<DevContent>
9695
<Sidebar.Group>
9796
<Sidebar.GroupContent>
9897
<Sidebar.Menu>
@@ -110,22 +109,23 @@
110109
></div>
111110
</Sidebar.MenuButton>
112111
</Sidebar.MenuItem>
113-
<Sidebar.MenuItem>
114-
<Sidebar.MenuButton>
115-
<Icon icon="i-mdi-account" />
116-
<span>{$t`Account`}</span>
117-
</Sidebar.MenuButton>
118-
</Sidebar.MenuItem>
119-
<Sidebar.MenuItem>
120-
<Sidebar.MenuButton>
121-
<Icon icon="i-mdi-cog" />
122-
<span>{$t`Settings`}</span>
123-
</Sidebar.MenuButton>
124-
</Sidebar.MenuItem>
112+
<DevContent>
113+
<Sidebar.MenuItem>
114+
<Sidebar.MenuButton>
115+
<Icon icon="i-mdi-account" />
116+
<span>{$t`Account`}</span>
117+
</Sidebar.MenuButton>
118+
</Sidebar.MenuItem>
119+
<Sidebar.MenuItem>
120+
<Sidebar.MenuButton>
121+
<Icon icon="i-mdi-cog" />
122+
<span>{$t`Settings`}</span>
123+
</Sidebar.MenuButton>
124+
</Sidebar.MenuItem>
125+
</DevContent>
125126
</Sidebar.Menu>
126127
</Sidebar.GroupContent>
127128
</Sidebar.Group>
128-
</DevContent>
129129

130130
<Sidebar.Group>
131131
<Sidebar.Menu>

frontend/viewer/src/project/browse/BrowseView.svelte

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import {QueryParamState} from '$lib/utils/url.svelte';
1717
import {pt} from '$lib/views/view-text';
1818
import {useCurrentView} from '$lib/views/view-service';
19+
import IfOnce from '$lib/components/if-once/if-once.svelte';
1920
2021
const currentView = useCurrentView();
2122
const dialogsService = useDialogsService();
@@ -46,7 +47,7 @@
4647
</SidebarPrimaryAction>
4748
<div class="flex flex-col h-full">
4849
<ResizablePaneGroup direction="horizontal" class="flex-1 min-h-0 !overflow-visible">
49-
{#if !IsMobile.value || !selectedEntryId.current}
50+
<IfOnce show={!IsMobile.value || !selectedEntryId.current}>
5051
<ResizablePane
5152
bind:this={leftPane}
5253
defaultSize={defaultLayout[0]}
@@ -55,7 +56,7 @@
5556
minSize={15}
5657
class="min-h-0 flex flex-col relative"
5758
>
58-
<div class="flex flex-col h-full p-2 md:p-4 md:pr-1">
59+
<div class="flex flex-col h-full p-2 md:p-4 md:pr-0">
5960
<div class="md:mr-3">
6061
<SearchFilter bind:search bind:gridifyFilter />
6162
<div class="my-2 flex items-center justify-between">
@@ -95,7 +96,7 @@
9596
previewDictionary={entryMode === 'preview'}/>
9697
</div>
9798
</ResizablePane>
98-
{/if}
99+
</IfOnce>
99100
{#if !IsMobile.value}
100101
<ResizableHandle class="my-4" {leftPane} {rightPane} withHandle resetTo={defaultLayout} />
101102
{/if}
Lines changed: 73 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
<script lang="ts">
2-
import type { IEntry } from '$lib/dotnet-types';
3-
import type { IQueryOptions } from '$lib/dotnet-types/generated-types/MiniLcm/IQueryOptions';
4-
import { SortField } from '$lib/dotnet-types/generated-types/MiniLcm/SortField';
5-
import { Debounced, resource } from 'runed';
6-
import { useMiniLcmApi } from '$lib/services/service-provider';
2+
import type {IEntry} from '$lib/dotnet-types';
3+
import type {IQueryOptions} from '$lib/dotnet-types/generated-types/MiniLcm/IQueryOptions';
4+
import {SortField} from '$lib/dotnet-types/generated-types/MiniLcm/SortField';
5+
import {resource, useDebounce} from 'runed';
6+
import {useMiniLcmApi} from '$lib/services/service-provider';
77
import EntryRow from './EntryRow.svelte';
88
import Button from '$lib/components/ui/button/button.svelte';
9-
import { cn } from '$lib/utils';
10-
import { t } from 'svelte-i18n-lingui';
11-
import {ScrollArea} from '$lib/components/ui/scroll-area';
9+
import {cn} from '$lib/utils';
10+
import {t} from 'svelte-i18n-lingui';
1211
import DevContent from '$lib/layout/DevContent.svelte';
1312
import NewEntryButton from '../NewEntryButton.svelte';
1413
import {useDialogsService} from '$lib/services/dialogs-service';
1514
import {useProjectEventBus} from '$lib/services/event-bus';
1615
import EntryMenu from './EntryMenu.svelte';
1716
import FabContainer from '$lib/components/fab/fab-container.svelte';
17+
import {VList, type VListHandle} from 'virtua/svelte';
1818
1919
const {
2020
search = '',
@@ -38,19 +38,32 @@
3838
projectEventBus.onEntryDeleted(entryId => {
3939
if (selectedEntryId === entryId) onSelectEntry(undefined);
4040
if (entriesResource.loading || !entries.some(e => e.id === entryId)) return;
41-
void entriesResource.refetch();
41+
const currentIndex = entriesResource.current?.findIndex(e => e.id === entryId) ?? -1;
42+
if (currentIndex >= 0) {
43+
entriesResource.current!.splice(currentIndex, 1);
44+
}
4245
});
4346
projectEventBus.onEntryUpdated(_entry => {
4447
if (entriesResource.loading) return;
45-
void entriesResource.refetch();
48+
const currentIndex = entriesResource.current?.findIndex(e => e.id === _entry.id) ?? -1;
49+
if (currentIndex >= 0) {
50+
entriesResource.current![currentIndex] = _entry;
51+
} else {
52+
void silentlyRefreshEntries();
53+
}
4654
});
4755
56+
async function silentlyRefreshEntries() {
57+
const updatedEntries = await fetchCurrentEntries(true);
58+
entriesResource.mutate(updatedEntries);
59+
}
4860
49-
const entriesResource = resource(
50-
() => ({ search, sortDirection, gridifyFilter }),
51-
async ({ search, sortDirection, gridifyFilter }) => {
61+
let loading = $state(false);
62+
const fetchCurrentEntries = useDebounce(async (silent = false) => {
63+
if (!silent) loading = true;
64+
try {
5265
const queryOptions: IQueryOptions = {
53-
count: 100,
66+
count: 10_000,
5467
offset: 0,
5568
filter: {
5669
gridifyFilter: gridifyFilter ? gridifyFilter : undefined,
@@ -63,16 +76,18 @@
6376
};
6477
6578
if (search) {
66-
return miniLcmApi.searchEntries(search, queryOptions);
79+
return await miniLcmApi.searchEntries(search, queryOptions);
6780
}
68-
return miniLcmApi.getEntries(queryOptions);
69-
},
70-
{
71-
debounce: 300,
81+
return await miniLcmApi.getEntries(queryOptions);
82+
} finally {
83+
loading = false;
7284
}
73-
);
85+
}, 300);
86+
87+
const entriesResource = resource(
88+
() => ({ search, sortDirection, gridifyFilter }),
89+
async () => await fetchCurrentEntries());
7490
const entries = $derived(entriesResource.current ?? []);
75-
const loading = new Debounced(() => entriesResource.loading, 50);
7691
7792
// Generate a random number of skeleton rows between 3 and 7
7893
const skeletonRowCount = Math.floor(Math.random() * 5) + 3;
@@ -82,49 +97,67 @@
8297
if (!entry) return;
8398
onSelectEntry(entry);
8499
}
100+
101+
let vList = $state<VListHandle>();
102+
$effect(() => {
103+
if (!vList || !selectedEntryId) return;
104+
const indexOfSelected = entries.findIndex(e => e.id === selectedEntryId);
105+
if (indexOfSelected === -1) return;
106+
if (indexOfSelected > vList.findEndIndex() || indexOfSelected < vList.findStartIndex())
107+
{
108+
//using smooth scroll caused lag, maybe only do it if scrolling a short distance?
109+
vList.scrollToIndex(indexOfSelected, {align: 'center'});
110+
}
111+
});
112+
85113
</script>
86114

87115
<FabContainer>
88116
<DevContent>
89117
<Button
90-
icon={loading.current ? 'i-mdi-loading' : 'i-mdi-refresh'}
118+
icon={loading ? 'i-mdi-loading' : 'i-mdi-refresh'}
91119
variant="outline"
92-
iconProps={{ class: cn(loading.current && 'animate-spin') }}
120+
iconProps={{ class: cn(loading && 'animate-spin') }}
93121
size="icon"
94-
class="mb-4"
95122
onclick={() => entriesResource.refetch()}
96123
/>
97124
</DevContent>
98125
<NewEntryButton onclick={handleNewEntry} shortForm />
99126
</FabContainer>
100127

101-
<ScrollArea class="md:pr-3 flex-1" role="table">
128+
<div class="flex-1 h-full" role="table">
102129
{#if entriesResource.error}
103130
<div class="flex items-center justify-center h-full text-muted-foreground">
104131
<p>{$t`Failed to load entries`}</p>
105132
<p>{entriesResource.error.message}</p>
106133
</div>
107134
{:else}
108-
<div class="space-y-2 p-0.5 pb-12">
109-
{#if loading.current}
110-
<!-- Show skeleton rows while loading -->
111-
{#each { length: skeletonRowCount }, _index}
112-
<EntryRow skeleton={true} />
113-
{/each}
135+
<div class="h-full">
136+
{#if loading}
137+
<div class="md:pr-3 p-0.5">
138+
<!-- Show skeleton rows while loading -->
139+
{#each { length: skeletonRowCount }, _index}
140+
<EntryRow class="mb-2" skeleton={true} />
141+
{/each}
142+
</div>
114143
{:else}
115-
{#each entries as entry}
116-
<EntryMenu {entry} contextMenu>
144+
<VList bind:this={vList} data={entries ?? []} class="h-full p-0.5 md:pr-3 after:h-12 after:block" getKey={d => d.id} overscan={10}>
145+
{#snippet children(entry)}
146+
<EntryMenu {entry} contextMenu>
117147
<EntryRow {entry}
118-
selected={selectedEntryId === entry.id}
119-
onclick={() => onSelectEntry(entry)}
120-
{previewDictionary} />
121-
</EntryMenu>
122-
{:else}
148+
class="mb-2"
149+
selected={selectedEntryId === entry.id}
150+
onclick={() => onSelectEntry(entry)}
151+
{previewDictionary}/>
152+
</EntryMenu>
153+
{/snippet}
154+
</VList>
155+
{#if entries.length === 0}
123156
<div class="flex items-center justify-center h-full text-muted-foreground">
124157
<p>{$t`No entries found`}</p>
125158
</div>
126-
{/each}
159+
{/if}
127160
{/if}
128161
</div>
129162
{/if}
130-
</ScrollArea>
163+
</div>

0 commit comments

Comments
 (0)