Merge PR #21: videos in the gallery and a file-format filter
From cocorang and wkdtjs. The PR's own "v1.58.2" CHANGELOG section is left out (1.58.2 shipped other changes); the 1.59 entry and the follow-up fixes come in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -428,7 +428,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 (the file card with Show in folder in `model_location.js`), `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`
|
||||
|
||||
+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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,6 +629,14 @@ export const i18n = {
|
||||
gallerySearchCount: '找到 {count} 张',
|
||||
gallerySearchEmpty: '没有找到匹配的图片',
|
||||
gallerySearching: '正在搜索…',
|
||||
galleryFilterAll: '全部',
|
||||
galleryFilterImages: '图片',
|
||||
galleryFilterVideos: '视频',
|
||||
galleryExtensionToggle: '切换格式过滤',
|
||||
galleryExtensionsTitle: '画廊显示格式',
|
||||
galleryExtensionsHelp: '设置在画廊中显示或隐藏的文件格式(图片、视频)',
|
||||
galleryViewVideo: '查看视频',
|
||||
settingsGallery: '画廊设置',
|
||||
galleryClose: '关闭图库',
|
||||
galleryHistoryTitle: '历史生成图库: {name}',
|
||||
galleryScanning: '加载中,正在扫描图片元数据...',
|
||||
@@ -2777,6 +2785,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();
|
||||
}
|
||||
|
||||
@@ -124,10 +124,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;
|
||||
@@ -143,7 +146,7 @@ export function createGalleryCard(owner, imgData, { showViewer }) {
|
||||
setTimeout(() => owner.modal?.classList.add('anomalous-material-dragging'), 0);
|
||||
});
|
||||
card.addEventListener('dragend', () => owner.modal?.classList.remove('anomalous-material-dragging'));
|
||||
img.onclick = () => (owner.gallerySelectModel ? useAsCover(owner, image) : showViewer(url));
|
||||
img.onclick = () => (owner.gallerySelectModel ? useAsCover(owner, image) : showViewer(url, isVideo));
|
||||
card.addEventListener('contextmenu', (event) => {
|
||||
if (!owner.gallerySelectModel) openGalleryCardMenu(event, image, { owner, openDetails: () => openWorkbench(owner, image, url) });
|
||||
});
|
||||
@@ -157,6 +160,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;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { fetchDownloadSettings, hfMirrorOn, saveDownloadSettings } from './model
|
||||
import { saveApiKey } from './ui_scan_page.js';
|
||||
import { exportBackup, importBackup } from './ui_backup.js';
|
||||
import { followsSelection, setFollowsSelection } from './shell_open_rules.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.
|
||||
@@ -182,6 +183,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',
|
||||
@@ -361,6 +368,7 @@ function render(owner) {
|
||||
el('h1', 'anomalous-scan-title', t('sidebarSettings')),
|
||||
appearanceGroup(owner, redraw),
|
||||
cardsGroup(owner, redraw),
|
||||
galleryGroup(owner, redraw),
|
||||
foldersGroup(owner),
|
||||
workflowGroup(owner),
|
||||
downloadGroup(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;
|
||||
@@ -337,7 +435,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