Gallery extension selection feature
This commit is contained in:
+1
-1
@@ -378,7 +378,7 @@ covered by the `styles.css` manifest.
|
||||
safe docked sidebar preservation upon node addition, and multi-type node dispatch; `ui_detail.js`
|
||||
coordinates detail display, `ui_model_editor.js` owns metadata editing, and
|
||||
`ui_model_selector.js` owns advanced selection. `ui_gallery.js` (cards in
|
||||
`ui_gallery_card.js`) and `ui_gallery_detail.js` own generated-image browsing and workbench lifecycle,
|
||||
`ui_gallery_card.js`, format filtering state in `gallery_filter.js`, and quick filter and settings UI in `ui_gallery_filter.js`) and `ui_gallery_detail.js` own generated-image browsing and workbench lifecycle,
|
||||
with stage interaction in `ui_image_stage.js` and metadata tabs in
|
||||
`ui_image_inspector.js`.
|
||||
- `ui_recipes.js` / `ui_recipe_detail.js` and `ui_notebooks.js`
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 📈 Anomalous Model Browser Changelog
|
||||
|
||||
## Unreleased (Gallery file extensions & video display)
|
||||
|
||||
### 🖼️ Gallery file extension filtering & video playback (画廊扩展名过滤与视频支持 / 갤러리 확장자 필터 및 비디오 지원)
|
||||
- **File format configuration (파일 확장자 표시/숨김 설정)**: In the Gallery toolbar and Settings page, users can configure which output file extensions to display and hide. Easily toggle individual formats (PNG, JPG, WEBP, GIF, MP4, WEBM, MOV, MKV) or use quick group presets (All, Images, Videos).
|
||||
- **Video outputs support (비디오 출력 지원)**: Gallery backend now indexes video outputs (.mp4, .webm, .mov, .avi, .mkv), generates video posters for thumbnails, displays a video format badge on the card, and enables video playback in the full-size gallery viewer.
|
||||
|
||||
## v1.58.1 Beta (Security: only this computer can change things) — 2026-10-12
|
||||
|
||||
### 🔒 Other devices can look, not change (别的设备只能看,不能改)
|
||||
|
||||
+46
-8
@@ -9,13 +9,22 @@ from aiohttp import web
|
||||
import folder_paths
|
||||
|
||||
from .image_search import filter_images
|
||||
from .media_routes import CARD_THUMBNAIL_STATIC_EXTENSIONS, _build_card_thumbnail
|
||||
from .media_routes import (
|
||||
CARD_POSTER_VIDEO_EXTENSIONS,
|
||||
CARD_THUMBNAIL_STATIC_EXTENSIONS,
|
||||
_build_card_thumbnail,
|
||||
_build_video_poster,
|
||||
)
|
||||
from .path_utils import require_filename, resolve_within
|
||||
from .trash import move_to_trash, trash_failure
|
||||
|
||||
|
||||
GALLERY_SNAPSHOT_SECONDS = 10
|
||||
MAX_CACHED_GALLERY_IMAGES = 50_000
|
||||
SUPPORTED_GALLERY_EXTENSIONS = {
|
||||
'.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif',
|
||||
'.mp4', '.webm', '.mov', '.avi', '.mkv',
|
||||
}
|
||||
_gallery_snapshot_lock = threading.Lock()
|
||||
_gallery_snapshot = {"root": None, "expires": 0, "images": []}
|
||||
|
||||
@@ -24,7 +33,8 @@ def _collect_gallery_images(output_dir):
|
||||
images = []
|
||||
for root, _, files in os.walk(output_dir):
|
||||
for filename in files:
|
||||
if os.path.splitext(filename)[1].lower() not in {'.png', '.jpg', '.jpeg', '.webp', '.gif'}:
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext not in SUPPORTED_GALLERY_EXTENSIONS:
|
||||
continue
|
||||
try:
|
||||
mtime = os.path.getmtime(os.path.join(root, filename))
|
||||
@@ -34,7 +44,10 @@ def _collect_gallery_images(output_dir):
|
||||
images.append({
|
||||
"filename": filename,
|
||||
"subfolder": "" if subfolder == "." else subfolder.replace(os.sep, '/'),
|
||||
"type": "output", "mtime": mtime,
|
||||
"type": "output",
|
||||
"mtime": mtime,
|
||||
"ext": ext.lstrip('.'),
|
||||
"media_type": "video" if ext in CARD_POSTER_VIDEO_EXTENSIONS else "image",
|
||||
})
|
||||
images.sort(key=lambda item: (item['mtime'], item['subfolder'], item['filename']), reverse=True)
|
||||
return images
|
||||
@@ -58,20 +71,40 @@ def _invalidate_gallery_snapshot():
|
||||
_gallery_snapshot.update(root=None, expires=0, images=[])
|
||||
|
||||
|
||||
def _gallery_listing(output_dir, refresh, query):
|
||||
def _gallery_listing(output_dir, refresh, query, allowed_exts=None):
|
||||
images = _gallery_images(output_dir, refresh)
|
||||
if allowed_exts is not None:
|
||||
images = [img for img in images if os.path.splitext(img['filename'])[1].lower().lstrip('.') in allowed_exts]
|
||||
return filter_images(os.path.realpath(output_dir), images, query) if query else images
|
||||
|
||||
|
||||
async def api_get_gallery_images(request):
|
||||
"""GET /anomalous/gallery_images?page&limit&refresh&term=..&term=.. (or q) - Output images, optionally searched by embedded generation info."""
|
||||
"""GET /anomalous/gallery_images?page&limit&refresh&term=..&ext=.. - Output images/videos, optionally filtered by extension."""
|
||||
try:
|
||||
page = max(1, int(request.query.get('page', 1)))
|
||||
limit = min(200, max(1, int(request.query.get('limit', 50))))
|
||||
# Repeated `term` params keep phrases whole; `q` is split on whitespace.
|
||||
terms = [term for term in request.query.getall('term', []) if term.strip()]
|
||||
query = terms or request.query.get('q', '').strip()
|
||||
images = await asyncio.to_thread(_gallery_listing, folder_paths.get_output_directory(), request.query.get('refresh') == '1', query)
|
||||
|
||||
# Optional extension filter (?ext=png&ext=mp4 or ?ext=png,mp4)
|
||||
ext_filter = None
|
||||
if 'ext' in request.query:
|
||||
allowed_exts = set()
|
||||
for raw in request.query.getall('ext', []):
|
||||
for part in raw.split(','):
|
||||
cleaned = part.strip().lower().lstrip('.')
|
||||
if cleaned:
|
||||
allowed_exts.add(cleaned)
|
||||
ext_filter = allowed_exts
|
||||
|
||||
images = await asyncio.to_thread(
|
||||
_gallery_listing,
|
||||
folder_paths.get_output_directory(),
|
||||
request.query.get('refresh') == '1',
|
||||
query,
|
||||
ext_filter,
|
||||
)
|
||||
total = len(images)
|
||||
start = (page - 1) * limit
|
||||
return web.json_response({"images": images[start:start + limit], "total": total,
|
||||
@@ -84,7 +117,7 @@ async def api_get_gallery_images(request):
|
||||
|
||||
async def api_output_thumbnail(request):
|
||||
"""GET /anomalous/output_thumbnail?filename&subfolder - the gallery grid's 512 px WebP of an
|
||||
output image (cached like model cards); the original stays at ComfyUI's /view."""
|
||||
output image/video (cached like model cards); the original stays at ComfyUI's /view."""
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
try:
|
||||
filename = require_filename(request.query.get('filename'))
|
||||
@@ -94,8 +127,13 @@ async def api_output_thumbnail(request):
|
||||
if not os.path.isfile(file_path):
|
||||
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
||||
served = file_path
|
||||
if os.path.splitext(file_path)[1].lower() in CARD_THUMBNAIL_STATIC_EXTENSIONS:
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
if ext in CARD_THUMBNAIL_STATIC_EXTENSIONS:
|
||||
served = await asyncio.to_thread(_build_card_thumbnail, file_path)
|
||||
elif ext in CARD_POSTER_VIDEO_EXTENSIONS:
|
||||
poster = await asyncio.to_thread(_build_video_poster, file_path)
|
||||
if poster:
|
||||
served = poster
|
||||
# Revalidate (cheap 304s): an output file can be replaced under the same name.
|
||||
return web.FileResponse(served, headers={'Cache-Control': 'no-cache'})
|
||||
|
||||
|
||||
+3
-2
@@ -26,7 +26,7 @@ from .path_utils import require_filename, resolve_folder_subdir, resolve_within
|
||||
CARD_THUMBNAIL_EDGE = 512
|
||||
CARD_THUMBNAIL_CACHE_LIMIT = 256 * 1024 * 1024
|
||||
CARD_THUMBNAIL_STATIC_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.avif'}
|
||||
CARD_POSTER_VIDEO_EXTENSIONS = {'.mp4', '.webm', '.mov'}
|
||||
CARD_POSTER_VIDEO_EXTENSIONS = {'.mp4', '.webm', '.mov', '.avi', '.mkv'}
|
||||
CARD_WARM_QUEUE_LIMIT = 512
|
||||
_thumbnail_cleanup_lock = threading.Lock()
|
||||
_thumbnail_generation_slots = threading.BoundedSemaphore(2)
|
||||
@@ -302,7 +302,8 @@ async def api_serve_image(request):
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.mov': 'video/quicktime',
|
||||
'.avi': 'video/x-msvideo'
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.mkv': 'video/x-matroska'
|
||||
}
|
||||
content_type = content_types.get(ext)
|
||||
if content_type is None:
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* gallery_filter.js
|
||||
* Source of truth for active/hidden output formats in the Gallery.
|
||||
* Persists extension preferences to localStorage ('anomalous_gallery_extensions').
|
||||
*/
|
||||
|
||||
export const GALLERY_IMAGE_EXTENSIONS = ['png', 'webp', 'jpg', 'jpeg', 'gif'];
|
||||
export const GALLERY_VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'mkv'];
|
||||
export const ALL_GALLERY_EXTENSIONS = [...GALLERY_IMAGE_EXTENSIONS, ...GALLERY_VIDEO_EXTENSIONS];
|
||||
|
||||
const STORAGE_KEY = 'anomalous_gallery_extensions';
|
||||
|
||||
function cleanExt(ext) {
|
||||
return String(ext || '').trim().toLowerCase().replace(/^\.+/, '');
|
||||
}
|
||||
|
||||
/** Returns the Set of currently active extensions (lowercase without dot). */
|
||||
export function getActiveGalleryExtensions() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
return new Set(parsed.map(cleanExt).filter(e => ALL_GALLERY_EXTENSIONS.includes(e)));
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return new Set(ALL_GALLERY_EXTENSIONS);
|
||||
}
|
||||
|
||||
/** Saves the active extensions to localStorage. */
|
||||
export function setActiveGalleryExtensions(extensions) {
|
||||
const arr = Array.from(extensions || []).map(cleanExt).filter(e => ALL_GALLERY_EXTENSIONS.includes(e));
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(arr));
|
||||
return new Set(arr);
|
||||
}
|
||||
|
||||
/** Toggles a single extension on/off. */
|
||||
export function toggleGalleryExtension(ext) {
|
||||
const cleaned = cleanExt(ext);
|
||||
const active = getActiveGalleryExtensions();
|
||||
if (active.has(cleaned)) {
|
||||
active.delete(cleaned);
|
||||
} else {
|
||||
active.add(cleaned);
|
||||
}
|
||||
setActiveGalleryExtensions(active);
|
||||
return active;
|
||||
}
|
||||
|
||||
/** Sets all extensions in a group (all, images, videos) to enabled or disabled. */
|
||||
export function setGalleryGroup(group, enabled) {
|
||||
const active = getActiveGalleryExtensions();
|
||||
let targets = [];
|
||||
if (group === 'all') {
|
||||
targets = ALL_GALLERY_EXTENSIONS;
|
||||
} else if (group === 'images') {
|
||||
targets = GALLERY_IMAGE_EXTENSIONS;
|
||||
} else if (group === 'videos') {
|
||||
targets = GALLERY_VIDEO_EXTENSIONS;
|
||||
}
|
||||
|
||||
for (const ext of targets) {
|
||||
if (enabled) active.add(ext);
|
||||
else active.delete(ext);
|
||||
}
|
||||
setActiveGalleryExtensions(active);
|
||||
return active;
|
||||
}
|
||||
|
||||
/** True if every supported extension is active. */
|
||||
export function isAllGalleryExtensionsActive() {
|
||||
const active = getActiveGalleryExtensions();
|
||||
return ALL_GALLERY_EXTENSIONS.every(ext => active.has(ext));
|
||||
}
|
||||
|
||||
/** Appends active extensions to URLSearchParams. If all are active, omits to query everything. */
|
||||
export function appendExtensionParams(params) {
|
||||
const active = getActiveGalleryExtensions();
|
||||
// If not all are active, append each active extension
|
||||
if (active.size < ALL_GALLERY_EXTENSIONS.length) {
|
||||
if (active.size === 0) {
|
||||
params.set('ext', 'none');
|
||||
} else {
|
||||
active.forEach(ext => params.append('ext', ext));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,6 +556,14 @@ export const i18n = {
|
||||
gallerySearchCount: '找到 {count} 张',
|
||||
gallerySearchEmpty: '没有找到匹配的图片',
|
||||
gallerySearching: '正在搜索…',
|
||||
galleryFilterAll: '全部',
|
||||
galleryFilterImages: '图片',
|
||||
galleryFilterVideos: '视频',
|
||||
galleryExtensionToggle: '切换格式过滤',
|
||||
galleryExtensionsTitle: '画廊显示格式',
|
||||
galleryExtensionsHelp: '设置在画廊中显示或隐藏的文件格式(图片、视频)',
|
||||
galleryViewVideo: '查看视频',
|
||||
settingsGallery: '画廊设置',
|
||||
galleryClose: '关闭图库',
|
||||
galleryHistoryTitle: '历史生成图库: {name}',
|
||||
galleryScanning: '加载中,正在扫描图片元数据...',
|
||||
@@ -2454,6 +2462,14 @@ export const i18n = {
|
||||
gallerySearchCount: '{count} found',
|
||||
gallerySearchEmpty: 'No matching images',
|
||||
gallerySearching: 'Searching…',
|
||||
galleryFilterAll: 'All',
|
||||
galleryFilterImages: 'Images',
|
||||
galleryFilterVideos: 'Videos',
|
||||
galleryExtensionToggle: 'Toggle format filter',
|
||||
galleryExtensionsTitle: 'Gallery File Formats',
|
||||
galleryExtensionsHelp: 'Configure which output file formats (images, videos) to display or hide in Gallery',
|
||||
galleryViewVideo: 'View video',
|
||||
settingsGallery: 'Gallery',
|
||||
galleryClose: 'Close Gallery',
|
||||
galleryHistoryTitle: 'Generated History: {name}',
|
||||
galleryScanning: 'Loading, scanning metadata...',
|
||||
|
||||
+58
-19
@@ -10,6 +10,8 @@ import { showImageWorkbench } from './ui_gallery_detail.js';
|
||||
import { createGalleryCard } from './ui_gallery_card.js';
|
||||
import { loadKeptImages } from './image_keep.js';
|
||||
import { createSearchChips } from './ui_search_chips.js';
|
||||
import { createGalleryFilterBar } from './ui_gallery_filter.js';
|
||||
import { appendExtensionParams } from './gallery_filter.js';
|
||||
|
||||
const t = (key, params) => translate(key, params);
|
||||
|
||||
@@ -29,8 +31,11 @@ export async function refreshGalleryImages() {
|
||||
|
||||
|
||||
|
||||
/** Search blocks above the output gallery; the terms live on the browser as `gallerySearchTerms`. */
|
||||
/** Search blocks and format filters above the output gallery. */
|
||||
export function createGallerySearchBar(owner) {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'anomalous-gallery-header';
|
||||
|
||||
const search = createSearchChips({
|
||||
placeholder: t('gallerySearchPlaceholder'),
|
||||
help: t('gallerySearchHelp'),
|
||||
@@ -41,7 +46,14 @@ export function createGallerySearchBar(owner) {
|
||||
});
|
||||
search.element.classList.add('anomalous-gallery-search');
|
||||
owner.gallerySearchCount = search.countElement;
|
||||
return search.element;
|
||||
|
||||
const filterBar = createGalleryFilterBar(owner, () => {
|
||||
owner.loadGalleryImages(1, true, { refresh: false });
|
||||
});
|
||||
owner.galleryFilterBar = filterBar;
|
||||
|
||||
header.append(search.element, filterBar);
|
||||
return header;
|
||||
}
|
||||
|
||||
export async function loadGalleryImages(page = 1, reset = false, { refresh = reset } = {}) {
|
||||
@@ -60,6 +72,7 @@ export async function loadGalleryImages(page = 1, reset = false, { refresh = res
|
||||
const params = new URLSearchParams({ page: String(page), limit: '50' });
|
||||
if (refresh) params.set('refresh', '1');
|
||||
terms.forEach(term => params.append('term', term));
|
||||
appendExtensionParams(params);
|
||||
const res = await fetch(`/anomalous/gallery_images?${params}`);
|
||||
const data = await res.json();
|
||||
if (termsKey !== JSON.stringify(this.gallerySearchTerms || [])) {
|
||||
@@ -433,7 +446,7 @@ export function showGallerySelectMode(model) {
|
||||
|
||||
|
||||
|
||||
export function showGalleryViewer(src) {
|
||||
export function showGalleryViewer(src, isVideo = false) {
|
||||
let viewer = document.getElementById('anomalous-gallery-viewer');
|
||||
if (!viewer) {
|
||||
viewer = document.createElement('dialog');
|
||||
@@ -447,7 +460,15 @@ export function showGalleryViewer(src) {
|
||||
const img = document.createElement('img');
|
||||
img.id = 'anomalous-gallery-viewer-img';
|
||||
|
||||
const video = document.createElement('video');
|
||||
video.id = 'anomalous-gallery-viewer-video';
|
||||
video.controls = true;
|
||||
video.autoplay = true;
|
||||
video.loop = true;
|
||||
video.style.display = 'none';
|
||||
|
||||
viewer.appendChild(img);
|
||||
viewer.appendChild(video);
|
||||
viewer.appendChild(closeBtn);
|
||||
|
||||
let scale = 1;
|
||||
@@ -456,24 +477,28 @@ export function showGalleryViewer(src) {
|
||||
let isDragging = false;
|
||||
let startX = 0, startY = 0;
|
||||
|
||||
const resetImgTransform = () => {
|
||||
const resetTransform = () => {
|
||||
scale = 1; translateX = 0; translateY = 0;
|
||||
img.style.transform = `translate(0px, 0px) scale(1)`;
|
||||
img.style.cursor = 'grab';
|
||||
};
|
||||
|
||||
closeBtn.onclick = () => {
|
||||
viewer.close();
|
||||
resetImgTransform();
|
||||
};
|
||||
|
||||
viewer.onclick = (e) => {
|
||||
if (e.target === viewer) {
|
||||
viewer.close();
|
||||
resetImgTransform();
|
||||
const v = document.getElementById('anomalous-gallery-viewer-video');
|
||||
if (v) {
|
||||
v.pause();
|
||||
v.style.display = 'none';
|
||||
v.removeAttribute('src');
|
||||
}
|
||||
};
|
||||
|
||||
const closeViewer = () => {
|
||||
viewer.close();
|
||||
resetTransform();
|
||||
};
|
||||
|
||||
closeBtn.onclick = closeViewer;
|
||||
viewer.onclick = (e) => {
|
||||
if (e.target === viewer) closeViewer();
|
||||
};
|
||||
|
||||
viewer.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
const zoomFactor = 0.1;
|
||||
@@ -506,11 +531,25 @@ export function showGalleryViewer(src) {
|
||||
}
|
||||
|
||||
const img = document.getElementById('anomalous-gallery-viewer-img');
|
||||
img.src = src;
|
||||
const video = document.getElementById('anomalous-gallery-viewer-video');
|
||||
const isVid = isVideo || /\.(mp4|webm|mov|avi|mkv)(\?|$)/i.test(src);
|
||||
|
||||
// Reset scale and translation when opening a new image
|
||||
img.style.transform = `translate(0px, 0px) scale(1)`;
|
||||
img.style.cursor = 'grab';
|
||||
if (isVid && video) {
|
||||
img.style.display = 'none';
|
||||
video.style.display = 'block';
|
||||
video.src = src;
|
||||
video.play().catch(() => {});
|
||||
} else {
|
||||
if (video) {
|
||||
video.pause();
|
||||
video.style.display = 'none';
|
||||
video.removeAttribute('src');
|
||||
}
|
||||
img.style.display = 'block';
|
||||
img.src = src;
|
||||
img.style.transform = `translate(0px, 0px) scale(1)`;
|
||||
img.style.cursor = 'grab';
|
||||
}
|
||||
|
||||
if (!viewer.open) viewer.showModal();
|
||||
}
|
||||
|
||||
@@ -122,10 +122,13 @@ export function createGalleryCard(owner, imgData, { showViewer }) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'anomalous-gallery-card';
|
||||
|
||||
const isVideo = /\.(mp4|webm|mov|avi|mkv)$/i.test(image.filename);
|
||||
if (isVideo) card.classList.add('is-video');
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = `/anomalous/output_thumbnail?${query}`; // drags and the viewer use the original
|
||||
img.loading = 'lazy';
|
||||
img.title = t('materialViewOriginal');
|
||||
img.title = isVideo ? t('galleryViewVideo') : t('materialViewOriginal');
|
||||
// The card is dragged, not the <img>: Chrome adds a dragged <img> as a file, and ComfyUI
|
||||
// would load that thumbnail (no workflow in it) instead of fetching the original's link.
|
||||
img.draggable = false;
|
||||
@@ -137,7 +140,7 @@ export function createGalleryCard(owner, imgData, { showViewer }) {
|
||||
// Chromium may not start dragging very large (hires) images without a small drag image.
|
||||
if (window.anomalousDragGhostImg) event.dataTransfer.setDragImage(window.anomalousDragGhostImg, 40, 40);
|
||||
});
|
||||
img.onclick = () => (owner.gallerySelectModel ? useAsCover(owner, image) : showViewer(url));
|
||||
img.onclick = () => (owner.gallerySelectModel ? useAsCover(owner, image) : showViewer(url, isVideo));
|
||||
|
||||
const details = button('anomalous-gallery-details', t('materialViewDetails'), () => {
|
||||
if (!owner.gallerySelectModel) openWorkbench(owner, image, url);
|
||||
@@ -148,6 +151,13 @@ export function createGalleryCard(owner, imgData, { showViewer }) {
|
||||
remove.innerHTML = DELETE_ICON; // static markup only
|
||||
|
||||
card.append(img, details, remove);
|
||||
if (isVideo) {
|
||||
const ext = image.filename.split('.').pop().toUpperCase();
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'anomalous-gallery-video-badge';
|
||||
badge.innerHTML = `<svg viewBox="0 0 24 24" width="10" height="10" fill="currentColor" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/></svg> ${ext}`;
|
||||
card.append(badge);
|
||||
}
|
||||
// Only PNG outputs carry the workflow that can be kept.
|
||||
if (/\.png$/i.test(image.filename)) card.append(keepButton(owner, image));
|
||||
return card;
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* ui_gallery_filter.js
|
||||
* UI components for gallery extension filtering: the quick filter bar
|
||||
* in the Gallery header, and the configuration section in Settings.
|
||||
*/
|
||||
|
||||
import { translate } from './locales.js';
|
||||
import {
|
||||
ALL_GALLERY_EXTENSIONS,
|
||||
GALLERY_IMAGE_EXTENSIONS,
|
||||
GALLERY_VIDEO_EXTENSIONS,
|
||||
getActiveGalleryExtensions,
|
||||
toggleGalleryExtension,
|
||||
setGalleryGroup,
|
||||
isAllGalleryExtensionsActive,
|
||||
} from './gallery_filter.js';
|
||||
|
||||
const t = (key, params) => translate(key, params);
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the filter bar rendered inside the Gallery header.
|
||||
* Displays quick group presets (All, Images, Videos) and format chips (PNG, WEBP, MP4, etc.).
|
||||
*/
|
||||
export function createGalleryFilterBar(owner, onFilterChange) {
|
||||
const bar = el('div', 'anomalous-gallery-filter-bar');
|
||||
|
||||
const updateChips = () => {
|
||||
const active = getActiveGalleryExtensions();
|
||||
const allActive = isAllGalleryExtensionsActive();
|
||||
|
||||
bar.querySelectorAll('[data-ext]').forEach(chip => {
|
||||
const ext = chip.getAttribute('data-ext');
|
||||
chip.classList.toggle('is-active', active.has(ext));
|
||||
chip.setAttribute('aria-pressed', String(active.has(ext)));
|
||||
});
|
||||
|
||||
const allChip = bar.querySelector('[data-group="all"]');
|
||||
if (allChip) {
|
||||
allChip.classList.toggle('is-active', allActive);
|
||||
allChip.setAttribute('aria-pressed', String(allActive));
|
||||
}
|
||||
|
||||
const imagesActive = GALLERY_IMAGE_EXTENSIONS.some(e => active.has(e));
|
||||
const imagesAll = GALLERY_IMAGE_EXTENSIONS.every(e => active.has(e));
|
||||
const imgChip = bar.querySelector('[data-group="images"]');
|
||||
if (imgChip) {
|
||||
imgChip.classList.toggle('is-active', imagesAll);
|
||||
imgChip.classList.toggle('is-partial', imagesActive && !imagesAll);
|
||||
}
|
||||
|
||||
const videosActive = GALLERY_VIDEO_EXTENSIONS.some(e => active.has(e));
|
||||
const videosAll = GALLERY_VIDEO_EXTENSIONS.every(e => active.has(e));
|
||||
const vidChip = bar.querySelector('[data-group="videos"]');
|
||||
if (vidChip) {
|
||||
vidChip.classList.toggle('is-active', videosAll);
|
||||
vidChip.classList.toggle('is-partial', videosActive && !videosAll);
|
||||
}
|
||||
};
|
||||
|
||||
const addGroupChip = (groupKey, labelKey) => {
|
||||
const chip = el('button', 'anomalous-gallery-filter-chip is-group', t(labelKey));
|
||||
chip.type = 'button';
|
||||
chip.setAttribute('data-group', groupKey);
|
||||
chip.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
const active = getActiveGalleryExtensions();
|
||||
let targets = [];
|
||||
if (groupKey === 'all') targets = ALL_GALLERY_EXTENSIONS;
|
||||
else if (groupKey === 'images') targets = GALLERY_IMAGE_EXTENSIONS;
|
||||
else if (groupKey === 'videos') targets = GALLERY_VIDEO_EXTENSIONS;
|
||||
|
||||
const allInGroupActive = targets.every(ext => active.has(ext));
|
||||
setGalleryGroup(groupKey, !allInGroupActive);
|
||||
updateChips();
|
||||
onFilterChange?.();
|
||||
};
|
||||
bar.appendChild(chip);
|
||||
};
|
||||
|
||||
// Preset group buttons
|
||||
addGroupChip('all', 'galleryFilterAll');
|
||||
addGroupChip('images', 'galleryFilterImages');
|
||||
addGroupChip('videos', 'galleryFilterVideos');
|
||||
|
||||
// Separator divider
|
||||
bar.appendChild(el('span', 'anomalous-gallery-filter-sep'));
|
||||
|
||||
// Format pills
|
||||
ALL_GALLERY_EXTENSIONS.forEach(ext => {
|
||||
const isVid = GALLERY_VIDEO_EXTENSIONS.includes(ext);
|
||||
const chip = el('button', `anomalous-gallery-filter-chip is-ext ${isVid ? 'is-video' : ''}`, ext.toUpperCase());
|
||||
chip.type = 'button';
|
||||
chip.setAttribute('data-ext', ext);
|
||||
chip.title = `${t('galleryExtensionToggle')}: .${ext}`;
|
||||
chip.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
toggleGalleryExtension(ext);
|
||||
updateChips();
|
||||
onFilterChange?.();
|
||||
};
|
||||
bar.appendChild(chip);
|
||||
});
|
||||
|
||||
updateChips();
|
||||
bar.refresh = updateChips;
|
||||
return bar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the extension configuration control for the Settings page.
|
||||
*/
|
||||
export function createGalleryExtensionSettings(owner, redraw) {
|
||||
const box = el('div', 'anomalous-gallery-settings-exts');
|
||||
const active = getActiveGalleryExtensions();
|
||||
|
||||
const chipsWrap = el('div', 'anomalous-gallery-settings-chips');
|
||||
ALL_GALLERY_EXTENSIONS.forEach(ext => {
|
||||
const isVid = GALLERY_VIDEO_EXTENSIONS.includes(ext);
|
||||
const btn = el('button', `anomalous-scan-segment-btn ${active.has(ext) ? 'is-active' : ''}`, `.${ext}`);
|
||||
btn.type = 'button';
|
||||
btn.onclick = () => {
|
||||
toggleGalleryExtension(ext);
|
||||
redraw();
|
||||
};
|
||||
chipsWrap.appendChild(btn);
|
||||
});
|
||||
|
||||
const actions = el('div', 'anomalous-gallery-settings-actions');
|
||||
const selectAllBtn = el('button', 'anomalous-scan-secondary anomalous-scan-small-btn', t('galleryFilterAll'));
|
||||
selectAllBtn.type = 'button';
|
||||
selectAllBtn.onclick = () => {
|
||||
setGalleryGroup('all', true);
|
||||
redraw();
|
||||
};
|
||||
|
||||
const imagesOnlyBtn = el('button', 'anomalous-scan-secondary anomalous-scan-small-btn', t('galleryFilterImages'));
|
||||
imagesOnlyBtn.type = 'button';
|
||||
imagesOnlyBtn.onclick = () => {
|
||||
setGalleryGroup('images', true);
|
||||
setGalleryGroup('videos', false);
|
||||
redraw();
|
||||
};
|
||||
|
||||
const videosOnlyBtn = el('button', 'anomalous-scan-secondary anomalous-scan-small-btn', t('galleryFilterVideos'));
|
||||
videosOnlyBtn.type = 'button';
|
||||
videosOnlyBtn.onclick = () => {
|
||||
setGalleryGroup('videos', true);
|
||||
setGalleryGroup('images', false);
|
||||
redraw();
|
||||
};
|
||||
|
||||
actions.append(selectAllBtn, imagesOnlyBtn, videosOnlyBtn);
|
||||
box.append(chipsWrap, actions);
|
||||
return box;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { copyDiagnostics } from './feedback.js';
|
||||
import { renderFolderPage } from './ui_folder_manager.js';
|
||||
import { openFeedbackDialog } from './ui_feedback_dialog.js';
|
||||
import { checkOnOpen, setCheckOnOpen } from './ui_doctor_banner.js';
|
||||
import { createGalleryExtensionSettings } from './ui_gallery_filter.js';
|
||||
|
||||
const t = (key, params) => translate(key, params);
|
||||
// The full written guide: the README, at its Chinese half for Chinese.
|
||||
@@ -178,6 +179,12 @@ function cardsGroup(owner, redraw) {
|
||||
);
|
||||
}
|
||||
|
||||
function galleryGroup(owner, redraw) {
|
||||
return group('settingsGallery',
|
||||
row('galleryExtensionsTitle', 'galleryExtensionsHelp', createGalleryExtensionSettings(owner, redraw)),
|
||||
);
|
||||
}
|
||||
|
||||
function foldersGroup(owner) {
|
||||
return group('settingsFolders',
|
||||
row('sidebarManageFolders', 'settingsFoldersHelp',
|
||||
@@ -253,6 +260,7 @@ function render(owner) {
|
||||
el('h1', 'anomalous-scan-title', t('sidebarSettings')),
|
||||
appearanceGroup(owner, redraw),
|
||||
cardsGroup(owner, redraw),
|
||||
galleryGroup(owner, redraw),
|
||||
foldersGroup(owner),
|
||||
workflowGroup(owner),
|
||||
openingGroup(owner, redraw),
|
||||
|
||||
@@ -7,6 +7,104 @@
|
||||
background: var(--bg-color, var(--amb-bg-card));
|
||||
}
|
||||
|
||||
.anomalous-gallery-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: var(--bg-color, var(--amb-bg-card));
|
||||
border-bottom: 1px solid var(--amb-border-subtle);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.anomalous-gallery-filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.anomalous-gallery-filter-sep {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: var(--amb-border-subtle);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.anomalous-gallery-filter-chip {
|
||||
background: var(--amb-bg-page);
|
||||
color: var(--amb-text-muted);
|
||||
border: 1px solid var(--amb-border-subtle);
|
||||
border-radius: 9999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
cursor: pointer;
|
||||
line-height: 1.2;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.anomalous-gallery-filter-chip:hover {
|
||||
color: var(--amb-text-main);
|
||||
border-color: var(--amb-border-strong);
|
||||
}
|
||||
|
||||
.anomalous-gallery-filter-chip.is-active {
|
||||
background: var(--amb-accent-soft, rgba(64, 128, 255, 0.15));
|
||||
border-color: var(--amb-accent, #3b82f6);
|
||||
color: var(--amb-text-main);
|
||||
}
|
||||
|
||||
.anomalous-gallery-filter-chip.is-video.is-active {
|
||||
border-color: #8b5cf6;
|
||||
background: rgba(139, 92, 246, 0.18);
|
||||
}
|
||||
|
||||
.anomalous-gallery-filter-chip.is-group.is-partial {
|
||||
border-color: var(--amb-border-strong);
|
||||
color: var(--amb-text-soft);
|
||||
background: var(--amb-bg-card-hover);
|
||||
}
|
||||
|
||||
.anomalous-gallery-video-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 2;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.anomalous-gallery-settings-exts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.anomalous-gallery-settings-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.anomalous-gallery-settings-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.anomalous-gallery-grid {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
@@ -331,7 +429,8 @@ dialog.anomalous-gallery-viewer[open] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.anomalous-gallery-viewer img {
|
||||
.anomalous-gallery-viewer img,
|
||||
.anomalous-gallery-viewer video {
|
||||
max-width: 95%;
|
||||
max-height: 95%;
|
||||
object-fit: contain;
|
||||
|
||||
Reference in New Issue
Block a user