feat(files): bookmarkable file preview URL + multi-drag count ghost (#500)
Two parity gaps from the VanillaJS → Svelte migration (issue #500): 1. URL anchor when viewing a file. Opening a file now writes `?file=<id>` to the URL, so a preview is bookmarkable, reload-restorable, and Back/Forward open/close it. The viewer is driven from the URL via two effects (URL→viewer with `untrack` so a user close can't be re-opened; viewer→URL to drop the param on close with replaceState). Replaces the write-less, load-only `maybeOpenDeepLink` (the `?file` reader that was effectively dead because nothing ever set the param). 2. Multi-selection drag ghost. Dragging more than one item now sets a custom drag image: a stack of the first few rows plus a count badge, reusing the already-ported but orphaned `.drag-preview`/`.dragged-items`/ `.dragged-items-badge` styles. Previously a multi-item drag showed only the browser's default single-row ghost with no count. Also adds `static/geo/` to .prettierignore (the bundled minified world basemap from #499 is a data asset and must stay byte-faithful — it was failing `prettier --check`, blocking the gate). Frontend gate green: svelte-check 0/0, eslint, stylelint, prettier, 47 Vitest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,3 +15,4 @@ static/locales/
|
||||
static/vendors/
|
||||
static/workers/
|
||||
static/basemaps/
|
||||
static/geo/
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { errorMessage, errorToast } from '$lib/utils/errors';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { untrack } from 'svelte';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import {
|
||||
cacheFolder,
|
||||
@@ -201,7 +202,6 @@
|
||||
}
|
||||
// 304 → the cached copy already on screen is current.
|
||||
error = null;
|
||||
maybeOpenDeepLink();
|
||||
} catch (e) {
|
||||
if (seq !== loadSeq) return;
|
||||
// With a cached view already shown, keep it on a transient failure.
|
||||
@@ -229,19 +229,6 @@
|
||||
await load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-link auto-open: when the URL carries `?file=<id>` and that file is in
|
||||
* the freshly loaded listing, open it in the viewer (ported from
|
||||
* filesView.js' `app.viewFile` handling). Best-effort — a missing/unlisted
|
||||
* file is silently ignored.
|
||||
*/
|
||||
function maybeOpenDeepLink() {
|
||||
const fileId = page.url.searchParams.get('file');
|
||||
if (!fileId) return;
|
||||
const file = listing.files.find((f) => f.id === fileId);
|
||||
if (file) openFile(file);
|
||||
}
|
||||
|
||||
function openFolder(folder: FolderItem) {
|
||||
goto(`/files/${[...pathSegments, folder.id].join('/')}`);
|
||||
}
|
||||
@@ -368,10 +355,46 @@
|
||||
let viewerFile = $state<FileItem | null>(null);
|
||||
|
||||
function openFile(file: FileItem) {
|
||||
viewerFile = file;
|
||||
viewerOpen = true;
|
||||
// Drive the viewer from the URL (?file=<id>) so a preview is bookmarkable,
|
||||
// reload-restorable, and Back/Forward open/close it. The effect below
|
||||
// reflects the param into viewerOpen/viewerFile.
|
||||
const url = new URL(page.url);
|
||||
url.searchParams.set('file', file.id);
|
||||
void goto(url, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
// ── File-preview deep link (?file=<id>) ──────────────────────────────────
|
||||
// URL → viewer. Runs on navigation, on popstate (Back/Forward), and once the
|
||||
// listing for an initial deep link arrives. `untrack` stops it re-firing on
|
||||
// viewer-state changes, so a user-initiated close can't be re-opened here.
|
||||
$effect(() => {
|
||||
const fileId = page.url.searchParams.get('file');
|
||||
const files = listing.files;
|
||||
untrack(() => {
|
||||
if (!fileId) {
|
||||
if (viewerOpen) viewerOpen = false;
|
||||
return;
|
||||
}
|
||||
if (viewerOpen && viewerFile?.id === fileId) return;
|
||||
const f = files.find((x) => x.id === fileId);
|
||||
if (f) {
|
||||
viewerFile = f;
|
||||
viewerOpen = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// viewer → URL: when closed from within (X / Esc / backdrop), drop the param
|
||||
// (replaceState, so closing doesn't add a history entry).
|
||||
$effect(() => {
|
||||
const hasParam = page.url.searchParams.get('file') !== null;
|
||||
if (!viewerOpen && hasParam) {
|
||||
const url = new URL(page.url);
|
||||
url.searchParams.delete('file');
|
||||
void goto(url, { keepFocus: true, noScroll: true, replaceState: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether the server can render a thumbnail preview for this file. Images and
|
||||
* videos always have one; PDFs (and other thumbnail-capable docs) do too, so
|
||||
@@ -593,7 +616,46 @@
|
||||
const items: ActionTarget[] =
|
||||
selected.has(id) && selected.size > 1 ? selectionTargets() : [{ id, name, kind }];
|
||||
e.dataTransfer?.setData(DRAG_TYPE, JSON.stringify(items));
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
if (items.length > 1) showDragGhost(e.dataTransfer, items);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom drag image for a multi-item drag: a stack of the first few rows plus
|
||||
* a count badge (ported from ui.js). Reuses the .drag-preview / .dragged-items
|
||||
* / .dragged-items-badge styles already in resourceList.css.
|
||||
*/
|
||||
function showDragGhost(dt: DataTransfer, items: ActionTarget[]) {
|
||||
const MAX = 4;
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'drag-preview';
|
||||
|
||||
const stack = document.createElement('div');
|
||||
stack.className = 'dragged-items';
|
||||
for (const [i, it] of items.slice(0, MAX).entries()) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'file-item';
|
||||
if (i === MAX - 1 && items.length > MAX) row.classList.add('fading');
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'file-icon';
|
||||
icon.textContent = it.kind === 'folder' ? '📁' : '📄';
|
||||
const label = document.createElement('div');
|
||||
label.textContent = it.name;
|
||||
row.append(icon, label);
|
||||
stack.appendChild(row);
|
||||
}
|
||||
|
||||
const badge = document.createElement('div');
|
||||
badge.className = 'dragged-items-badge';
|
||||
badge.textContent = String(items.length);
|
||||
|
||||
preview.append(stack, badge);
|
||||
document.body.appendChild(preview);
|
||||
dt.setDragImage(preview, 0, 0);
|
||||
// The browser snapshots the drag image synchronously; drop the node next tick.
|
||||
setTimeout(() => preview.remove(), 0);
|
||||
}
|
||||
|
||||
function dragPayload(e: DragEvent): ActionTarget[] {
|
||||
|
||||
Reference in New Issue
Block a user