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