refactor: split prompt studio views and unify UI lifecycle
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { fixture, Element, all } from './ui_fixture.mjs';
|
||||
|
||||
const f = fixture();
|
||||
const record = { filename: 'parameters.json', kind: 'image_node_selection', name: 'Parameters', node_types: ['CLIPTextEncode'], selection: { scope: 'nodes' } };
|
||||
f.fetch = async url => ({ ok: true, json: async () => String(url).includes('material_full')
|
||||
? { status: 'success', data: record, node_blocks: [2, 3].map(id => ({ node_id: id, type: 'CLIPTextEncode', widgets_values: [`text ${id}`] })) }
|
||||
: { status: 'success', materials: [record], page: 1, pages: 1, total: 1, tags: [] } });
|
||||
const materials = await f.module('ui_materials.js');
|
||||
const owner = { nbPanel: new Element('div') };
|
||||
owner.nbPanel.style.display = 'flex';
|
||||
f.document.body.appendChild(owner.nbPanel);
|
||||
for (const key of ['showMaterials', 'refreshMaterials', 'openSavedMaterial', 'openMaterialLibrary']) owner[key] = materials[key].bind(owner);
|
||||
await owner.openMaterialLibrary();
|
||||
assert.equal(owner.notebookContainer, undefined);
|
||||
assert.equal(owner.materialApplyMode, true);
|
||||
assert.equal(new URL(f.requests.at(-1)[0], 'http://test').searchParams.get('node_type'), 'CLIPTextEncode');
|
||||
assert.deepEqual(f.errors, []);
|
||||
const card = owner.materialList.querySelector('article');
|
||||
assert.ok(card, 'current material card renders');
|
||||
await card.click();
|
||||
assert.equal(f.node.widgets[0].value, 'original', 'opening details must not write to canvas');
|
||||
assert.ok(owner.materialDetailView);
|
||||
await card.querySelector('.anomalous-material-card-apply-btn').click();
|
||||
assert.deepEqual(f.errors, []);
|
||||
assert.ok(owner.materialBlockDialog);
|
||||
let choices = owner.materialBlockDialog.querySelectorAll('.anomalous-material-choice-card');
|
||||
await choices[1].querySelector('button').click();
|
||||
assert.equal(f.node.widgets[0].value, 'text 3');
|
||||
await f.button(owner.materialContext, 'Undo').click();
|
||||
assert.equal(f.node.widgets[0].value, 'original');
|
||||
|
||||
f.runTimers();
|
||||
await card.querySelector('.anomalous-material-card-apply-btn').click();
|
||||
const dialog = owner.materialBlockDialog;
|
||||
f.app.canvas.selected_nodes = {};
|
||||
await dialog.querySelector('.anomalous-material-choice-card').querySelector('button').click();
|
||||
assert.equal(f.node.widgets[0].value, 'original');
|
||||
assert.ok(dialog.textContent.includes((await f.module('locales.js')).translate('materialTargetChanged')));
|
||||
dialog.close();
|
||||
f.app.canvas.selected_nodes = { 1: f.node };
|
||||
|
||||
const notes = await f.module('ui_notebooks.js');
|
||||
owner.showNotebooks = notes.showNotebooks.bind(owner);
|
||||
owner.refreshNotebooks = () => {};
|
||||
await owner.showNotebooks();
|
||||
assert.equal(owner.materialContainer.style.display, 'none');
|
||||
assert.ok(owner.notebookContainer);
|
||||
assert.equal(owner.notebookMaterialsTab, undefined);
|
||||
await owner.showMaterials();
|
||||
assert.equal(owner.notebookContainer.style.display, 'none');
|
||||
assert.equal(owner.materialContainer.style.display, 'flex');
|
||||
assert.equal(all(owner.materialContainer).some(el => el.className === 'anomalous-nb-section-tabs'), false);
|
||||
assert.deepEqual(f.errors, []);
|
||||
console.log('material workspace: current cards, explicit apply, undo, stale selection and notes isolation passed');
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadPromptSourceCards, mergePromptSourceCards } from '../web/modules/prompt_material_source.js';
|
||||
|
||||
const requests = [];
|
||||
globalThis.fetch = async (url, options) => {
|
||||
requests.push([url, options]);
|
||||
const parsed = new URL(url, 'http://test');
|
||||
const page = Number(parsed.searchParams.get('page'));
|
||||
const filename = parsed.searchParams.get('filename');
|
||||
return { ok: true, json: async () => filename
|
||||
? { status: 'success', data: { kind: 'prompt_text', note: { promptEn: filename } } }
|
||||
: { status: 'success', pages: 2, materials: page === 1 ? [{ filename: 'first.json' }] : [{ filename: 'first.json' }, { filename: 'last.json' }] } };
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const cards = await loadPromptSourceCards(controller.signal);
|
||||
assert.deepEqual(cards.map(card => card.content), ['first.json', 'last.json']);
|
||||
assert.equal(requests.length, 4, 'two pages and two unique details');
|
||||
assert.equal(requests.every(([, options]) => options.signal === controller.signal), true);
|
||||
const deck = [];
|
||||
assert.equal(mergePromptSourceCards(deck, cards), 2);
|
||||
assert.equal(mergePromptSourceCards(deck, cards), 0);
|
||||
controller.abort();
|
||||
await assert.rejects(loadPromptSourceCards(controller.signal), { name: 'AbortError' });
|
||||
assert.equal(requests.length, 4);
|
||||
console.log('prompt material source: later pages, deduplication, counts and cancellation passed');
|
||||
@@ -0,0 +1,134 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { fixture, all } from './ui_fixture.mjs';
|
||||
|
||||
const f = fixture();
|
||||
const studio = await f.module('ui_prompt_composer.js');
|
||||
const translator = await f.module('ui_prompt_translator.js');
|
||||
const owner = { setTriggerVisible(value) { this.triggerVisible = value; } };
|
||||
const listeners = () => f.window.listenerCount() + f.document.listenerCount();
|
||||
const clean = () => {
|
||||
assert.equal(listeners(), 0, 'all global listeners must be released');
|
||||
assert.equal(f.document.querySelector('.anomalous-prompt-studio-overlay'), null);
|
||||
assert.equal(f.document.querySelector('.anomalous-translator-overlay'), null);
|
||||
assert.equal(owner.sidePromptComposerControl, null);
|
||||
assert.equal(owner.triggerVisible, true);
|
||||
};
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await studio.openPromptStudio(owner);
|
||||
await f.flush();
|
||||
assert.equal(f.document.querySelectorAll('.anomalous-source-card-compact').length, 6);
|
||||
owner.sidePromptComposerControl.addBlock({ content: 'quality', role: 'positive', category: 'base' });
|
||||
assert.equal(owner.promptPlanDraft.plan.positive, 'quality');
|
||||
f.window.dispatch('keydown', { key: 'Escape' });
|
||||
clean();
|
||||
owner.promptPlanDraft = null;
|
||||
}
|
||||
|
||||
await studio.openPromptStudio(owner);
|
||||
await studio.openPromptStudio(owner);
|
||||
assert.equal(f.document.querySelectorAll('.anomalous-prompt-studio-overlay').length, 1);
|
||||
const beforeDrag = listeners();
|
||||
const handle = f.document.querySelector('.anomalous-studio-resize-handle');
|
||||
f.document.body.style.cursor = 'crosshair';
|
||||
handle.onmousedown({ button: 0, clientX: 100, preventDefault() {} });
|
||||
assert.equal(listeners(), beforeDrag + 2);
|
||||
f.window.dispatch('mousemove', { clientX: 160 });
|
||||
assert.equal(f.document.querySelector('.anomalous-prompt-studio-drawer').style.width, '640px');
|
||||
studio.closePromptStudio(owner);
|
||||
assert.equal(f.document.body.style.cursor, 'crosshair');
|
||||
clean();
|
||||
|
||||
await studio.openPromptStudio(owner);
|
||||
owner.sidePromptComposerControl.addBlock({ content: 'quality', role: 'positive' });
|
||||
await f.button(f.document.body, '⛶').click();
|
||||
assert.ok(f.document.querySelector('.anomalous-prompt-inspector-overlay'));
|
||||
f.window.dispatch('keydown', { key: 'Escape' });
|
||||
assert.equal(f.document.querySelector('.anomalous-prompt-inspector-overlay'), null);
|
||||
assert.ok(f.document.querySelector('.anomalous-prompt-studio-overlay'), 'first Escape closes only inspector');
|
||||
f.window.dispatch('keydown', { key: 'Escape' });
|
||||
clean();
|
||||
|
||||
// Full-text edits remain authoritative after recomposition, save and reopening.
|
||||
await studio.openPromptStudio(owner);
|
||||
owner.sidePromptComposerControl.addBlock({ content: 'scene', role: 'positive' });
|
||||
owner.sidePromptComposerControl.addBlock({ content: 'bad anatomy', role: 'negative' });
|
||||
await f.button(f.document.body, 'Positive (').click();
|
||||
await f.button(f.document.body, '⛶').click();
|
||||
const inspectorText = f.document.querySelector('.anomalous-inspector-textarea');
|
||||
inspectorText.value = 'edited whole prompt';
|
||||
inspectorText.oninput();
|
||||
f.window.dispatch('keydown', { key: 'Escape' });
|
||||
await f.button(f.document.body, '📋').click();
|
||||
assert.equal(f.clipboard.at(-1), 'edited whole prompt');
|
||||
assert.equal(owner.promptPlanDraft.plan.negative, 'bad anatomy');
|
||||
const name = f.document.querySelector('.anomalous-prompt-name-input');
|
||||
name.value = 'round trip'; name.oninput();
|
||||
f.fetch = async url => ({ ok: true, json: async () => String(url).includes('/materials?')
|
||||
? { status: 'success', materials: [], pages: 1 }
|
||||
: { status: 'success', material: { filename: 'saved.json' } } });
|
||||
await f.button(f.document.body, '💾').click();
|
||||
const savedRequest = f.requests.findLast(([url]) => url.includes('save_prompt_plan'));
|
||||
const saved = JSON.parse(savedRequest[1].body);
|
||||
assert.equal(saved.name, 'round trip');
|
||||
assert.equal(saved.plan.positive, 'edited whole prompt');
|
||||
assert.equal(saved.plan.negative, 'bad anatomy');
|
||||
await f.button(f.document.body, 'Sync Library').click();
|
||||
assert.ok(f.requests.at(-1)[0].includes('category=prompts'));
|
||||
studio.closePromptStudio(owner);
|
||||
await studio.openPromptStudio(owner);
|
||||
assert.equal(owner.promptPlanDraft.plan.positive, 'edited whole prompt');
|
||||
await f.button(f.document.body, '✨').click();
|
||||
assert.equal(owner.promptPlanDraft.plan.positive, '');
|
||||
assert.equal(owner.promptPlanDraft.plan.negative, '');
|
||||
studio.closePromptStudio(owner);
|
||||
clean();
|
||||
|
||||
// Closing while a saved plan is loading must not replace the draft or reopen.
|
||||
const keptDraft = owner.promptPlanDraft;
|
||||
let releasePlan;
|
||||
f.fetch = async () => new Promise(resolve => { releasePlan = () => resolve({ ok: true, json: async () => ({ status: 'success', data: { kind: 'prompt_plan', name: 'late', plan: { positive: 'late text' } } }) }); });
|
||||
const loadingPlan = studio.showPromptComposer(owner, { filename: 'late.json' });
|
||||
await f.flush();
|
||||
studio.closePromptStudio(owner);
|
||||
releasePlan(); await loadingPlan;
|
||||
assert.equal(owner.promptPlanDraft, keptDraft);
|
||||
clean();
|
||||
|
||||
for (const path of ['button', 'escape', 'backdrop']) {
|
||||
translator.openPromptTranslator(owner);
|
||||
translator.openPromptTranslator(owner);
|
||||
assert.equal(f.document.querySelectorAll('.anomalous-translator-overlay').length, 1);
|
||||
const overlay = f.document.querySelector('.anomalous-translator-overlay');
|
||||
if (path === 'button') f.document.querySelector('.anomalous-translator-close').click();
|
||||
if (path === 'escape') f.document.dispatch('keydown', { key: 'Escape' });
|
||||
if (path === 'backdrop') { overlay.classList.remove('is-sidebar'); overlay.onclick({ target: overlay }); }
|
||||
clean();
|
||||
}
|
||||
|
||||
// A delayed translation may not write into a new selection, overwrite an edited
|
||||
// widget, or write after the translator closes. Kana must use the EN service too.
|
||||
for (const change of ['selection', 'widget', 'close', 'none']) {
|
||||
translator.openPromptTranslator(owner);
|
||||
const target = f.document.querySelector('.is-target');
|
||||
target.value = 'かわいい';
|
||||
let release;
|
||||
f.fetch = async (url, options) => {
|
||||
assert.equal(JSON.parse(options.body).target_lang, 'en');
|
||||
return new Promise(resolve => { release = () => resolve({ ok: true, json: async () => ({ translated: 'cute' }) }); });
|
||||
};
|
||||
const pending = f.button(f.document.body, 'Translate to EN & Write').click();
|
||||
await f.flush();
|
||||
if (change === 'selection') f.app.canvas.selected_nodes = {};
|
||||
if (change === 'widget') f.node.widgets[0].value = 'edited';
|
||||
if (change === 'close') f.document.querySelector('.anomalous-translator-close').click();
|
||||
release?.(); await pending;
|
||||
assert.equal(f.node.widgets[0].value, change === 'widget' ? 'edited' : change === 'none' ? 'cute' : 'original');
|
||||
f.document.querySelector('.anomalous-translator-close')?.click();
|
||||
f.app.canvas.selected_nodes = { 1: f.node };
|
||||
f.node.widgets[0].value = 'original';
|
||||
(await f.module('translation_service.js')).clearTranslationCache();
|
||||
clean();
|
||||
}
|
||||
assert.deepEqual(f.errors, []);
|
||||
console.log('prompt UI: reopen/close, Escape nesting, resize cleanup, real card insertion and delayed node writes passed');
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import { materialPromptText, loadMaterialPrompts } from '../web/modules/material_prompt_data.js';
|
||||
import { composePromptPlan, joinPromptText, categorizePromptSnippet, smartSortPromptBlocks, assemblePromptBlocks, planToWorkbenchDraft, workbenchDraftToSavedPlan } from '../web/modules/prompt_composition.js';
|
||||
import { translate } from '../web/modules/locales.js';
|
||||
|
||||
assert.deepEqual(materialPromptText({ data: { kind: 'prompt_text', note: { promptEn: ' exact ', promptZh: 'translation' } } }), { positive: ' exact ', negative: '' });
|
||||
assert.deepEqual(materialPromptText({ data: { kind: 'prompt_plan', plan: { parts: [{ positive: 'base', negative: 'bad' }, { positive: 'skip', enabled: false }], positive: 'scene' } } }), { positive: 'base\nscene', negative: 'bad' });
|
||||
assert.deepEqual(materialPromptText({ data: { kind: 'image_node_selection' }, prompt_groups: { positive: ['yes'], negative: ['no'] }, node_blocks: [{ type: 'CheckpointLoaderSimple', widgets_values: ['not-prompt'] }] }), { positive: 'yes', negative: 'no' });
|
||||
assert.deepEqual(materialPromptText({ data: {}, node_blocks: [{ widgets_values: ['unknown role'] }] }), { positive: '', negative: '' });
|
||||
let lastUrl;
|
||||
globalThis.fetch = async url => { lastUrl = url; return { ok: true, json: async () => ({ status: 'success', data: { kind: 'prompt_note_bundle', note: { promptEn: 'real detail' } } }) }; };
|
||||
assert.equal((await loadMaterialPrompts('note.json')).positive, 'real detail'); assert.ok(lastUrl.includes('include_workflow=0'));
|
||||
|
||||
class Element {
|
||||
constructor(tag) { this.tagName = tag; this.children = []; this.style = {}; this.value = ''; this.attrs = {}; this.className = ''; this.classList = { add() {}, remove() {}, toggle() {} }; this.dataset = {}; }
|
||||
appendChild(child) { child.parent = this; this.children.push(child); return child; }
|
||||
append(...children) { children.forEach(c => this.appendChild(c)); }
|
||||
replaceChildren() { this.children.forEach(c => c.parent = null); this.children = []; }
|
||||
remove() { if (this.parent) this.parent.children = this.parent.children.filter(c => c !== this); this.parent = null; }
|
||||
setAttribute(k, v) { this.attrs[k] = v; }
|
||||
addEventListener(key, fn) { (this.listeners ||= {})[key] = fn; }
|
||||
focus() {}
|
||||
get isConnected() { return this.tagName === 'body' || !!this.parent?.isConnected; }
|
||||
}
|
||||
const document = { body: new Element('body'), createElement: tag => new Element(tag), addEventListener: (key, fn) => events[key] = fn };
|
||||
const text = (parent, tag, value, cls = '') => { const el = document.createElement(tag); el.textContent = value; el.className = cls; parent.appendChild(el); return el; };
|
||||
const all = parent => [parent, ...parent.children.flatMap(all)];
|
||||
const events = {};
|
||||
let sent;
|
||||
const context = vm.createContext({ console, document, app: {}, translate, t: translate, text, composePromptPlan, joinPromptText,
|
||||
categorizePromptSnippet, smartSortPromptBlocks, assemblePromptBlocks, planToWorkbenchDraft, workbenchDraftToSavedPlan,
|
||||
window: { addEventListener: (key, fn) => events[key] = fn, removeEventListener: (key, fn) => { if (events[key] === fn) delete events[key]; } },
|
||||
selectedMaterialNode: () => null, promptWidgetTargets: () => [], bindMaterialDrag() {},
|
||||
anomalousConfirm: async () => true, anomalousAlert: async msg => { throw new Error(msg); }, showMaterialSaved() {},
|
||||
jsonResponse: response => response.json(), loadMaterialPrompts: async () => ({ positive: 'imported', negative: '' }),
|
||||
URL, URLSearchParams, AbortController, setTimeout, clearTimeout,
|
||||
fetch: async (url, options) => {
|
||||
sent = [url, options];
|
||||
return { ok: true, json: async () => url.includes('save_prompt_plan') ? { status: 'success' } : { status: 'success', materials: [{ filename: 'p.json', name: 'Real prompt' }], total: 49, page: 1, pages: 2 } };
|
||||
},
|
||||
});
|
||||
async function moduleFor(file, extra) {
|
||||
const source = fs.readFileSync(`web/modules/${file}`, 'utf8').replace(/^import[\s\S]*?;\s*/gm, '');
|
||||
const mod = new vm.SourceTextModule(source + `\nexport { ${extra} };`, {
|
||||
context,
|
||||
identifier: `file:///${process.cwd().replace(/\\/g, '/')}/web/modules/${file}`,
|
||||
initializeImportMeta(meta, module) { meta.url = module.identifier; }
|
||||
}); await mod.link(() => { throw new Error('unexpected import'); }); await mod.evaluate(); return mod.namespace;
|
||||
}
|
||||
const recipes = await moduleFor('ui_recipes.js', 'recipeMatchesFilter, updateRecipeFilterControls, getRecipeReadiness');
|
||||
assert.equal(recipes.recipeMatchesFilter({ name: 'cat', workflow_scope: 'partial', tags: ['A'] }, 'cat', new Set(['a']), 'complete'), false);
|
||||
assert.equal(recipes.recipeMatchesFilter({ name: 'cat', workflow_scope: 'partial', tags: ['A'] }, 'cat', new Set(['a']), 'partial'), true);
|
||||
assert.equal(recipes.recipeMatchesFilter({ name: 'old' }, '', new Set(), 'complete'), true);
|
||||
const recipeOwner = { recipeTagSelect: new Element('select'), recipeSelectedTags: new Set(['B']) };
|
||||
recipes.updateRecipeFilterControls(recipeOwner, [{ data: { tags: ['A', 'B'] } }]);
|
||||
assert.equal(recipeOwner.recipeTagSelect.children.length, 3); assert.equal(recipeOwner.recipeTagSelect.value, 'B');
|
||||
assert.equal(recipes.getRecipeReadiness({ params: { model_references: [{ identity: { status: 'verified' }, currentAvailability: 'missing' }] } }).status, 'missing');
|
||||
assert.equal(recipes.getRecipeReadiness({ params: { model_references: [{ identity: { status: 'verified' } }] } }).status, 'warning');
|
||||
|
||||
const drags = await moduleFor('material_drag.js', '');
|
||||
const graph = { getNodeOnPos: () => null };
|
||||
const surface = new Element('canvas'); surface.getBoundingClientRect = () => ({ left: 0, top: 0, right: 100, bottom: 100 });
|
||||
context.app.graph = graph; context.app.canvas = { graph, canvas: surface, convertEventToCanvasOffset: () => [50, 50] };
|
||||
const source = new Element('article'); let drops = 0;
|
||||
drags.bindMaterialDrag(source, {}, { payload: () => ({}), dropOnCanvas: () => { drops++; } });
|
||||
const event = target => ({ target, clientX: 50, clientY: 50, dataTransfer: { setData() {} }, preventDefault() {}, stopPropagation() {}, stopImmediatePropagation() {} });
|
||||
source.listeners.dragstart(event(source)); await events.drop(event(new Element('button'))); assert.equal(drops, 0);
|
||||
source.listeners.dragstart(event(source)); context.app.graph = {}; await events.drop(event(surface)); assert.equal(drops, 0); context.app.graph = graph;
|
||||
source.listeners.dragstart(event(source)); await events.drop(event(surface)); assert.equal(drops, 1); assert.equal(events.drop, undefined);
|
||||
console.log('studio contracts: recipe scope/tags/readiness, prompt detail shapes, active material drag cleanup passed');
|
||||
@@ -0,0 +1,115 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const all = node => [node, ...node.children.flatMap(all)];
|
||||
export class Events {
|
||||
listeners = new Map();
|
||||
addEventListener(type, fn) { if (!this.listeners.has(type)) this.listeners.set(type, new Set()); this.listeners.get(type).add(fn); }
|
||||
removeEventListener(type, fn) { this.listeners.get(type)?.delete(fn); }
|
||||
dispatch(type, event = {}) { for (const fn of [...(this.listeners.get(type) || [])]) fn(event); }
|
||||
listenerCount() { return [...this.listeners.values()].reduce((sum, set) => sum + set.size, 0); }
|
||||
}
|
||||
export class Element extends Events {
|
||||
constructor(tag) {
|
||||
super();
|
||||
this.tagName = tag; this.children = []; this.dataset = {}; this.attrs = {};
|
||||
this.className = ''; this.value = ''; this._text = ''; this.disabled = false;
|
||||
this.style = { setProperty(key, value) { this[key] = value; }, removeProperty(key) { delete this[key]; } };
|
||||
this.classList = {
|
||||
contains: name => this.className.split(/\s+/).includes(name),
|
||||
add: (...names) => { this.className = [...new Set([...this.className.split(/\s+/).filter(Boolean), ...names])].join(' '); },
|
||||
remove: (...names) => { this.className = this.className.split(/\s+/).filter(name => !names.includes(name)).join(' '); },
|
||||
toggle: (name, force) => { const enabled = force ?? !this.classList.contains(name); this.classList[enabled ? 'add' : 'remove'](name); return enabled; },
|
||||
};
|
||||
}
|
||||
set textContent(value) { this.replaceChildren(); this._text = String(value); }
|
||||
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
|
||||
set innerHTML(value) { this.textContent = value; }
|
||||
get innerHTML() { return this.textContent; }
|
||||
appendChild(child) { child.remove(); child.parentNode = this; this.children.push(child); return child; }
|
||||
append(...children) { children.forEach(child => this.appendChild(child)); }
|
||||
insertBefore(child, before) { if (!before) return this.appendChild(child); child.remove(); child.parentNode = this; this.children.splice(this.children.indexOf(before), 0, child); return child; }
|
||||
prepend(child) { child.remove(); child.parentNode = this; this.children.unshift(child); }
|
||||
replaceChildren(...children) { for (const child of this.children) child.parentNode = null; this.children = []; this._text = ''; this.append(...children); }
|
||||
remove() { if (this.parentNode) this.parentNode.children = this.parentNode.children.filter(child => child !== this); this.parentNode = null; }
|
||||
get parentElement() { return this.parentNode; }
|
||||
get isConnected() { return this.tagName === 'body' || !!this.parentNode?.isConnected; }
|
||||
get firstChild() { return this.children[0] || null; }
|
||||
get options() { return this.children; }
|
||||
cloneNode(deep = false) {
|
||||
const copy = new Element(this.tagName);
|
||||
copy.className = this.className; copy.value = this.value; copy._text = this._text;
|
||||
copy.attrs = { ...this.attrs }; copy.dataset = { ...this.dataset };
|
||||
if (deep) copy.append(...this.children.map(child => child.cloneNode(true)));
|
||||
return copy;
|
||||
}
|
||||
setAttribute(name, value) { this.attrs[name] = value; }
|
||||
removeAttribute(name) { delete this.attrs[name]; }
|
||||
getAttribute(name) { return this.attrs[name] ?? null; }
|
||||
matches(selector) { return selector.startsWith('.') ? this.classList.contains(selector.slice(1)) : this.tagName === selector; }
|
||||
querySelectorAll(selector) { return all(this).slice(1).filter(child => selector.split(',').some(part => child.matches(part.trim()))); }
|
||||
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
|
||||
closest(selector) { return this.matches(selector) ? this : this.parentNode?.closest(selector) || null; }
|
||||
contains(child) { return all(this).includes(child); }
|
||||
hasChildNodes() { return !!this.children.length; }
|
||||
getBoundingClientRect() { return { width: parseFloat(this.style.width || this.style['--amb-translator-width']) || 580, height: 600, top: 0, left: 0, right: 580, bottom: 600 }; }
|
||||
click() { if (!this.disabled) return this.onclick?.({ target: this, preventDefault() {}, stopPropagation() {} }); }
|
||||
showModal() { this.open = true; }
|
||||
close() { this.open = false; this.onclose?.(); }
|
||||
focus() {}
|
||||
scrollIntoView() {}
|
||||
}
|
||||
|
||||
export function fixture() {
|
||||
const document = new Events();
|
||||
document.body = new Element('body');
|
||||
document.createElement = tag => new Element(tag);
|
||||
document.createDocumentFragment = () => new Element('fragment');
|
||||
document.querySelector = selector => document.body.querySelector(selector);
|
||||
document.querySelectorAll = selector => document.body.querySelectorAll(selector);
|
||||
const window = new Events();
|
||||
Object.assign(window, { innerWidth: 1440, innerHeight: 900, anomalous_browser_lang: 'en' });
|
||||
const storage = new Map();
|
||||
const timers = new Map();
|
||||
let timerId = 0;
|
||||
const errors = [], requests = [], clipboard = [];
|
||||
const node = { id: 1, type: 'CLIPTextEncode', widgets: [{ name: 'text', type: 'customtext', value: 'original' }], widgets_values: ['original'] };
|
||||
const app = { graph: { getNodeById: id => id === node.id ? node : null }, canvas: { selected_nodes: { 1: node } } };
|
||||
const state = {
|
||||
app, node, window, document, errors, requests, clipboard,
|
||||
async fetch(url) { return { ok: true, json: async () => String(url).includes('/materials?') ? { status: 'success', materials: [], pages: 1 } : { status: 'success', translated: 'translated' } }; },
|
||||
};
|
||||
const context = vm.createContext({ console, URL, URLSearchParams, Blob, AbortController, DOMException, structuredClone,
|
||||
document, window, app, errors, anomalous_browser_lang: 'en',
|
||||
setTimeout: callback => { timers.set(++timerId, callback); return timerId; }, clearTimeout: id => timers.delete(id),
|
||||
requestAnimationFrame: callback => callback(), queueMicrotask,
|
||||
localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, String(value)), removeItem: key => storage.delete(key) },
|
||||
navigator: { clipboard: { async writeText(text) { clipboard.push(text); } } }, confirm: () => true,
|
||||
fetch: async (url, options = {}) => { requests.push([url, options]); return state.fetch(url, options); },
|
||||
});
|
||||
const modules = new Map();
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../web/modules');
|
||||
const load = filename => {
|
||||
if (modules.has(filename)) return modules.get(filename);
|
||||
let source;
|
||||
if (filename.endsWith('/scripts/app.js')) source = 'export const app = globalThis.app;';
|
||||
else if (filename.endsWith('/ui_dialog.js')) source = 'export async function anomalousAlert(message) { errors.push(message); } export async function anomalousConfirm() { return true; }';
|
||||
else if (filename.endsWith('/material_feedback.js')) source = 'export function showMaterialSaved() {}';
|
||||
else if (filename.endsWith('/ui_gallery_detail.js')) source = 'export function showImageWorkbench() {}';
|
||||
else source = fs.readFileSync(filename, 'utf8');
|
||||
const mod = new vm.SourceTextModule(source, { context, identifier: filename });
|
||||
modules.set(filename, mod); return mod;
|
||||
};
|
||||
state.module = async name => {
|
||||
const mod = load(path.resolve(root, name).replaceAll('\\', '/'));
|
||||
if (mod.status === 'unlinked') await mod.link((specifier, parent) => load(path.resolve(path.dirname(parent.identifier), specifier).replaceAll('\\', '/')));
|
||||
if (mod.status === 'linked') await mod.evaluate();
|
||||
return mod.namespace;
|
||||
};
|
||||
state.button = (rootNode, label) => all(rootNode).find(el => el.tagName === 'button' && el.textContent.includes(label));
|
||||
state.flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
|
||||
state.runTimers = () => { const pending = [...timers.values()]; timers.clear(); pending.forEach(callback => callback()); };
|
||||
return state;
|
||||
}
|
||||
Reference in New Issue
Block a user