Every viewer gets a button before the close button that switches between Centered (today's framed dialog) and Full screen (the whole browser window, edge to edge); it names the view it switches to. In full screen the details stay toggleable. System > Appearance gains a Viewer surface with "Open viewer in: Centered / Full screen" and the moved "Collapse viewer details by default"; the choice is a browser display preference (viewerOpenMode). The button changes only the current view and hides at 700px and below, where the viewer is already full-bleed. Recorded as D156.
204 lines
12 KiB
JavaScript
204 lines
12 KiB
JavaScript
// System › Appearance (#249 System S3 Task 8): the concept's "Browsing & display" rows (app.js 676–678) on the drafts
|
||
// of the one Save row, their stored values (Q3 mappings), the host timeout's 0–300 whole seconds and the D97 rows.
|
||
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const gallerySettings = require('../../web/js/gallery-settings.js');
|
||
|
||
const appearanceModule = import('../../web/js/workspace/system-appearance.mjs');
|
||
const stateModule = import('../../web/js/workspace/system-state.mjs');
|
||
const coreModule = import('../../web/js/shared/toolkit/core.js');
|
||
globalThis.GallerySettings = gallerySettings;
|
||
|
||
/** A minimal DOM whose elements count their live listeners (a listener's signal aborts it, as in a browser). */
|
||
function fakeDOM() {
|
||
const previous = global.document;
|
||
const elements = [];
|
||
class Element {
|
||
constructor(tag) {
|
||
this.tagName = tag.toUpperCase();
|
||
this.children = [];
|
||
this.attributes = {};
|
||
this.listeners = [];
|
||
this.dataset = {};
|
||
this.className = '';
|
||
this.id = '';
|
||
this.value = '';
|
||
this._text = '';
|
||
elements.push(this);
|
||
}
|
||
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
|
||
set textContent(text) { this.children = []; this._text = String(text); }
|
||
append(...nodes) {
|
||
for (const node of nodes) {
|
||
const child = typeof node === 'string' ? Object.assign(new Element('#text'), { _text: node }) : node;
|
||
child.parentNode = this;
|
||
this.children.push(child);
|
||
}
|
||
}
|
||
remove() { if (this.parentNode) this.parentNode.children = this.parentNode.children.filter(child => child !== this); this.parentNode = null; }
|
||
replaceChildren(...nodes) { this.children = []; this._text = ''; this.append(...nodes); }
|
||
setAttribute(name, value) { this.attributes[name] = String(value); }
|
||
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
|
||
removeAttribute(name) { delete this.attributes[name]; }
|
||
addEventListener(type, callback, options = {}) {
|
||
if (options.signal?.aborted) return;
|
||
const entry = { type, callback };
|
||
this.listeners.push(entry);
|
||
options.signal?.addEventListener('abort', () => { this.listeners = this.listeners.filter(item => item !== entry); });
|
||
}
|
||
removeEventListener(type, callback) { this.listeners = this.listeners.filter(item => item.type !== type || item.callback !== callback); }
|
||
dispatch(type) { for (const { callback } of this.listeners.filter(item => item.type === type)) callback({ type, target: this }); }
|
||
click() { this.dispatch('click'); }
|
||
}
|
||
global.document = {
|
||
createElement: tag => new Element(tag),
|
||
createTextNode: text => Object.assign(new Element('#text'), { _text: String(text) }),
|
||
};
|
||
const walk = (node, found = []) => { found.push(node); node.children.forEach(child => walk(child, found)); return found; };
|
||
return {
|
||
listenerCount: () => elements.reduce((sum, element) => sum + element.listeners.length, 0),
|
||
find: (root, predicate) => walk(root).find(predicate),
|
||
tags: node => node.children.map(child => child.tagName.toLowerCase()),
|
||
restore() { global.document = previous; },
|
||
};
|
||
}
|
||
|
||
const BROWSER = { workspaceLayout: 'grid', workspaceDensity: 'comfortable', autoLoadMetadata: true, cacheMetadata: true,
|
||
imageQuality: 'medium', videoAutoplay: false, videoMute: true, videoLoop: true, showFilePaths: true, showImageInfo: true,
|
||
sidebarCollapsedByDefault: false, viewerOpenMode: 'centered' };
|
||
|
||
async function mounted({ host = { webui_display_mode: 'newtab', result_timeout: 5 }, browser = BROWSER, hostFails = false } = {}) {
|
||
const { mountAppearance } = await appearanceModule;
|
||
const { createSystemDrafts } = await stateModule;
|
||
const { createScope } = await coreModule;
|
||
const dom = fakeDOM();
|
||
let hostValues = null;
|
||
const drafts = createSystemDrafts({
|
||
readBrowser: () => ({ ...browser }), readRuntime: () => ({}),
|
||
readHost: async () => { if (hostFails) throw new Error('offline'); return { values: hostValues, revision: 'sha256:one' }; },
|
||
writeBrowser: async () => ({ persisted: true }), writeHost: async () => ({ success: true, persisted: true }), writeRuntime: async () => ({}),
|
||
});
|
||
await drafts.load('browser');
|
||
const scope = createScope();
|
||
const [surface, viewer] = mountAppearance(null, { scope, drafts });
|
||
const byId = id => dom.find(surface, node => node.dataset?.setting === id) ?? dom.find(viewer, node => node.dataset?.setting === id);
|
||
return { dom, drafts, scope, surface, viewer, byId, loadHost: async () => { hostValues = host; await drafts.load('host'); },
|
||
fixHost: () => { hostFails = false; } };
|
||
}
|
||
|
||
test('the surface is the concept section with the concept rows in order, then the D97 rows', async () => {
|
||
const { APPEARANCE_ROWS } = await appearanceModule;
|
||
const { dom, surface, scope } = await mounted();
|
||
try {
|
||
assert.equal(surface.className, 'surface');
|
||
assert.equal(surface.children[0].textContent, 'Browsing & display');
|
||
assert.equal(surface.children[1].textContent, 'One set of defaults; toolbar changes apply to the current view.');
|
||
const rows = surface.children.slice(2);
|
||
const titles = rows.map(row => row.children[0].children[0].textContent);
|
||
assert.deepEqual(titles, ['Default layout', 'Thumbnail size', 'Continuous browsing', 'Load metadata on selection',
|
||
'Cache metadata in this browser', 'Image loading quality', 'Autoplay videos', 'Mute videos by default', 'Loop videos',
|
||
'Show file paths', 'Open from ComfyUI', 'Node search auto-hide (seconds)', 'Show image details on cards',
|
||
'Reset display defaults']);
|
||
const continuous = rows[2];
|
||
assert.deepEqual(dom.tags(continuous.children[0]), ['h3', 'p']);
|
||
assert.equal(continuous.children[1].className, 'chip blue');
|
||
assert.equal(continuous.children[1].textContent, 'Infinite scroll');
|
||
assert.equal(rows.at(-1).className, 'operation');
|
||
assert.equal(APPEARANCE_ROWS.filter(Array.isArray).length, 12);
|
||
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
|
||
});
|
||
|
||
// #450 (D156, amends D97): a "Viewer" surface after "Browsing & display" holds the viewer's two defaults.
|
||
test('the Viewer surface holds Open viewer in and the moved collapse row', async () => {
|
||
const { VIEWER_ROWS } = await appearanceModule;
|
||
const { dom, viewer, scope } = await mounted();
|
||
try {
|
||
assert.equal(viewer.className, 'surface');
|
||
assert.equal(viewer.children[0].textContent, 'Viewer');
|
||
assert.equal(viewer.children[1].textContent, 'How the image viewer opens; the button beside ✕ switches the current view.');
|
||
assert.deepEqual(viewer.children.slice(2).map(row => row.children[0].children[0].textContent), ['Open viewer in', 'Collapse viewer details by default']);
|
||
assert.deepEqual(VIEWER_ROWS.map(row => row[0]), ['viewer-mode', 'viewer-details']);
|
||
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
|
||
});
|
||
|
||
test('added rows reach their consumers: each control edits its stored key with the Q3 values', async () => {
|
||
const { dom, drafts, scope, byId, loadHost } = await mounted();
|
||
try {
|
||
// Host rows stay disabled until the server settings answered.
|
||
assert.equal(byId('open-mode').disabled, true);
|
||
assert.equal(byId('timeout').disabled, true);
|
||
await loadHost();
|
||
assert.equal(byId('open-mode').disabled, false);
|
||
assert.equal(byId('timeout').value, '5');
|
||
assert.deepEqual(byId('image-quality').children.map(option => [option.value, option.textContent]),
|
||
[['medium', 'Optimized'], ['high', 'Original'], ['low', 'Fast']]);
|
||
assert.deepEqual(byId('open-mode').children.map(option => [option.value, option.textContent]),
|
||
[['newtab', 'New tab'], ['popup', 'Popup window']]);
|
||
const edits = [['default-view', 'masonry'], ['density', 'large'], ['image-quality', 'low'], ['open-mode', 'popup']];
|
||
for (const [id, value] of edits) { byId(id).value = value; byId(id).dispatch('change'); }
|
||
for (const id of ['metadata-auto', 'metadata-cache', 'video-muted', 'video-loop', 'paths', 'card-details']) {
|
||
byId(id).checked = false; byId(id).dispatch('change');
|
||
}
|
||
for (const id of ['video-auto', 'viewer-details']) { byId(id).checked = true; byId(id).dispatch('change'); }
|
||
assert.deepEqual(byId('viewer-mode').children.map(option => [option.value, option.textContent]), [['centered', 'Centered'], ['fullscreen', 'Full screen']]);
|
||
byId('viewer-mode').value = 'fullscreen'; byId('viewer-mode').dispatch('change');
|
||
assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'masonry', workspaceDensity: 'large', imageQuality: 'low',
|
||
autoLoadMetadata: false, cacheMetadata: false, videoMute: false, videoLoop: false, showFilePaths: false, showImageInfo: false,
|
||
videoAutoplay: true, sidebarCollapsedByDefault: true, viewerOpenMode: 'fullscreen' });
|
||
assert.deepEqual(drafts.changes('host'), { webui_display_mode: 'popup' });
|
||
} finally { scope.dispose(); dom.restore(); }
|
||
});
|
||
|
||
test('the auto-hide timeout takes whole seconds from 0 to 300 only', async () => {
|
||
const { resultTimeout } = await appearanceModule;
|
||
assert.deepEqual(['0', '30', ' 300 ', '301', '-1', '2.5', '', 'abc', '1e2'].map(resultTimeout), [0, 30, 300, null, null, null, null, null, null]);
|
||
const { dom, drafts, scope, byId, loadHost } = await mounted();
|
||
try {
|
||
await loadHost();
|
||
const timeout = byId('timeout');
|
||
timeout.value = '45'; timeout.dispatch('change');
|
||
assert.deepEqual(drafts.changes('host'), { result_timeout: 45 });
|
||
timeout.value = '301'; timeout.dispatch('change');
|
||
assert.deepEqual(drafts.changes('host'), {});
|
||
assert.deepEqual([...drafts.failed('host')], ['result_timeout']);
|
||
timeout.value = '0'; timeout.dispatch('change');
|
||
assert.deepEqual(drafts.changes('host'), { result_timeout: 0 });
|
||
assert.deepEqual([...drafts.failed('host')], []);
|
||
} finally { scope.dispose(); dom.restore(); }
|
||
});
|
||
|
||
test('reset restores defaults without touching other keys', async () => {
|
||
const { DISPLAY_KEYS } = await appearanceModule;
|
||
const changed = { ...BROWSER, workspaceLayout: 'list', videoMute: false, showImageInfo: false, sidebarCollapsedByDefault: true, viewerOpenMode: 'fullscreen' };
|
||
const { dom, drafts, scope, surface, loadHost } = await mounted({ browser: changed });
|
||
try {
|
||
await loadHost();
|
||
drafts.set('host', 'result_timeout', 9);
|
||
const reset = dom.find(surface.children.at(-1), node => node.tagName === 'BUTTON');
|
||
assert.equal(reset.textContent, 'Reset');
|
||
reset.click();
|
||
assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'grid', videoMute: true, showImageInfo: true, sidebarCollapsedByDefault: false, viewerOpenMode: 'centered' });
|
||
// The host fields and every key outside the display defaults are left as they are.
|
||
assert.deepEqual(drafts.changes('host'), { result_timeout: 9 });
|
||
assert.deepEqual([...DISPLAY_KEYS].sort(), Object.keys(BROWSER).sort());
|
||
} finally { scope.dispose(); dom.restore(); }
|
||
});
|
||
|
||
test('host rows that could not be read stay disabled and say so; a later read clears it', async () => {
|
||
const { HOST_UNAVAILABLE } = await appearanceModule;
|
||
const { dom, drafts, scope, byId, surface, fixHost } = await mounted({ hostFails: true });
|
||
const notes = () => surface.children.slice(2).flatMap(row => row.children[0].children.slice(2).map(note => [row.children[0].children[0].textContent, note.className, note.textContent]));
|
||
try {
|
||
assert.deepEqual(notes(), []);
|
||
assert.equal(await drafts.reloadHost(), false);
|
||
assert.deepEqual(notes(), [['Open from ComfyUI', 'small', HOST_UNAVAILABLE], ['Node search auto-hide (seconds)', 'small', HOST_UNAVAILABLE]]);
|
||
assert.equal(byId('timeout').disabled, true);
|
||
// Repaints keep one line per row.
|
||
drafts.set('browser', 'videoMute', false);
|
||
assert.equal(notes().length, 2);
|
||
fixHost();
|
||
assert.equal(await drafts.reloadHost(), true);
|
||
assert.deepEqual(notes(), []);
|
||
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
|
||
});
|