From ed7fbe5cbd62f8106041eca5ded6d785d457e3c3 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Fri, 21 Aug 2026 14:32:24 -0400 Subject: [PATCH] fix: keep the bottom panels on screen after leaving fullscreen on iPad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On iPad, entering fullscreen and leaving it again took the control and search panels with it: they slide past the bottom of the tablet on the way out and never come back. Rotating, resizing and switching to window mode all fail to restore them; only a reload does. Safari is unaffected. They are not hidden — they are laid out below the bottom edge of the screen. Both panels are `position: fixed; bottom: 10px`, which resolves against the layout viewport, and iPadOS keeps the taller fullscreen-sized layout viewport after the browser chrome comes back. Ten pixels above *that* bottom edge is off the tablet, and nothing in the page ever re-lays them out. panel-anchor.js measures how far the layout viewport's bottom sits below window.visualViewport — the region actually on screen — and translates the anchored elements up by that overshoot, following the viewport from then on. Where the two agree, which is every desktop browser and an iPad that behaves, the offset is zero and no transform is set at all. The software keyboard produces the same signal, and on iPad it produces it far more often: tapping into the text search field shrinks the visual viewport by the whole keyboard height. Treating that as an overshoot flings both icon bars up into the middle of the photo at z-index 4000, over the dialog they belong under. Nothing in the geometry tells the two apart, so while a text field holds focus the last correction is held rather than recomputed, and focusin/focusout resync so it is recomputed once the keyboard goes away. For the same reason the correction has a 24px floor: pinch-zoom is live (iOS ignores user-scalable=no) and the first fraction of a pinch shrinks the visible area while the scale is still 1.00, which would twitch the panels before the scale guard engages. Browser chrome, the thing actually being corrected for, is far taller than that. Anchored with the panels: #textSearchPanel, which strands identically. Not anchored: the score display, which hangs off the *top* of the viewport and would be pushed off that edge instead, and .curation-panel, which animates itself with a transform this would overwrite. The back-nav flyout and the bookmark menu clamp themselves against window.innerHeight — the layout bottom that is off the tablet — and now clamp to the exported visibleViewportBottom() instead, so their bottom rows are not cut off once the panels are reachable again. control-panel.js is hardened in passing, since it was making three assumptions that a single iPad browser can each break, and being wrong about any of them latches .hidden-fullscreen (opacity:0 + visibility:hidden, both !important) onto the panels while the app is windowed — unrecoverable, because visibility:hidden also takes the fullscreen button out of hit testing: - The state is read through the vendor-prefixed properties and the prefixed change events are subscribed to, matching what touch.js already does. Two modules answering "are we fullscreen?" differently was a bug waiting for a browser to disagree with one of them. The exit chain covers every spelling the state check accepts, including mozCancelFullScreen and webkitCancelFullScreen — neither legacy vendor calls it "exit", and without them the button enters fullscreen once and then does nothing for ever. - The state is resampled at 0/250/750ms after each change event, since an event delivered while the document still names the outgoing element reads as "still fullscreen", and the exit is animated so the viewport settles later than the event. - resize, orientationchange and visualViewport resize all resync. The last of those matters most: on a stranded exit the layout viewport does not change, so window.resize may never fire, while the visible area shrinking always does. Both syncs derive their result from the current state rather than toggling it, which is what makes every extra sample free. Tests: panel-anchor.test.js covers the overshoot maths, an offset visible area, clearing again, the resync triggers, the keyboard hold and its release, the mid-transition resample, visibleViewportBottom, the six no-op cases and the seam with control-panel.js; fullscreen-panels.test.js covers the webkit-only transition, the prefixed request/exit paths including the cancel spelling, a browser with no fullscreen API, a stale-read exit and all three resync signals. Frontend 633 passed, backend 770 passed, lint and format clean. Verified on the reporter's iPad, text search included. Co-Authored-By: Claude Opus 5 (1M context) --- photomap/backend/invokeai_client.py | 486 ++++++----------- photomap/backend/routers/index.py | 31 +- .../static/css/umap-floating-window.css | 11 - .../frontend/static/javascript/back-button.js | 9 +- .../frontend/static/javascript/bookmarks.js | 5 +- .../static/javascript/control-panel.js | 96 +++- .../static/javascript/panel-anchor.js | 127 +++++ photomap/frontend/static/javascript/umap.js | 245 +-------- tests/backend/test_cluster_eps.py | 24 - tests/backend/test_invokeai_board_index.py | 78 +-- tests/backend/test_invokeai_client.py | 346 ++---------- tests/frontend/fullscreen-panels.test.js | 179 +++++++ tests/frontend/panel-anchor.test.js | 260 ++++++++++ tests/frontend/umap-eps-debounce.test.js | 491 ------------------ tests/frontend/umap-reindex-refresh.test.js | 177 +------ 15 files changed, 898 insertions(+), 1667 deletions(-) create mode 100644 photomap/frontend/static/javascript/panel-anchor.js create mode 100644 tests/frontend/fullscreen-panels.test.js create mode 100644 tests/frontend/panel-anchor.test.js delete mode 100644 tests/frontend/umap-eps-debounce.test.js diff --git a/photomap/backend/invokeai_client.py b/photomap/backend/invokeai_client.py index abdf8d44..4ca06a62 100644 --- a/photomap/backend/invokeai_client.py +++ b/photomap/backend/invokeai_client.py @@ -4,16 +4,11 @@ running InvokeAI instance: URL validation, the JWT token cache with its single-user/multi-user fallback logic, and thin wrappers around the InvokeAI REST endpoints PhotoMap consumes (version probe, board listing, -board image and video paths, image and video deletion). +board image and video names, image and video deletion). Images and videos are distinct resources on the InvokeAI side — separate -routers, separate output directories — so each has its own wrapper here -over the shared paging helper. - -Board contents are reported as paths *relative to the media type's outputs -directory*, never as bare names: InvokeAI's subfolder strategy is a -server-side setting, and the subfolder each file was actually written to is -recorded per row, so only the row itself says where the file lives. +routers, separate listing shapes, separate output directories — so each has +its own wrapper here rather than one parameterized by media type. It deliberately lives outside ``routers/`` so that non-router code (the indexing pipeline, curation) can use it without importing a FastAPI router @@ -44,19 +39,10 @@ # certainly means the backend is unreachable rather than genuinely busy. _HTTP_TIMEOUT = 5.0 -# Listing the contents of a very large board can legitimately take longer -# than the snappy 5s used for control-plane calls. +# Listing the image names of a very large board can legitimately take +# longer than the snappy 5s used for control-plane calls. _BOARD_FETCH_TIMEOUT = 30.0 -# The videos listing declares ``limit: le=MAX_PAGE_SIZE`` and answers 422 -# above it; the images listing has no such clamp. MAX_PAGE_SIZE has been -# 1000 since it was introduced, so this is the largest page both will serve. -_DTO_PAGE_SIZE = 1000 - -# Last-resort backstop. A server that ignores ``offset`` is normally caught -# far sooner, by the "paged past the declared total" break in the walk. -_MAX_DTO_PAGES = 1_000 - # ── InvokeAI JWT token cache ────────────────────────────────────────── _cached_token: str | None = None _token_expires_at: float = 0.0 @@ -292,354 +278,192 @@ async def _do(headers: dict[str, str]) -> httpx.Response: ] -def _has_path_syntax(part: str) -> bool: - """True if ``part`` is anything other than one plain path component. - - An empty string, a dot segment, or a component carrying a separator of - either flavour all mean the server handed back structure where a bare - name was expected — which is the only way a joined path can leave the - outputs directory. - """ - return not part or part in {".", ".."} or "/" in part or "\\" in part - - -def _media_relpath(name: object, subfolder: object, resource: str) -> str | None: - """Join InvokeAI's stored subfolder onto a media name, or ``None``. - - InvokeAI does not necessarily store a board's media flat under - ``outputs/images`` / ``outputs/videos``: the subfolder strategy is a - server-side setting (``flat``, ``type``, ``date``, ``hash``) and the - subfolder actually used is recorded per row at save time, which is why - it has to be read off each DTO rather than derived here. An empty or - absent subfolder is the flat layout, and is what every InvokeAI - predating the setting reports. - - The result is turned into a local filesystem path by the caller, so the - server's string is treated as untrusted: a name that is not a bare - basename, or a subfolder that is absolute or walks upwards, is dropped - with a warning rather than allowed to escape the outputs directory. - - The accepted grammar is deliberately the one InvokeAI itself enforces - when it *writes* a subfolder (``DiskImageFileStorage._validate_subfolder``): - forward-slash separated, relative, no empty or dot segments, and no - backslashes at all. Every strategy emits ``/`` even when InvokeAI runs - on Windows, so a backslash is not a separator to be normalized but a - value InvokeAI would have refused to store. Rewriting them to ``/`` - instead is actively unsafe: a lone backslash followed by ``etc`` is not - absolute as written, so an is-absolute check on it passes, and it then - *becomes* ``/etc`` — and joining an absolute path discards the outputs - directory entirely. - - Names are held to the same rule. Testing only for ``/`` leaves a - backslash-separated ``..`` chain looking like an ordinary filename, - which it is on Linux but is not on a Windows PhotoMap host, where those - segments would be walked. - """ - if not isinstance(name, str) or _has_path_syntax(name): - logger.warning("Ignoring InvokeAI %s with a suspicious name: %r", resource, name) - return None - if not subfolder: - return name - if not isinstance(subfolder, str): - logger.warning( - "Ignoring InvokeAI %s %r: non-string subfolder %r", resource, name, subfolder - ) - return None - parts = subfolder.split("/") - if any(_has_path_syntax(part) for part in parts): - logger.warning( - "Ignoring InvokeAI %s %r: unsafe subfolder %r", resource, name, subfolder - ) - return None - return "/".join([*parts, name]) - - -class BoardMediaPaths(NamedTuple): - """What a board listing learned about one media type on a board. +async def fetch_board_image_names( + base_url: str, + board_ids: list[str], + username: str | None, + password: str | None, +) -> list[str]: + """Return the image names belonging to ``board_ids``, deduplicated. - ``relpaths`` are paths *relative to that media type's outputs directory* - (``general/x.mp4`` under a type-organized InvokeAI, plain ``x.mp4`` - under a flat one), so the caller only has to join them to - ``/outputs/``. + Calls ``GET /api/v1/boards/{board_id}/image_names`` for each board. + The special board id ``"none"`` is InvokeAI's Uncategorized bucket. + Returned names include their file extension (``{uuid}.png`` style). + Raises 502 on any network error or non-200 response. - ``api_available`` is False when the backend answered 404 for the - listing router. ``relpaths`` may still be non-empty in that case - (earlier boards in the same call succeeded); what the flag says is that - the listing is incomplete, so an empty or short list must not be read as - "these files were removed from the board". + Canvas intermediates (region masks, staging composites) and + control/mask-category assets are excluded, matching what InvokeAI's own + gallery shows. Servers that predate these query params ignore them and + return the unfiltered list. """ + # httpx repeats list values (categories=general&categories=user), which + # is the encoding FastAPI expects for list[ImageCategory]. + filter_params = { + "is_intermediate": "false", + "categories": ["general", "user"], + } + all_names: list[str] = [] + try: + async with httpx.AsyncClient(timeout=_BOARD_FETCH_TIMEOUT) as client: + for board_id in board_ids: + names_url = ( + f"{base_url.rstrip('/')}/api/v1/boards/{board_id}/image_names" + ) - relpaths: list[str] - api_available: bool + async def _do( + headers: dict[str, str], url: str = names_url + ) -> httpx.Response: + return await client.get(url, params=filter_params, headers=headers) + + response = await _request_with_auth_fallback( + base_url, username, password, _do + ) + if response.status_code >= 400: + raise HTTPException( + status_code=502, + detail=( + f"InvokeAI backend returned {response.status_code} for " + f"board {board_id!r}: {response.text[:200]}" + ), + ) + try: + names = response.json() + except ValueError as exc: + raise HTTPException( + status_code=502, + detail=f"Image-names endpoint for board {board_id!r} did not return JSON", + ) from exc + if not isinstance(names, list): + raise HTTPException( + status_code=502, + detail=f"Image-names endpoint for board {board_id!r} returned an unexpected shape", + ) + all_names.extend(str(name) for name in names) + except httpx.RequestError as exc: + logger.warning("InvokeAI image-names request failed: %s", exc) + raise HTTPException( + status_code=502, + detail=f"Could not reach InvokeAI backend at {base_url}: {exc}", + ) from exc + # An image can belong to only one board, but guard against overlapping + # selections (e.g. "none" plus a board) — dedupe preserving order. + return list(dict.fromkeys(all_names)) -class _BoardWalk(NamedTuple): - """One pass over a single board's listing. - ``relpaths`` is in listing order and may repeat an entry: rows added - while the walk is in progress shift later rows to a higher offset, so a - page can re-show something an earlier page already returned. +class BoardVideoNames(NamedTuple): + """What :func:`fetch_board_video_names` learned about a board's videos. - ``seen_names`` holds the raw names, deduped, *before* sanitizing — it is - what gets compared against ``declared_total``, so a row dropped for an - unsafe path does not read as a page the server failed to serve. + ``api_available`` is False when the backend answered 404 for the video + router. ``names`` may still be non-empty in that case (earlier boards in + the same call succeeded); what the flag says is that the listing is + incomplete, so an empty or short list must not be read as "these videos + were removed from the board". """ - relpaths: list[str] - seen_names: set[str] - declared_total: int | None + names: list[str] + api_available: bool -async def _fetch_board_media_relpaths( +async def fetch_board_video_names( base_url: str, board_ids: list[str], username: str | None, password: str | None, - *, - resource: str, - name_key: str, - subfolder_key: str, - tolerate_absent_router: bool, -) -> BoardMediaPaths: - """Page a board listing endpoint and return relative on-disk paths. - - ``GET /api/v1/{resource}/`` is used rather than the cheaper - ``.../names`` endpoints because only the DTO carries the row's - subfolder, and without it there is no way to locate the file on disk on - any InvokeAI not configured for the flat layout. (The name endpoints - are deprecated upstream in favour of the polymorphic gallery listing - anyway.) It costs roughly one request per 1000 rows and a fatter - payload, since a whole DTO is fetched to read one field. +) -> BoardVideoNames: + """Return the video names belonging to ``board_ids``, deduplicated. - Canvas intermediates (region masks, staging composites) and - control/mask-category assets are excluded, matching what InvokeAI's own - gallery shows — a Wan pipeline writes its intermediate clips to the - board just as canvas writes its staging images. Servers that predate - these query params ignore them and return the unfiltered list. - - **Completeness matters more than freshness here.** The caller feeds - this list to an index *update*, which prunes every indexed row the list - does not mention, so a listing that is quietly short deletes rows for - files that are still on the board. Offset pagination is not a snapshot: - InvokeAI orders by ``starred DESC, created_at DESC`` and serves each page - from a separate query, so deleting, unstarring or un-boarding one row - between pages shifts every later row down an offset and skips exactly one - of them. Each board is therefore checked against the ``total`` its own - listing reported, re-walked once if it came up short, and only then - failed — an aborted update leaves the previous index intact, which is the - recoverable outcome; silently pruning live rows is not. (Rows *added* - mid-walk are harmless: they push entries to a higher offset, so they can - only produce a repeat, which the dedupe absorbs.) - - ``tolerate_absent_router`` reports a 404 as ``api_available=False`` - instead of raising. That matters only for videos: an InvokeAI predating - video support has no ``/api/v1/videos`` router at all, and a board album - on such a backend still has to index its images. The 404 is *not* - unambiguous — InvokeAI answers 404 "Board not found" for a caller whose - board id no longer resolves, and a reverse proxy can route - ``/api/v1/images`` while 404ing ``/api/v1/videos`` — so an absent - listing is reported as a distinct fact from an empty one, and only the - caller can decide whether dropping previously indexed rows is warranted. - - Paths already collected from earlier boards are kept when a later board + Videos are a separate resource from images in InvokeAI, with their own + router: this calls ``GET /api/v1/videos/names?board_id=...`` once per + board (the board is a *query* parameter here, unlike the images + endpoint's path parameter) and reads the ``video_names`` array out of the + returned ``VideoNamesResult`` object. Returned names include their + extension (``{uuid}.mp4``) and resolve under + ``/outputs/videos``. + + The same ``is_intermediate``/``categories`` filters as + :func:`fetch_board_image_names` are applied, and they matter just as much + here: a Wan pipeline writes its intermediate clips to the board too, so + an unfiltered listing returns videos the InvokeAI gallery itself hides. + + A backend predating video support has no ``/api/v1/videos`` router at + all and answers **404**, which must not fail the whole index run — board + albums have to keep indexing their images against an older InvokeAI. + That case is reported as ``api_available=False`` rather than as a bare + empty list, because a 404 is *not* unambiguous: InvokeAI's own + ``get_video_names`` calls ``assert_board_read_access``, which answers 404 + "Board not found" for a non-admin caller whose board id no longer + resolves, and a reverse proxy in front of InvokeAI can route + ``/api/v1/images`` while 404ing ``/api/v1/videos``. An empty listing and + an absent listing are different facts to the caller: the first means the + board has no videos, the second means we do not know what it has, and + only the caller can decide whether dropping previously indexed videos is + warranted. + + Names already collected from earlier boards are kept when a later board 404s, for the same reason: they were fetched successfully and are not made wrong by a subsequent failure. """ - filter_params: dict[str, object] = { + filter_params = { "is_intermediate": "false", "categories": ["general", "user"], - "limit": _DTO_PAGE_SIZE, } - list_url = f"{base_url.rstrip('/')}/api/v1/{resource}/" - all_relpaths: list[str] = [] - - def _incomplete(walk: _BoardWalk) -> bool: - """Did the walk return fewer distinct rows than the server claimed?""" - return walk.declared_total is not None and len(walk.seen_names) < walk.declared_total - + names_url = f"{base_url.rstrip('/')}/api/v1/videos/names" + all_names: list[str] = [] try: async with httpx.AsyncClient(timeout=_BOARD_FETCH_TIMEOUT) as client: + for board_id in board_ids: + params = {**filter_params, "board_id": board_id} - async def _walk(board_id: str) -> _BoardWalk | None: - """One full pass over ``board_id``; ``None`` if it 404'd and - a 404 is tolerated for this resource.""" - relpaths: list[str] = [] - seen_names: set[str] = set() - declared_total: int | None = None - offset = 0 - for _ in range(_MAX_DTO_PAGES): - params = {**filter_params, "board_id": board_id, "offset": offset} - - async def _do( - headers: dict[str, str], params: dict = params - ) -> httpx.Response: - return await client.get(list_url, params=params, headers=headers) - - response = await _request_with_auth_fallback( - base_url, username, password, _do - ) - if response.status_code == 404 and tolerate_absent_router: - logger.info( - "InvokeAI backend at %s did not answer the %s listing " - "for board %r (no such API, or the board is not " - "readable); board %s were not listed.", - base_url, - resource, - board_id, - resource, - ) - return None - if response.status_code >= 400: - raise HTTPException( - status_code=502, - detail=( - f"InvokeAI backend returned {response.status_code} for " - f"{resource} on board {board_id!r}: {response.text[:200]}" - ), - ) - try: - payload = response.json() - except ValueError as exc: - raise HTTPException( - status_code=502, - detail=f"The {resource} listing for board {board_id!r} did not return JSON", - ) from exc - items = payload.get("items") if isinstance(payload, dict) else None - if not isinstance(items, list): - raise HTTPException( - status_code=502, - detail=( - f"The {resource} listing for board {board_id!r} " - "returned an unexpected shape" - ), - ) - total = payload.get("total") - if declared_total is None and isinstance(total, int) and total >= 0: - declared_total = total - for item in items: - if not isinstance(item, dict): - continue - name = item.get(name_key) - if isinstance(name, str): - seen_names.add(name) - relpath = _media_relpath( - name, item.get(subfolder_key, ""), resource - ) - if relpath is not None: - relpaths.append(relpath) - # A short page is the last page. - if len(items) < _DTO_PAGE_SIZE: - break - offset += len(items) - # Paging past what the server said it holds means it is - # not honouring ``offset`` (a caching or query-stripping - # proxy will re-serve page one forever). Stop and let the - # completeness check below report it, rather than grinding - # through the whole page budget. - if declared_total is not None and offset >= declared_total + _DTO_PAGE_SIZE: - break - else: - logger.warning( - "Stopped paging %s on board %r after %d pages.", - resource, - board_id, - _MAX_DTO_PAGES, - ) - return _BoardWalk(relpaths, seen_names, declared_total) + async def _do( + headers: dict[str, str], params: dict = params + ) -> httpx.Response: + return await client.get(names_url, params=params, headers=headers) - for board_id in board_ids: - walk = await _walk(board_id) - if walk is None: - return BoardMediaPaths(list(dict.fromkeys(all_relpaths)), False) - if _incomplete(walk): - logger.warning( - "InvokeAI listed %d of %d %s for board %r; the board " - "changed mid-listing. Re-reading it.", - len(walk.seen_names), - walk.declared_total, - resource, + response = await _request_with_auth_fallback( + base_url, username, password, _do + ) + if response.status_code == 404: + logger.info( + "InvokeAI backend at %s did not answer the video-names " + "endpoint for board %r (no video API, or the board is " + "not readable); board videos were not listed.", + base_url, board_id, ) - retry = await _walk(board_id) - if retry is None: - return BoardMediaPaths(list(dict.fromkeys(all_relpaths)), False) - if _incomplete(retry): - raise HTTPException( - status_code=502, - detail=( - f"InvokeAI listed only {len(retry.seen_names)} of " - f"{retry.declared_total} {resource} on board " - f"{board_id!r}, twice in a row. Indexing was " - f"stopped rather than treat the missing entries " - f"as deleted; try again once the board is idle." - ), - ) - walk = retry - all_relpaths.extend(walk.relpaths) + return BoardVideoNames(list(dict.fromkeys(all_names)), False) + if response.status_code >= 400: + raise HTTPException( + status_code=502, + detail=( + f"InvokeAI backend returned {response.status_code} for " + f"videos on board {board_id!r}: {response.text[:200]}" + ), + ) + try: + payload = response.json() + except ValueError as exc: + raise HTTPException( + status_code=502, + detail=f"Video-names endpoint for board {board_id!r} did not return JSON", + ) from exc + names = payload.get("video_names") if isinstance(payload, dict) else None + if not isinstance(names, list): + raise HTTPException( + status_code=502, + detail=f"Video-names endpoint for board {board_id!r} returned an unexpected shape", + ) + all_names.extend(str(name) for name in names) except httpx.RequestError as exc: - logger.warning("InvokeAI %s listing request failed: %s", resource, exc) + logger.warning("InvokeAI video-names request failed: %s", exc) raise HTTPException( status_code=502, detail=f"Could not reach InvokeAI backend at {base_url}: {exc}", ) from exc - # A file belongs to one board, but overlapping selections (a board plus + # A video belongs to one board, but overlapping selections (a board plus # "none") must not index the same file twice — dedupe preserving order. - return BoardMediaPaths(list(dict.fromkeys(all_relpaths)), True) - - -async def fetch_board_image_relpaths( - base_url: str, - board_ids: list[str], - username: str | None, - password: str | None, -) -> list[str]: - """Return the board images' paths relative to ``outputs/images``. - - The special board id ``"none"`` is InvokeAI's Uncategorized bucket. - Names include their file extension (``{uuid}.png`` style) and are - prefixed by the subfolder InvokeAI filed them under, if any. Raises 502 - on any network error or non-200 response — unlike videos, there is no - InvokeAI old enough to lack an image listing, so a failure here is a - real failure. - """ - media = await _fetch_board_media_relpaths( - base_url, - board_ids, - username, - password, - resource="images", - name_key="image_name", - subfolder_key="image_subfolder", - tolerate_absent_router=False, - ) - return media.relpaths - - -async def fetch_board_video_relpaths( - base_url: str, - board_ids: list[str], - username: str | None, - password: str | None, -) -> BoardMediaPaths: - """Return the board videos' paths relative to ``outputs/videos``. - - Videos are a separate resource from images in InvokeAI, with their own - router and their own outputs directory, so they are listed separately. - A backend predating video support answers 404 and is reported as - ``api_available=False`` rather than as an empty board — see - :func:`_fetch_board_media_relpaths`. - """ - return await _fetch_board_media_relpaths( - base_url, - board_ids, - username, - password, - resource="videos", - name_key="video_name", - subfolder_key="video_subfolder", - tolerate_absent_router=True, - ) + return BoardVideoNames(list(dict.fromkeys(all_names)), True) async def delete_image( diff --git a/photomap/backend/routers/index.py b/photomap/backend/routers/index.py index 2b5ef5a8..a7fee279 100644 --- a/photomap/backend/routers/index.py +++ b/photomap/backend/routers/index.py @@ -722,20 +722,12 @@ class BoardAlbumFiles(NamedTuple): async def _resolve_board_album_files(album_config) -> BoardAlbumFiles: """Resolve an InvokeAI-board album's images and videos to local paths. - Fetches the selected boards' images *and* videos from the InvokeAI API - and joins the returned paths to ``/outputs/images`` and - ``/outputs/videos`` respectively — the two are separate - resources on the InvokeAI side, listed by separate endpoints and stored - in separate directories, so both have to be asked for. - - Those paths are *relative*, not bare filenames: InvokeAI files each - image and video into a subfolder chosen by a server-side strategy - (``flat``, ``type``, ``date``, ``hash``), recorded per row, so a board's - videos routinely live at ``outputs/videos/general/`` rather than - ``outputs/videos/``. The client hands back whatever prefix the - server reported (empty for a flat backend), and this only joins it. - - Files the API lists but that don't exist locally are skipped with a + Fetches the selected boards' image names *and* video names from the + InvokeAI API and maps them to ``/outputs/images/`` + and ``/outputs/videos/`` respectively — the two are + separate resources on the InvokeAI side, listed by separate endpoints and + stored in separate directories, so both have to be asked for by name. + Names the API lists but that don't exist locally are skipped with a warning; if *none* of them exist the InvokeAI root is almost certainly wrong, which deserves a pointed error instead of a generic "no images found". The error names the directory that actually came up empty, since @@ -750,7 +742,7 @@ async def _resolve_board_album_files(album_config) -> BoardAlbumFiles: still on the board. """ outputs = Path(album_config.invokeai_root).expanduser() / "outputs" - image_relpaths = await invokeai_client.fetch_board_image_relpaths( + image_names = await invokeai_client.fetch_board_image_names( album_config.invokeai_url, album_config.invokeai_board_ids, album_config.invokeai_username, @@ -759,18 +751,15 @@ async def _resolve_board_album_files(album_config) -> BoardAlbumFiles: # ``api_available`` is False against an InvokeAI with no video API, so a # board album on an older backend keeps indexing exactly as it did before # — the caller only has to say so when it costs the album something. - ( - video_relpaths, - video_api_available, - ) = await invokeai_client.fetch_board_video_relpaths( + video_names, video_api_available = await invokeai_client.fetch_board_video_names( album_config.invokeai_url, album_config.invokeai_board_ids, album_config.invokeai_username, album_config.invokeai_password, ) - image_paths = [outputs / "images" / rel for rel in image_relpaths] - video_paths = [outputs / "videos" / rel for rel in video_relpaths] + image_paths = [outputs / "images" / name for name in image_names] + video_paths = [outputs / "videos" / name for name in video_names] paths = image_paths + video_paths existing = [p for p in paths if p.is_file()] missing = len(paths) - len(existing) diff --git a/photomap/frontend/static/css/umap-floating-window.css b/photomap/frontend/static/css/umap-floating-window.css index 8f03c9dd..e715d333 100644 --- a/photomap/frontend/static/css/umap-floating-window.css +++ b/photomap/frontend/static/css/umap-floating-window.css @@ -268,17 +268,6 @@ display: none; } -/* The Cluster Strength field holds something the album cannot be set to — a - half-typed number, or one below the floor the server would silently raise. - umap.js answers those by doing nothing, so without a mark a refused - keystroke and a saved one look the same. Set from JS rather than `:invalid` - so it never fires on a derived strength that simply exceeds the spinner's - display `max`, which is a real number the map is clustering with. */ -#umapEpsSpinner.umap-eps-unusable { - border: 1px solid #ff8080; - outline: none; -} - #umapClickBehaviorContainer, #umapMediaFilterContainer { font-size: 0.85em; diff --git a/photomap/frontend/static/javascript/back-button.js b/photomap/frontend/static/javascript/back-button.js index 58218bcb..24fac76a 100644 --- a/photomap/frontend/static/javascript/back-button.js +++ b/photomap/frontend/static/javascript/back-button.js @@ -4,6 +4,7 @@ // recent positions as thumbnails the user can jump to directly. import { backStack } from "./back-stack.js"; +import { visibleViewportBottom } from "./panel-anchor.js"; import { state } from "./state.js"; const FLYOUT_ROWS = 3; @@ -68,13 +69,17 @@ function populateFlyout(flyout) { function clampFlyoutPosition(flyout) { const rect = flyout.getBoundingClientRect(); const margin = 6; + // Not window.innerHeight: on iPadOS the bottom of the layout viewport can be + // off the screen (see panel-anchor.js), and clamping to it puts the bottom + // row of thumbnails somewhere the user cannot see. + const bottom = visibleViewportBottom(); let left = flyout._anchorX; let top = flyout._anchorY; if (left + rect.width > window.innerWidth - margin) { left = Math.max(margin, window.innerWidth - rect.width - margin); } - if (top + rect.height > window.innerHeight - margin) { - top = Math.max(margin, window.innerHeight - rect.height - margin); + if (top + rect.height > bottom - margin) { + top = Math.max(margin, bottom - rect.height - margin); } flyout.style.left = `${left}px`; flyout.style.top = `${top}px`; diff --git a/photomap/frontend/static/javascript/bookmarks.js b/photomap/frontend/static/javascript/bookmarks.js index 2365562f..490161e0 100644 --- a/photomap/frontend/static/javascript/bookmarks.js +++ b/photomap/frontend/static/javascript/bookmarks.js @@ -6,6 +6,7 @@ import { showDeleteConfirmModal } from "./control-panel.js"; import { createSimpleDirectoryPicker } from "./filetree.js"; import { deleteImages } from "./index.js"; import { showConfirmModal } from "./modal-utils.js"; +import { visibleViewportBottom } from "./panel-anchor.js"; import { setSearchResults } from "./search.js"; import { slideState } from "./slide-state.js"; import { state } from "./state.js"; @@ -1006,7 +1007,9 @@ class BookmarkManager { // Position after appending so we can measure const menuHeight = menu.offsetHeight; const menuWidth = menu.offsetWidth; - const windowHeight = window.innerHeight; + // The visible bottom edge, which on iPadOS is not always the layout + // viewport's bottom edge — see panel-anchor.js. + const windowHeight = visibleViewportBottom(); const windowWidth = window.innerWidth; // Position above the click if would go off bottom diff --git a/photomap/frontend/static/javascript/control-panel.js b/photomap/frontend/static/javascript/control-panel.js index 1113a151..3ed0a235 100644 --- a/photomap/frontend/static/javascript/control-panel.js +++ b/photomap/frontend/static/javascript/control-panel.js @@ -1,6 +1,7 @@ // control-panel.js // This file manages control panel button events (fullscreen, copy, delete) import { deleteImage, getIndexMetadata } from "./index.js"; +import { initializePanelAnchor, syncPanelAnchor } from "./panel-anchor.js"; import { getCurrentFilepath, getCurrentSlideIndex, slideState } from "./slide-state.js"; import { saveSettingsToLocalStorage, state } from "./state.js"; import { errorDetail, hideSpinner, showSpinner } from "./utils.js"; @@ -19,17 +20,48 @@ function cacheElements() { }; } -// Toggle fullscreen mode +// Is the document (or anything in it) currently fullscreen? +// +// Which spelling of the Fullscreen API a browser exposes is not something this +// app gets to assume — reading only document.fullscreenElement can report "not +// fullscreen" while the app plainly is, and the panels then stay hidden with +// no way back. touch.js already checks all four; this now agrees with it +// rather than answering the same question differently. +function isDocumentFullscreen() { + return !!( + document.fullscreenElement || + document.webkitFullscreenElement || + document.webkitCurrentFullScreenElement || + document.mozFullScreenElement || + document.msFullscreenElement + ); +} + +// Toggle fullscreen mode. +// +// The exit chain has to cover every spelling the state check above accepts, or +// the button becomes a one-way trip: enter succeeds, isDocumentFullscreen() +// then reports true through a prefixed property, and the exit call is missing. +// Note that neither legacy vendor spells it "exit" — Firefox cancels, and so +// did older WebKit. function toggleFullscreen() { const elem = document.documentElement; - if (!document.fullscreenElement) { - elem.requestFullscreen(); + if (isDocumentFullscreen()) { + const exit = + document.exitFullscreen || + document.webkitExitFullscreen || + document.webkitCancelFullScreen || + document.mozCancelFullScreen || + document.msExitFullscreen; + exit?.call(document); } else { - document.exitFullscreen(); + const request = + elem.requestFullscreen || elem.webkitRequestFullscreen || elem.mozRequestFullScreen || elem.msRequestFullscreen; + request?.call(elem); } } -// Panel visibility follows document.fullscreenElement and nothing else. +// Panel visibility follows the current fullscreen state and nothing else. // // A