feat(ui): migrate saved-image viewer to shared window controls
This commit is contained in:
+5
-1
@@ -93,7 +93,11 @@ so user settings, saved workflows and selections are not written to the live
|
||||
server. It does not execute a workflow or download models.
|
||||
|
||||
Checks cover Classic/Nodes 2.0/live switching, theme changes, workflow save/reload
|
||||
and removal. Screenshots/traces appear in
|
||||
and removal for the loader. Save Image additionally checks real image output,
|
||||
window close/reopen through the node control across renderer switches, theme
|
||||
changes, exclusion of the launcher from serialized values and owner removal.
|
||||
Its `/view` requests also use disposable fixture output rather than live files.
|
||||
Screenshots/traces appear in
|
||||
`tools/testing/test-results/comfyui/`. This opt-in smoke suite is separate from
|
||||
the required fixture CI job; fully provisioned, pinned ComfyUI CI and testing
|
||||
alongside other node packs remain follow-up work in issue #81.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
@@ -315,6 +315,148 @@ test('real image save output opens a preview, closes, and reuses the viewer on e
|
||||
await expect(viewer.locator('.kiko-viewer-container')).toBeHidden();
|
||||
});
|
||||
|
||||
async function savedImageResult(page, request, count = 1) {
|
||||
const id = await createNode(page, 'KikoSaveImage', { format: 'PNG', quality: 90 });
|
||||
const response = await request.post('/__test__/execute/KikoSaveImage', { data: { filename_prefix: 'Window test' } });
|
||||
await expect(response).toBeOK();
|
||||
const message = (await response.json()).ui;
|
||||
message.kiko_enhanced = Array.from({ length: count }, () => ({ ...message.kiko_enhanced[0] }));
|
||||
await page.evaluate(({ id, message }) => kikoHost.nodes.get(String(id)).onExecuted(message), { id, message });
|
||||
return { id, message };
|
||||
}
|
||||
|
||||
test('shared saved-image window follows themes and supports keyboard close and reopen', async ({ page, request }) => {
|
||||
await openHost(page);
|
||||
const { id } = await savedImageResult(page, request);
|
||||
const viewer = page.getByRole('dialog', { name: 'Saved images', exact: true });
|
||||
await expect(viewer).toBeVisible();
|
||||
await page.evaluate(() => document.body.style.setProperty('--bg-color', '#fafafa'));
|
||||
await expect(viewer).toHaveAttribute('data-ca-theme', 'light');
|
||||
await viewer.getByTitle('Minimize', { exact: true }).click();
|
||||
await expect(viewer.locator('.kiko-viewer-container')).toBeHidden();
|
||||
await viewer.getByTitle('Restore', { exact: true }).click();
|
||||
await expect(viewer.locator('.kiko-viewer-container')).toBeVisible();
|
||||
await viewer.getByTitle('Close', { exact: true }).focus();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer).toBeHidden();
|
||||
await page.locator(`[data-node="KikoSaveImage"]`).getByRole('button', { name: 'Show images', exact: true }).click();
|
||||
await expect(viewer).toBeVisible();
|
||||
expect(await page.evaluate(id => kikoHost.nodes.get(String(id)).kikoViewer.isConnected, id)).toBe(true);
|
||||
});
|
||||
|
||||
test('saved-image ownership transfers safely and deletion cleans up only the owner window', async ({ page, request }) => {
|
||||
await openHost(page);
|
||||
const first = await savedImageResult(page, request);
|
||||
const second = await savedImageResult(page, request);
|
||||
expect(await page.evaluate(id => Boolean(kikoHost.nodes.get(String(id)).kikoViewer), second.id)).toBe(true);
|
||||
await page.evaluate(id => kikoHost.removeNode(id), first.id);
|
||||
await expect(page.locator('kiko-image-viewer')).toBeVisible();
|
||||
await page.evaluate(id => kikoHost.removeNode(id), second.id);
|
||||
await expect(page.locator('kiko-image-viewer')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('saved-image window moves and resizes by keyboard and remains inside a shrinking viewport', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await openHost(page);
|
||||
await savedImageResult(page, request);
|
||||
const viewer = page.locator('kiko-image-viewer');
|
||||
const move = viewer.getByRole('button', { name: 'Move image viewer with arrow keys', exact: true });
|
||||
await move.focus();
|
||||
const before = await viewer.boundingBox();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
expect((await viewer.boundingBox()).x).toBeLessThan(before.x);
|
||||
const resize = viewer.getByRole('button', { name: 'Resize image viewer with arrow keys', exact: true });
|
||||
await resize.focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
expect((await viewer.boundingBox()).width).toBeGreaterThan(before.width);
|
||||
const imageBefore = await viewer.locator('img').boundingBox();
|
||||
await page.keyboard.press('ArrowDown');
|
||||
expect((await viewer.locator('img').boundingBox()).height).toBeGreaterThan(imageBefore.height);
|
||||
const info = await viewer.locator('.kiko-image-info').boundingBox();
|
||||
const content = await viewer.locator('.kiko-viewer-container').boundingBox();
|
||||
expect(info.y + info.height).toBeLessThanOrEqual(content.y + content.height);
|
||||
await page.setViewportSize({ width: 360, height: 500 });
|
||||
await expect.poll(async () => {
|
||||
const rect = await viewer.boundingBox();
|
||||
return rect.x >= 0 && rect.y >= 0 && rect.x + rect.width <= 361 && rect.y + rect.height <= 501;
|
||||
}).toBe(true);
|
||||
});
|
||||
|
||||
test('saved-image batch selection downloads and resets for new execution results', async ({ page, request }) => {
|
||||
await openHost(page);
|
||||
const { id, message } = await savedImageResult(page, request, 2);
|
||||
const viewer = page.locator('kiko-image-viewer');
|
||||
const choices = viewer.getByRole('checkbox');
|
||||
await expect(choices).toHaveCount(2);
|
||||
await choices.first().check();
|
||||
await expect(viewer.locator('.selected-count')).toHaveText('1');
|
||||
await viewer.getByRole('button', { name: 'Select all', exact: true }).click();
|
||||
await expect(choices.nth(1)).toBeChecked();
|
||||
const download = page.waitForEvent('download');
|
||||
await viewer.getByRole('button', { name: /Download image 1:/ }).click();
|
||||
expect((await download).suggestedFilename()).toBe(message.kiko_enhanced[0].filename);
|
||||
await page.evaluate(({ id, message }) => kikoHost.nodes.get(String(id)).onExecuted(message), { id, message: { kiko_enhanced: [message.kiko_enhanced[0]] } });
|
||||
expect(await viewer.evaluate(element => element.selectedImages.size)).toBe(0);
|
||||
await expect(viewer.getByRole('checkbox')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('saved-image window pointer handles drag resize and cancel without losing reachability', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await openHost(page);
|
||||
await savedImageResult(page, request);
|
||||
const viewer = page.locator('kiko-image-viewer');
|
||||
const before = await viewer.boundingBox();
|
||||
const handle = await viewer.locator('[data-window-move]').boundingBox();
|
||||
await page.mouse.move(handle.x + 20, handle.y + 15);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(handle.x - 100, handle.y + 35, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
const moved = await viewer.boundingBox();
|
||||
expect(moved.x).toBeLessThan(before.x);
|
||||
await expect(viewer).not.toHaveClass(/ca-dragging/);
|
||||
const resize = await viewer.locator('[data-window-resize]').boundingBox();
|
||||
await page.mouse.move(resize.x + 12, resize.y + 12);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(resize.x + 60, resize.y + 15, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
expect((await viewer.boundingBox()).width).toBeGreaterThan(moved.width);
|
||||
const next = await viewer.locator('[data-window-move]').boundingBox();
|
||||
await page.mouse.move(next.x + 20, next.y + 15);
|
||||
await page.mouse.down();
|
||||
await viewer.dispatchEvent('pointercancel');
|
||||
await expect(viewer).not.toHaveClass(/ca-dragging/);
|
||||
await page.mouse.up();
|
||||
});
|
||||
|
||||
test('delayed selected-image opens stop on close replacement and owner removal', async ({ page, request }) => {
|
||||
await openHost(page);
|
||||
const { id, message } = await savedImageResult(page, request, 2);
|
||||
await page.clock.install();
|
||||
await page.evaluate(() => {
|
||||
window.openedImages = [];
|
||||
window.open = url => { window.openedImages.push(url); return {}; };
|
||||
const viewer = document.querySelector('kiko-image-viewer');
|
||||
viewer.handleBulkAction('select-all');
|
||||
viewer.handleBulkAction('open-all');
|
||||
});
|
||||
await page.clock.runFor(200);
|
||||
expect(await page.evaluate(() => window.openedImages.length)).toBe(2);
|
||||
for (const action of ['close', 'replace', 'remove']) {
|
||||
await page.evaluate(({ id, message, action }) => {
|
||||
const node = kikoHost.nodes.get(String(id));
|
||||
node.onExecuted(message);
|
||||
const viewer = document.querySelector('kiko-image-viewer');
|
||||
viewer.handleBulkAction('select-all');
|
||||
viewer.handleBulkAction('open-all');
|
||||
if (action === 'close') viewer.close();
|
||||
if (action === 'replace') node.onExecuted(message);
|
||||
if (action === 'remove') kikoHost.removeNode(id);
|
||||
}, { id, message, action });
|
||||
await page.clock.runFor(500);
|
||||
expect(await page.evaluate(() => window.openedImages.length)).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
test('Display Any renders real multiline output and restores serialized values without duplicate widgets', async ({ page, request }) => {
|
||||
await openHost(page);
|
||||
const id = await createNode(page, 'DisplayAny');
|
||||
|
||||
@@ -17,7 +17,7 @@ test.beforeEach(async ({ page, context, request, baseURL }, testInfo) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const path = url.pathname.replace(/^\/api\//, '/');
|
||||
if (path.startsWith('/kiko_local_image_loader/')) {
|
||||
if (path.startsWith('/kiko_local_image_loader/') || path === '/view') {
|
||||
const response = await route.fetch({ url: `${fixture}${path}${url.search}` });
|
||||
return route.fulfill({ response });
|
||||
}
|
||||
@@ -47,6 +47,54 @@ test.beforeEach(async ({ page, context, request, baseURL }, testInfo) => {
|
||||
});
|
||||
});
|
||||
|
||||
test('saved-image window survives real renderer switching and is removed with its owner', async ({ page, request }, testInfo) => {
|
||||
const result = await request.post(`${fixture}/__test__/execute/KikoSaveImage`, { data: { filename_prefix: 'Real host viewer' } });
|
||||
await expect(result).toBeOK();
|
||||
const message = (await result.json()).ui;
|
||||
const id = await page.evaluate(async message => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode('KikoSaveImage');
|
||||
app.graph.add(node);
|
||||
node.onExecuted(message);
|
||||
app.canvas.centerOnNode(node);
|
||||
return node.id;
|
||||
}, message);
|
||||
const viewer = page.getByRole('dialog', { name: 'Saved images', exact: true });
|
||||
await expect(viewer).toBeVisible();
|
||||
await expect.poll(() => viewer.locator('img').evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true);
|
||||
for (const renderer of [false, true, false]) {
|
||||
await page.evaluate(async renderer => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
|
||||
}, renderer);
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
await expect(viewer).toHaveCount(1);
|
||||
await viewer.getByTitle('Close', { exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Show images', exact: true }).click();
|
||||
await expect(viewer).toBeVisible();
|
||||
expect(await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return app.graph.serialize().nodes.find(node => String(node.id) === String(id)).widgets_values.includes('Show images');
|
||||
}, id)).toBe(false);
|
||||
}
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.evaluate(async theme => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.ColorPalette', theme);
|
||||
}, theme);
|
||||
await expect(viewer).toHaveAttribute('data-ca-theme', theme);
|
||||
const info = await viewer.locator('.kiko-image-info').boundingBox();
|
||||
const content = await viewer.locator('.kiko-viewer-container').boundingBox();
|
||||
expect(info.y + info.height).toBeLessThanOrEqual(content.y + content.height);
|
||||
await page.screenshot({ path: testInfo.outputPath(`viewer-${theme}.png`) });
|
||||
}
|
||||
await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph.remove(app.graph.getNodeById(id));
|
||||
}, id);
|
||||
await expect(viewer).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('actual host preserves the widget through renderer changes, theme changes and save/reload', async ({ page, request }, testInfo) => {
|
||||
await expect(await request.post(`${fixture}/__test__/reset`, { data: { extra_images: 52 } })).toBeOK();
|
||||
await page.evaluate(async () => {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fitWindowRect } from '../../web/ui/floating-window.js';
|
||||
|
||||
test('window bounds keep its controls reachable after moving outside the viewport', () => {
|
||||
const rect = fitWindowRect({ x: -100, y: 900, width: 420, height: 400 }, { width: 1000, height: 700 });
|
||||
assert.equal(rect.x, 8);
|
||||
assert.equal(rect.y + rect.height, 692);
|
||||
});
|
||||
|
||||
test('window fitting respects minimum sizes and small viewports', () => {
|
||||
const small = fitWindowRect({ x: 0, y: 0, width: 10, height: 10 }, { width: 1000, height: 700 });
|
||||
assert.equal(small.width, 280);
|
||||
assert.equal(small.height, 200);
|
||||
const narrow = fitWindowRect({ x: 800, y: 600, width: 900, height: 800 }, { width: 260, height: 180 });
|
||||
assert.equal(narrow.width, 244);
|
||||
assert.equal(narrow.height, 164);
|
||||
assert.equal(narrow.x, 8);
|
||||
assert.equal(narrow.y, 8);
|
||||
});
|
||||
@@ -48,10 +48,10 @@
|
||||
"statements": 77
|
||||
},
|
||||
"kiko_save_image_ui.js": {
|
||||
"lines": 66,
|
||||
"branches": 49,
|
||||
"functions": 57,
|
||||
"statements": 66
|
||||
"lines": 84,
|
||||
"branches": 74,
|
||||
"functions": 78,
|
||||
"statements": 84
|
||||
},
|
||||
"kiko_workflow_timer_ui.js": {
|
||||
"lines": 44,
|
||||
@@ -73,10 +73,16 @@
|
||||
},
|
||||
"ui/core.js": {
|
||||
"lines": 98,
|
||||
"branches": 91,
|
||||
"branches": 92,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"ui/floating-window.js": {
|
||||
"lines": 93,
|
||||
"branches": 80,
|
||||
"functions": 98,
|
||||
"statements": 93
|
||||
},
|
||||
"views/image-browser.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
|
||||
+252
-919
File diff suppressed because it is too large
Load Diff
@@ -24,3 +24,20 @@ of `ui/`; add abstractions only as demonstrated by the next consumer.
|
||||
Architecture and control organization were inspired by
|
||||
[Pixaroma](https://github.com/pixaroma/ComfyUI-Pixaroma). This implementation and
|
||||
styling are original; none of his toolkit code or assets are vendored.
|
||||
|
||||
## Floating windows
|
||||
|
||||
`floating-window.js` owns pointer capture, keyboard movement/resizing, viewport
|
||||
bounds and observer cleanup. `window.css` consumes the same control tokens as the
|
||||
loader. Kiko Save Image is its first consumer; the controller has no node, API or
|
||||
gallery dependencies.
|
||||
|
||||
Drag the title or bottom-right resize handle. Focus either handle and use arrow
|
||||
keys (Shift for larger steps). Escape closes the focused window. The viewer stays
|
||||
nonmodal so the canvas remains usable. Window position and size survive new
|
||||
results and minimize/reopen within the session; they are not written into a
|
||||
workflow or persisted across browser reloads.
|
||||
|
||||
The owning node controls removal. Another node may take over the shared viewer,
|
||||
but deleting the previous owner must not remove it. Closing or deleting a viewer,
|
||||
or replacing its results, cancels delayed bulk-open actions.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
export function fitWindowRect(rect, viewport) {
|
||||
const width = Math.min(Math.max(280, rect.width), Math.max(1, viewport.width - 16));
|
||||
const height = Math.min(Math.max(200, rect.height), Math.max(1, viewport.height - 16));
|
||||
return {
|
||||
width, height,
|
||||
x: Math.max(8, Math.min(rect.x, viewport.width - width - 8)),
|
||||
y: Math.max(8, Math.min(rect.y, viewport.height - height - 8)),
|
||||
};
|
||||
}
|
||||
|
||||
export function mountFloatingWindow(element, scope, onClose) {
|
||||
const stylesheet = new URL('./window.css', import.meta.url).href;
|
||||
if (![...document.querySelectorAll('link[rel="stylesheet"]')].some(link => link.href === stylesheet)) {
|
||||
const link = document.createElement('link');
|
||||
Object.assign(link, { rel: 'stylesheet', href: stylesheet });
|
||||
document.head.append(link);
|
||||
}
|
||||
let gesture;
|
||||
const viewport = () => ({ width: innerWidth, height: innerHeight });
|
||||
const move = (x, y) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const next = fitWindowRect({ x, y, width: rect.width, height: rect.height }, viewport());
|
||||
element.style.left = `${next.x}px`;
|
||||
element.style.top = `${next.y}px`;
|
||||
element.style.right = 'auto';
|
||||
};
|
||||
const resize = (width, height) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const next = fitWindowRect({ x: rect.x, y: rect.y, width, height }, viewport());
|
||||
element.style.setProperty('--ca-window-width', `${next.width}px`);
|
||||
element.style.setProperty('--ca-window-height', `${next.height}px`);
|
||||
move(next.x, next.y);
|
||||
};
|
||||
const end = () => {
|
||||
if (gesture && element.hasPointerCapture(gesture.id)) element.releasePointerCapture(gesture.id);
|
||||
gesture = null;
|
||||
element.classList.remove('ca-dragging');
|
||||
};
|
||||
scope.listen(element, 'pointerdown', event => {
|
||||
const handle = event.target.closest('[data-window-move], [data-window-resize]');
|
||||
if (!handle || event.button !== 0) return;
|
||||
const rect = element.getBoundingClientRect();
|
||||
gesture = { id: event.pointerId, kind: handle.hasAttribute('data-window-resize') ? 'resize' : 'move', rect, x: event.clientX, y: event.clientY };
|
||||
handle.focus();
|
||||
element.setPointerCapture(event.pointerId);
|
||||
element.classList.add('ca-dragging');
|
||||
event.preventDefault();
|
||||
});
|
||||
scope.listen(element, 'pointermove', event => {
|
||||
if (!gesture || event.pointerId !== gesture.id) return;
|
||||
const dx = event.clientX - gesture.x;
|
||||
const dy = event.clientY - gesture.y;
|
||||
if (gesture.kind === 'resize') resize(gesture.rect.width + dx, gesture.rect.height + dy);
|
||||
else move(gesture.rect.x + dx, gesture.rect.y + dy);
|
||||
});
|
||||
for (const event of ['pointerup', 'pointercancel', 'lostpointercapture']) scope.listen(element, event, end);
|
||||
scope.listen(element, 'keydown', event => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); onClose(); return; }
|
||||
const handle = event.target.closest('[data-window-move], [data-window-resize]');
|
||||
if (!handle || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const delta = event.shiftKey ? 40 : 16;
|
||||
const dx = event.key === 'ArrowLeft' ? -delta : event.key === 'ArrowRight' ? delta : 0;
|
||||
const dy = event.key === 'ArrowUp' ? -delta : event.key === 'ArrowDown' ? delta : 0;
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (handle.hasAttribute('data-window-resize')) resize(rect.width + dx, rect.height + dy);
|
||||
else move(rect.x + dx, rect.y + dy);
|
||||
});
|
||||
const keepReachable = () => {
|
||||
if (element.hidden) return;
|
||||
const rect = element.getBoundingClientRect();
|
||||
move(rect.x, rect.y);
|
||||
};
|
||||
scope.listen(window, 'resize', keepReachable);
|
||||
const observer = new ResizeObserver(keepReachable);
|
||||
observer.observe(element);
|
||||
scope.cleanup(() => { observer.disconnect(); end(); });
|
||||
return { fit: keepReachable };
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
.ca-floating-window {
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
inset-block-start: 100px;
|
||||
right: 20px;
|
||||
inline-size: min(var(--ca-window-width, 600px), calc(100dvw - 16px));
|
||||
block-size: min(var(--ca-window-height, 560px), calc(100dvh - 16px));
|
||||
min-inline-size: min(280px, calc(100dvw - 16px));
|
||||
min-block-size: min(200px, calc(100dvh - 16px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ca-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 28px #00000040;
|
||||
z-index: 10000;
|
||||
}
|
||||
.ca-floating-window[hidden], .ca-floating-window [hidden] { display: none; }
|
||||
.ca-floating-window.ca-minimized { block-size: auto; min-block-size: 0; }
|
||||
.ca-window-header { display: flex; align-items: center; gap: 8px; padding: 7px 10px; border-block-end: 1px solid var(--ca-line); flex-shrink: 0; }
|
||||
.ca-window-header .ca-window-handle { min-inline-size: 0; flex: 1; justify-content: start; border: 0; background: transparent; padding-inline: 0; overflow: hidden; text-overflow: ellipsis; cursor: grab; touch-action: none; -webkit-user-select: none; user-select: none; }
|
||||
.ca-floating-window.ca-dragging [data-window-move] { cursor: grabbing; }
|
||||
.ca-window-header .kiko-viewer-controls { display: flex; align-items: center; gap: 6px; }
|
||||
.ca-window-header button.kiko-control-btn { min-inline-size: 30px; padding-inline: 6px; }
|
||||
.ca-window-header .ca-window-brand { color: var(--ca-muted); font-size: 10px; }
|
||||
.ca-floating-window .ca-window-resize { position: absolute; inset-inline-end: 2px; inset-block-end: 2px; min-inline-size: 24px; min-block-size: 24px; border: 0; background: var(--ca-surface); color: var(--ca-muted); cursor: nwse-resize; touch-action: none; padding: 0; }
|
||||
.ca-floating-window .kiko-viewer-container { padding: 12px 12px 28px; overflow: auto; min-block-size: 0; flex: 1; }
|
||||
.ca-floating-window .kiko-image-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 224px), 1fr)); gap: 10px; align-items: start; }
|
||||
.ca-floating-window .kiko-image-item { min-inline-size: 0; border: 1px solid var(--ca-border); border-radius: 5px; background: var(--ca-field); padding: 6px; }
|
||||
.ca-floating-window .kiko-image-open { padding: 0; border: 0; background: var(--ca-field); inline-size: 100%; display: block; }
|
||||
.ca-floating-window .kiko-image-open img { display: block; inline-size: 100%; block-size: 230px; object-fit: contain; }
|
||||
.ca-floating-window.ca-single-image .kiko-viewer-container:not([hidden]) { display: flex; flex-direction: column; }
|
||||
.ca-floating-window.ca-single-image .kiko-image-grid { display: flex; flex: 1; min-block-size: 0; align-items: stretch; }
|
||||
.ca-floating-window.ca-single-image .kiko-image-item { display: flex; flex-direction: column; flex: 1; min-block-size: 0; }
|
||||
.ca-floating-window.ca-single-image .kiko-image-open { position: relative; flex: 1; min-block-size: 100px; overflow: hidden; }
|
||||
.ca-floating-window.ca-single-image .kiko-image-open img { position: absolute; inset: 0; block-size: 100%; }
|
||||
.ca-floating-window .kiko-image-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-block-start: 8px; flex-wrap: wrap; }
|
||||
.ca-floating-window .kiko-image-selector { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; font-size: 12px; min-block-size: 30px; }
|
||||
.ca-floating-window .kiko-image-selector input { inline-size: 18px; block-size: 18px; min-block-size: 18px; }
|
||||
.ca-floating-window .kiko-image-info { padding-block-start: 6px; font-size: 11px; }
|
||||
.ca-floating-window .kiko-image-filename { overflow-wrap: anywhere; }
|
||||
.ca-floating-window .kiko-image-stats { color: var(--ca-muted); margin-block-start: 3px; }
|
||||
.ca-floating-window .kiko-format-badge { display: inline-block; border: 1px solid var(--ca-border); border-radius: 3px; padding: 1px 4px; margin-inline-end: 3px; }
|
||||
.ca-floating-window .kiko-bulk-actions { border-block-start: 1px solid var(--ca-line); margin-block-start: 12px; padding-block-start: 10px; font-size: 12px; color: var(--ca-muted); }
|
||||
.ca-floating-window .kiko-bulk-buttons { display: flex; flex-wrap: wrap; gap: 7px; margin-block-start: 7px; }
|
||||
.ca-floating-window .kiko-bulk-buttons button { font-size: 12px; }
|
||||
.ca-floating-window .ca-window-status { color: var(--ca-muted); font-size: 11px; padding: 6px 30px 6px 12px; border-block-start: 1px solid var(--ca-line); flex-shrink: 0; }
|
||||
.ca-viewer-launcher { padding: 3px 0; }
|
||||
.ca-viewer-launcher button { inline-size: 100%; }
|
||||
@media (pointer: coarse) { .ca-floating-window .ca-window-header button, .ca-floating-window .ca-window-resize { min-inline-size: 40px; min-block-size: 40px; } }
|
||||
@media (forced-colors: active) { .ca-floating-window { box-shadow: none; border-color: CanvasText; } }
|
||||
Reference in New Issue
Block a user