mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-06-14 18:04:58 +03:00
Auto-build: opencode (powershell) from 6d119eb
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
// web-test dom shared v1.0 — embedded JS function constants
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
/**
|
||||
* Shared function strings embedded into page.evaluate() generators.
|
||||
* Не экспортируются наружу через dom.mjs facade — внутренняя кухня.
|
||||
*/
|
||||
|
||||
/** Find visible #modalSurface. 1C may leave multiple #modalSurface in DOM (duplicate id),
|
||||
* e.g. when a second form (drill-down) creates its own alongside a stale one from the first
|
||||
* form. getElementById returns the FIRST in document order, which may be hidden. Scan all. */
|
||||
export const HAS_VISIBLE_MODAL_FN = `function hasVisibleModal() {
|
||||
const all = document.querySelectorAll('#modalSurface');
|
||||
for (const el of all) { if (el.offsetWidth > 0) return true; }
|
||||
return false;
|
||||
}`;
|
||||
|
||||
/** Detect active form number. Picks form with most visible elements, skipping form0.
|
||||
* When modalSurface is visible — prefer the highest-numbered form (modal dialog). */
|
||||
export const DETECT_FORM_FN = HAS_VISIBLE_MODAL_FN + `
|
||||
function detectForm() {
|
||||
const counts = {};
|
||||
document.querySelectorAll('input.editInput[id], textarea[id], a.press[id]').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const m = el.id.match(/^form(\\d+)_/);
|
||||
if (m) counts[m[1]] = (counts[m[1]] || 0) + 1;
|
||||
});
|
||||
const nums = Object.keys(counts).map(Number);
|
||||
if (!nums.length) return null;
|
||||
const candidates = nums.filter(n => n > 0);
|
||||
if (!candidates.length) return nums[0];
|
||||
// When modal surface is visible, prefer the highest-numbered form (modal dialog)
|
||||
if (hasVisibleModal()) {
|
||||
const maxForm = Math.max(...candidates);
|
||||
if (counts[maxForm] >= 1) return maxForm;
|
||||
}
|
||||
return candidates.reduce((best, n) => counts[n] > counts[best] ? n : best);
|
||||
}`;
|
||||
|
||||
/** Detect all open forms + modal state. Returns { activeForm, allForms, formCount, modal }.
|
||||
* Works even when the open-windows tab bar is hidden. */
|
||||
export const DETECT_FORMS_FN = HAS_VISIBLE_MODAL_FN + `
|
||||
function detectForms() {
|
||||
const counts = {};
|
||||
document.querySelectorAll('input.editInput[id], textarea[id], a.press[id]').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const m = el.id.match(/^form(\\d+)_/);
|
||||
if (m) counts[m[1]] = (counts[m[1]] || 0) + 1;
|
||||
});
|
||||
const nums = Object.keys(counts).map(Number);
|
||||
return { allForms: nums.sort((a, b) => a - b), formCount: nums.length, modal: hasVisibleModal() };
|
||||
}`;
|
||||
|
||||
/** Read form state given prefix p. Returns { fields, buttons, tabs, texts, hyperlinks, table, iframes }. */
|
||||
export const READ_FORM_FN = `function readForm(p) {
|
||||
const result = {};
|
||||
const fields = [];
|
||||
const buttons = [];
|
||||
const formTabs = [];
|
||||
const texts = [];
|
||||
const hyperlinks = [];
|
||||
// Normalize non-breaking spaces to regular spaces
|
||||
const nbsp = s => (s || '').replace(/\\u00a0/g, ' ');
|
||||
|
||||
// Fields (inputs)
|
||||
document.querySelectorAll('input.editInput[id^="' + p + '"]').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
||||
const titleEl = document.getElementById(p + name + '#title_text')
|
||||
|| document.getElementById(p + name + '#title_div');
|
||||
const label = nbsp((titleEl?.innerText?.trim() || '').replace(/\\n/g, ' '));
|
||||
const actions = [];
|
||||
if (document.getElementById(p + name + '_DLB')?.offsetWidth > 0) actions.push('select');
|
||||
if (document.getElementById(p + name + '_OB')?.offsetWidth > 0) actions.push('open');
|
||||
if (document.getElementById(p + name + '_CLR')?.offsetWidth > 0) actions.push('clear');
|
||||
if (document.getElementById(p + name + '_CB')?.offsetWidth > 0) actions.push('pick');
|
||||
const field = { name, value: el.value || '' };
|
||||
// Multi-value reference fields keep their value in .chipsItem chips, not in input.value
|
||||
if (!field.value) {
|
||||
const labelEl = document.getElementById(p + name);
|
||||
if (labelEl) {
|
||||
const chipTexts = [...labelEl.querySelectorAll('.chipsItem .chipsTitle')]
|
||||
.map(c => nbsp(c.innerText?.trim() || ''))
|
||||
.filter(Boolean);
|
||||
if (chipTexts.length) field.value = chipTexts.join(', ');
|
||||
}
|
||||
}
|
||||
if (label && label !== name) field.label = label;
|
||||
if (el.readOnly) field.readonly = true;
|
||||
if (el.disabled) field.disabled = true;
|
||||
if (el.type && el.type !== 'text') field.type = el.type;
|
||||
if (document.activeElement === el) field.focused = true;
|
||||
if (actions.length) field.actions = actions;
|
||||
if (el.closest('.inputsBox')?.classList.contains('markIncomplete')) field.required = true;
|
||||
fields.push(field);
|
||||
});
|
||||
|
||||
// Textareas
|
||||
document.querySelectorAll('textarea[id^="' + p + '"]').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
||||
const titleEl = document.getElementById(p + name + '#title_text')
|
||||
|| document.getElementById(p + name + '#title_div');
|
||||
const label = nbsp((titleEl?.innerText?.trim() || '').replace(/\\n/g, ' '));
|
||||
const field = { name, value: el.value || '', type: 'textarea' };
|
||||
if (label && label !== name) field.label = label;
|
||||
if (el.readOnly) field.readonly = true;
|
||||
if (el.disabled) field.disabled = true;
|
||||
if (document.activeElement === el) field.focused = true;
|
||||
if (el.closest('.inputsBox')?.classList.contains('markIncomplete')) field.required = true;
|
||||
fields.push(field);
|
||||
});
|
||||
|
||||
// Checkboxes
|
||||
document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const name = el.id.replace(p, '');
|
||||
const titleEl = document.getElementById(p + name + '#title_text');
|
||||
const label = nbsp(titleEl?.innerText?.trim() || '');
|
||||
const field = {
|
||||
name,
|
||||
value: el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select'),
|
||||
type: 'checkbox'
|
||||
};
|
||||
if (label && label !== name) field.label = label;
|
||||
fields.push(field);
|
||||
});
|
||||
|
||||
// Radio buttons — base element is option 0, others are #N#radio (N >= 1)
|
||||
const radioGroups = {};
|
||||
document.querySelectorAll('[id^="' + p + '"].radio').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const id = el.id.replace(p, '');
|
||||
const m = id.match(/^(.+?)#(\\d+)#radio$/);
|
||||
if (m) {
|
||||
// Options 1, 2, ... have explicit #N#radio suffix
|
||||
const [, groupName, idx] = m;
|
||||
if (!radioGroups[groupName]) radioGroups[groupName] = [];
|
||||
const labelEl = document.getElementById(p + groupName + '#' + idx + '#radio_text');
|
||||
const label = nbsp(labelEl?.innerText?.trim() || 'option' + idx);
|
||||
radioGroups[groupName].push({ index: parseInt(idx), label, selected: el.classList.contains('select') });
|
||||
} else if (!id.includes('#')) {
|
||||
// Base element = option 0 (no #0#radio suffix)
|
||||
if (!radioGroups[id]) radioGroups[id] = [];
|
||||
const labelEl = document.getElementById(p + id + '#0#radio_text');
|
||||
const label = nbsp(labelEl?.innerText?.trim() || 'option0');
|
||||
radioGroups[id].unshift({ index: 0, label, selected: el.classList.contains('select') });
|
||||
}
|
||||
});
|
||||
for (const [name, options] of Object.entries(radioGroups)) {
|
||||
const titleEl = document.getElementById(p + name + '#title_text');
|
||||
const label = titleEl?.innerText?.trim() || '';
|
||||
const selected = options.find(o => o.selected);
|
||||
const field = {
|
||||
name,
|
||||
value: selected?.label || '',
|
||||
type: 'radio',
|
||||
options: options.map(o => o.label)
|
||||
};
|
||||
if (label && label !== name) field.label = label;
|
||||
fields.push(field);
|
||||
}
|
||||
|
||||
// Buttons (a.press)
|
||||
document.querySelectorAll('a.press[id^="' + p + '"]').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const idName = el.id.replace(p, '');
|
||||
if (/_(?:DLB|CLR|OB|CB)$/.test(idName)) return;
|
||||
const span = el.querySelector('.submenuText') || el.querySelector('span');
|
||||
const text = nbsp(span?.textContent?.trim() || el.innerText?.trim() || '');
|
||||
if (!text && !el.classList.contains('pressCommand')) return;
|
||||
const btn = { name: text || idName };
|
||||
if (el.classList.contains('pressDefault')) btn.default = true;
|
||||
if (el.classList.contains('pressDisabled')) btn.disabled = true;
|
||||
// Icon-only buttons: expose tooltip from DOM title attribute (1C puts title on parent .framePress)
|
||||
if (!text) {
|
||||
const tip = nbsp(el.title || el.parentElement?.title || '');
|
||||
if (tip) btn.tooltip = tip;
|
||||
}
|
||||
buttons.push(btn);
|
||||
});
|
||||
|
||||
// Frame buttons
|
||||
document.querySelectorAll('[id^="' + p + '"].frameButton, [id^="' + p + '"] .frameButton').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const text = nbsp(el.innerText?.trim() || '');
|
||||
const idName = el.id?.replace(p, '') || '';
|
||||
if (!text && !idName) return;
|
||||
buttons.push({ name: text || idName, frame: true });
|
||||
});
|
||||
|
||||
// Tumbler items
|
||||
document.querySelectorAll('[id^="' + p + '"].tumblerItem').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const text = el.innerText?.trim();
|
||||
const idName = el.id?.replace(p, '') || '';
|
||||
buttons.push({ name: text || idName, tumbler: true });
|
||||
});
|
||||
|
||||
// Tabs — scoped to form by checking ancestor IDs
|
||||
document.querySelectorAll('[data-content]').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
let node = el.parentElement;
|
||||
let inForm = false;
|
||||
while (node) {
|
||||
if (node.id && node.id.startsWith(p)) { inForm = true; break; }
|
||||
node = node.parentElement;
|
||||
}
|
||||
if (!inForm) return;
|
||||
const tab = { name: el.dataset.content };
|
||||
if (el.classList.contains('select')) tab.active = true;
|
||||
formTabs.push(tab);
|
||||
});
|
||||
|
||||
// Static texts and hyperlinks
|
||||
document.querySelectorAll('[id^="' + p + '"].staticText').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const name = el.id.replace(p, '');
|
||||
if (name.endsWith('_div') || name.includes('#title')) return;
|
||||
const text = el.innerText?.trim();
|
||||
if (!text) return;
|
||||
if (el.classList.contains('staticTextHyper')) {
|
||||
hyperlinks.push({ name: text });
|
||||
} else {
|
||||
const titleEl = document.getElementById(p + name + '#title_text');
|
||||
const label = titleEl?.innerText?.trim() || '';
|
||||
const entry = { name, value: text };
|
||||
if (label) entry.label = label;
|
||||
texts.push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
// Tables/grids — collect ALL visible grids
|
||||
const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
|
||||
if (allGrids.length > 0) {
|
||||
const tables = allGrids.map(grid => {
|
||||
const name = grid.id ? grid.id.replace(p, '') : '';
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const body = grid.querySelector('.gridBody');
|
||||
const columns = [];
|
||||
if (head) {
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const textEl = box.querySelector('.gridBoxText');
|
||||
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||||
if (text) {
|
||||
const r = box.getBoundingClientRect();
|
||||
columns.push({ text, x: r.x, right: r.x + r.width, y: r.y, h: r.height });
|
||||
} else {
|
||||
// Unnamed column — check if data cells contain checkboxes
|
||||
const firstLine = body?.querySelector('.gridLine');
|
||||
if (firstLine) {
|
||||
const visibleHeaders = [...headLine.children].filter(c => c.offsetWidth > 0);
|
||||
const idx = visibleHeaders.indexOf(box);
|
||||
const cells = [...firstLine.children].filter(c => c.offsetWidth > 0);
|
||||
if (cells[idx]?.querySelector('.checkbox')) {
|
||||
columns.push({ text: '(checkbox)', x: 0, right: 0, y: 0, h: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// Expand single merged headers with multiple data sub-rows (e.g. "Субконто Дт" → 1/2/3)
|
||||
const firstLine = body?.querySelector('.gridLine');
|
||||
if (firstLine && columns.length > 0) {
|
||||
const xGrp = new Map();
|
||||
columns.forEach(c => {
|
||||
const k = Math.round(c.x) + ':' + Math.round(c.right);
|
||||
if (!xGrp.has(k)) xGrp.set(k, []);
|
||||
xGrp.get(k).push(c);
|
||||
});
|
||||
for (const [k, hdrs] of xGrp) {
|
||||
if (hdrs.length !== 1) continue;
|
||||
let cnt = 0;
|
||||
[...firstLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const r = box.getBoundingClientRect();
|
||||
const cx = r.x + r.width / 2;
|
||||
if (cx >= hdrs[0].x && cx < hdrs[0].right) cnt++;
|
||||
});
|
||||
if (cnt > 1) {
|
||||
const base = hdrs[0];
|
||||
const baseIdx = columns.indexOf(base);
|
||||
columns.splice(baseIdx, 1);
|
||||
for (let si = 0; si < cnt; si++) {
|
||||
columns.splice(baseIdx + si, 0, { text: base.text + ' ' + (si + 1), x: base.x, right: base.right, y: 0, h: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const colNames = columns.map(c => c.text);
|
||||
const rowCount = body ? body.querySelectorAll('.gridLine').length : 0;
|
||||
// Visual label from group title (e.g. "Входящие:" for grid "Входящие")
|
||||
const titleEl = document.getElementById(p + name + '#title_div')
|
||||
|| document.getElementById(p + 'Группа' + name + '#title_div');
|
||||
const label = titleEl ? (titleEl.innerText?.trim().replace(/:\\s*$/, '').replace(/\\u00a0/g, ' ') || null) : null;
|
||||
return { name, columns: colNames, rowCount, ...(label ? { label } : {}) };
|
||||
});
|
||||
result.tables = tables;
|
||||
// Backward compat: table = first grid summary
|
||||
const first = tables[0];
|
||||
result.table = { present: true, columns: first.columns, rowCount: first.rowCount };
|
||||
}
|
||||
|
||||
// Active filters (train badges above grid: *СостояниеПросмотра)
|
||||
const filters = [];
|
||||
document.querySelectorAll('[id^="' + p + '"].trainItem').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const titleEl = el.querySelector('.trainName');
|
||||
const valueEl = el.querySelector('.trainTitle');
|
||||
if (!titleEl && !valueEl) return;
|
||||
const field = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/\\s*:$/, '').trim();
|
||||
const value = valueEl?.innerText?.trim()?.replace(/\\n/g, ' ') || '';
|
||||
if (field || value) filters.push({ field, value });
|
||||
});
|
||||
// Also check search field value
|
||||
const searchInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||||
.find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id));
|
||||
if (searchInput?.value) {
|
||||
filters.push({ type: 'search', value: searchInput.value });
|
||||
}
|
||||
if (filters.length) result.filters = filters;
|
||||
|
||||
// Navigation panel (FormNavigationPanel) — lives in parent page{N} container
|
||||
const navigation = [];
|
||||
const formEl = document.querySelector('[id^="' + p + '"]');
|
||||
if (formEl) {
|
||||
let pageEl = formEl.parentElement;
|
||||
while (pageEl && !(pageEl.id && /^page\\d+$/.test(pageEl.id))) pageEl = pageEl.parentElement;
|
||||
if (pageEl) {
|
||||
pageEl.querySelectorAll('.navigationItem').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const nameEl = el.querySelector('.navigationItemName');
|
||||
const text = (nameEl?.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
|
||||
if (!text) return;
|
||||
const nav = { name: text };
|
||||
if (el.classList.contains('select')) nav.active = true;
|
||||
navigation.push(nav);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Iframes
|
||||
let iframeCount = 0;
|
||||
document.querySelectorAll('[id^="' + p + '"] iframe, iframe[id^="' + p + '"]').forEach(el => {
|
||||
if (el.offsetWidth > 0 && el.offsetHeight > 0) iframeCount++;
|
||||
});
|
||||
if (iframeCount) result.iframes = iframeCount;
|
||||
|
||||
if (fields.length) result.fields = fields;
|
||||
if (buttons.length) result.buttons = buttons;
|
||||
if (formTabs.length) result.tabs = formTabs;
|
||||
if (navigation.length) result.navigation = navigation;
|
||||
if (texts.length) result.texts = texts;
|
||||
if (hyperlinks.length) result.hyperlinks = hyperlinks;
|
||||
|
||||
// Group DCS report settings into readable format
|
||||
if (result.fields) {
|
||||
const dcsRe = /^(.+Элемент(\\d+))(Использование|Значение|ВидСравнения)$/;
|
||||
const dcsGroups = {};
|
||||
const dcsNames = new Set();
|
||||
for (const f of result.fields) {
|
||||
const m = f.name.match(dcsRe);
|
||||
if (!m) continue;
|
||||
if (!dcsGroups[m[1]]) dcsGroups[m[1]] = { _n: parseInt(m[2]) };
|
||||
dcsGroups[m[1]][m[3]] = f;
|
||||
dcsNames.add(f.name);
|
||||
}
|
||||
const dcsEntries = Object.entries(dcsGroups).sort((a, b) => a[1]._n - b[1]._n);
|
||||
if (dcsEntries.length) {
|
||||
result.reportSettings = dcsEntries.map(([, g]) => {
|
||||
const cb = g['Использование'];
|
||||
const val = g['Значение'];
|
||||
if (!cb && !val) return null;
|
||||
// No checkbox present (class="staticText" instead of .checkbox) — setting is always enabled
|
||||
const label = (val?.label || cb?.label || val?.name || cb?.name || '').replace(/:$/, '').trim();
|
||||
const s = { name: label, enabled: cb ? !!cb.value : true };
|
||||
if (val) {
|
||||
s.value = val.value || '';
|
||||
if (val.actions && val.actions.length) s.actions = val.actions;
|
||||
}
|
||||
return s;
|
||||
}).filter(Boolean);
|
||||
result.fields = result.fields.filter(f => !dcsNames.has(f.name));
|
||||
if (!result.fields.length) delete result.fields;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}`;
|
||||
@@ -0,0 +1,108 @@
|
||||
// web-test dom/edd v1.0 — DOM scripts for the #editDropDown autocomplete popup
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
/**
|
||||
* Read the `#editDropDown` autocomplete popup.
|
||||
*
|
||||
* Returns `{ visible: false }` when EDD is absent/hidden, or
|
||||
* `{ visible: true, items: [{ name, x, y }] }` with center coords suitable
|
||||
* for `page.mouse.click(x, y)`.
|
||||
*
|
||||
* Note: `page.mouse.click` is often intercepted by `div.surface` overlays
|
||||
* from DLB — prefer `clickEddItemViaDispatchScript` for those cases.
|
||||
*/
|
||||
export function readEddScript() {
|
||||
return `(() => {
|
||||
const edd = document.getElementById('editDropDown');
|
||||
if (!edd || edd.offsetWidth === 0) return { visible: false };
|
||||
const eddTexts = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0);
|
||||
return {
|
||||
visible: true,
|
||||
items: eddTexts.map(el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { name: el.innerText?.trim() || '', x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||
})
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the EDD popup currently visible? Returns boolean.
|
||||
* Lighter than `readEddScript` when only presence matters.
|
||||
*/
|
||||
export function isEddVisibleScript() {
|
||||
return `(() => {
|
||||
const edd = document.getElementById('editDropDown');
|
||||
return !!(edd && edd.offsetWidth > 0);
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click an EDD item by name via `dispatchEvent` — bypasses `div.surface`
|
||||
* overlays from DLB that intercept `page.mouse.click`.
|
||||
*
|
||||
* Matching is fuzzy: exact (with optional `(suffix)` strip) → includes,
|
||||
* normalizes ё/е and NBSP.
|
||||
*
|
||||
* Returns the clicked item's innerText (trimmed), or `null` when no match.
|
||||
*/
|
||||
export function clickEddItemViaDispatchScript(itemName) {
|
||||
return `(() => {
|
||||
const edd = document.getElementById('editDropDown');
|
||||
if (!edd || edd.offsetWidth === 0) return null;
|
||||
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
||||
const target = ny(${JSON.stringify(itemName.toLowerCase())});
|
||||
const items = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0);
|
||||
function clickEl(el) {
|
||||
const r = el.getBoundingClientRect();
|
||||
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
|
||||
el.dispatchEvent(new MouseEvent('mousedown', opts));
|
||||
el.dispatchEvent(new MouseEvent('mouseup', opts));
|
||||
el.dispatchEvent(new MouseEvent('click', opts));
|
||||
return el.innerText.trim();
|
||||
}
|
||||
// Pass 1: exact match (prefer over partial)
|
||||
for (const el of items) {
|
||||
const t = ny((el.innerText?.trim() || '').toLowerCase());
|
||||
if (t === target) return clickEl(el);
|
||||
const stripped = t.replace(/\\s*\\([^)]*\\)\\s*$/, '');
|
||||
if (stripped === target) return clickEl(el);
|
||||
}
|
||||
// Pass 2: partial match
|
||||
for (const el of items) {
|
||||
const t = ny((el.innerText?.trim() || '').toLowerCase());
|
||||
if (t.includes(target) || target.includes(t.replace(/\\s*\\([^)]*\\)\\s*$/, ''))) return clickEl(el);
|
||||
}
|
||||
return null;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "Показать все" / "Show all" link in the EDD footer via
|
||||
* `dispatchEvent`. Tries `.eddBottom .hyperlink` first, then falls back
|
||||
* to scanning for span/div/a with the literal text.
|
||||
*
|
||||
* Returns boolean — whether the link was found and clicked.
|
||||
*/
|
||||
export function clickShowAllInEddScript() {
|
||||
return `(() => {
|
||||
const edd = document.getElementById('editDropDown');
|
||||
if (!edd || edd.offsetWidth === 0) return false;
|
||||
let el = edd.querySelector('.eddBottom .hyperlink');
|
||||
if (!el || el.offsetWidth === 0) {
|
||||
const candidates = [...edd.querySelectorAll('span, div, a')]
|
||||
.filter(e => e.offsetWidth > 0 && e.children.length === 0);
|
||||
el = candidates.find(e => {
|
||||
const t = (e.innerText?.trim() || '').toLowerCase();
|
||||
return t === 'показать все' || t === 'show all';
|
||||
});
|
||||
}
|
||||
if (!el) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
|
||||
el.dispatchEvent(new MouseEvent('mousedown', opts));
|
||||
el.dispatchEvent(new MouseEvent('mouseup', opts));
|
||||
el.dispatchEvent(new MouseEvent('click', opts));
|
||||
return true;
|
||||
})()`;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// web-test dom/edit-state v1.1 — focus and popup detection inside the 1C web client
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
/**
|
||||
* Is the currently focused element an INPUT (optionally TEXTAREA too)?
|
||||
* Returns boolean.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.allowTextarea=false] — also return true for TEXTAREA.
|
||||
*/
|
||||
export function isInputFocusedScript({ allowTextarea = false } = {}) {
|
||||
const cond = allowTextarea
|
||||
? `f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'`
|
||||
: `f.tagName === 'INPUT'`;
|
||||
return `(() => {
|
||||
const f = document.activeElement;
|
||||
return !!(f && (${cond}));
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the currently focused INPUT/TEXTAREA inside a `.grid` ancestor?
|
||||
* Used to verify grid edit-mode (active cell editor).
|
||||
*
|
||||
* @param {string} [gridSelector] — when given, only `true` if the focused input
|
||||
* is inside that specific grid. Without it — any `.grid` ancestor counts.
|
||||
*
|
||||
* Returns boolean.
|
||||
*/
|
||||
export function isInputFocusedInGridScript(gridSelector) {
|
||||
const sel = gridSelector ? JSON.stringify(gridSelector) : 'null';
|
||||
return `(() => {
|
||||
const f = document.activeElement;
|
||||
if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false;
|
||||
const sel = ${sel};
|
||||
if (sel) {
|
||||
const grid = document.querySelector(sel);
|
||||
return !!(grid && grid.contains(f));
|
||||
}
|
||||
let n = f;
|
||||
while (n) {
|
||||
if (n.classList?.contains('grid')) return true;
|
||||
n = n.parentElement;
|
||||
}
|
||||
return false;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a calculator (`.calculate`) or calendar (`.frameCalendar`) popup visible?
|
||||
* Returns `'calculator' | 'calendar' | null`.
|
||||
*
|
||||
* For the "popup gone" check, callers use: `!await findOpenPopup()`.
|
||||
*/
|
||||
export function findOpenPopupScript() {
|
||||
return `(() => {
|
||||
const calc = document.querySelector('.calculate');
|
||||
if (calc && calc.offsetWidth > 0) return 'calculator';
|
||||
const cal = document.querySelector('.frameCalendar');
|
||||
if (cal && cal.offsetWidth > 0) return 'calendar';
|
||||
return null;
|
||||
})()`;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// web-test dom/errors-stack v1.0 — DOM scripts for fetching error stack via OpenReport link.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// Path-1 flow for platform exceptions: click "Сформировать отчет об ошибке" link,
|
||||
// open detailed error dialog, read textarea, close cleanup dialogs.
|
||||
|
||||
/** Find OpenReport link coordinates on the error modal for given formNum. */
|
||||
export function getOpenReportCoordsScript(formNum) {
|
||||
return `(() => {
|
||||
const el = document.getElementById('form${formNum}_OpenReport#text');
|
||||
if (!el || el.offsetWidth <= 2) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/** Check whether the "подробный текст ошибки" link is visible (signals report dialog ready). */
|
||||
export function isErrorDetailLinkVisibleScript() {
|
||||
return `(() => {
|
||||
const links = document.querySelectorAll('a, [class*="hyper"], span');
|
||||
for (const el of links) {
|
||||
if (el.offsetWidth > 0 && el.textContent.includes('подробный текст ошибки')) return true;
|
||||
}
|
||||
return false;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/** Read the largest visible non-empty textarea — contains the detailed error stack. */
|
||||
export function readLargestVisibleTextareaScript() {
|
||||
return `(() => {
|
||||
let best = null;
|
||||
document.querySelectorAll('textarea').forEach(ta => {
|
||||
if (ta.offsetWidth > 0 && ta.value.length > 0) {
|
||||
if (!best || ta.value.length > best.value.length) best = ta;
|
||||
}
|
||||
});
|
||||
return best?.value || null;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/** Click the OK button in the topmost cloud window (closes "Подробный текст ошибки"). */
|
||||
export function clickTopCloudOkButtonScript() {
|
||||
return `(() => {
|
||||
const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')]
|
||||
.filter(w => w.offsetWidth > 0)
|
||||
.sort((a, b) => parseInt(b.style?.zIndex || '0') - parseInt(a.style?.zIndex || '0'));
|
||||
for (const w of psWins) {
|
||||
const ok = w.querySelector('button.webBtn, .pressDefault');
|
||||
if (ok && ok.textContent.trim() === 'OK') { ok.click(); return true; }
|
||||
}
|
||||
return false;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/** Click the × CloseButton in the topmost visible cloud window (closes "Отчет об ошибке"). */
|
||||
export function clickReportCloseButtonScript() {
|
||||
return `(() => {
|
||||
const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')]
|
||||
.filter(w => w.offsetWidth > 0);
|
||||
for (const w of psWins) {
|
||||
const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]');
|
||||
if (closeBtn && closeBtn.offsetWidth > 0) { closeBtn.click(); break; }
|
||||
}
|
||||
})()`;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// web-test dom/errors v1.0 — error/diagnostic detection (balloon, messages, modal, stateWindow)
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
/**
|
||||
* Check for validation errors / diagnostics after an action.
|
||||
* Detects three patterns:
|
||||
* 1. Inline balloon tooltip (div.balloon with .balloonMessage)
|
||||
* 2. Messages panel (div.messages with msg0, msg1... grid rows)
|
||||
* 3. Modal error dialog (high-numbered form with pressDefault + static texts)
|
||||
* Returns { balloon, messages[], modal } or null if no errors.
|
||||
*/
|
||||
export function checkErrorsScript() {
|
||||
return `(() => {
|
||||
const result = {};
|
||||
|
||||
// 1. Inline balloon tooltip
|
||||
const balloon = document.querySelector('.balloon');
|
||||
if (balloon && balloon.offsetWidth > 0) {
|
||||
const msg = balloon.querySelector('.balloonMessage');
|
||||
const title = balloon.querySelector('.balloonTitle');
|
||||
if (msg) {
|
||||
result.balloon = {
|
||||
title: title?.innerText?.trim() || 'Ошибка',
|
||||
message: msg.innerText?.trim() || ''
|
||||
};
|
||||
// Count navigation arrows to indicate total errors
|
||||
const fwd = balloon.querySelector('.balloonJumpFwd');
|
||||
const back = balloon.querySelector('.balloonJumpBack');
|
||||
const fwdDisabled = fwd?.classList.contains('disabled');
|
||||
const backDisabled = back?.classList.contains('disabled');
|
||||
if (fwd && !fwdDisabled) result.balloon.hasNext = true;
|
||||
if (back && !backDisabled) result.balloon.hasPrev = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Messages panel (div.messages — pick visible one, multiple may exist across tabs)
|
||||
const msgPanels = [...document.querySelectorAll('.messages')].filter(el => el.offsetWidth > 0);
|
||||
for (const msgPanel of msgPanels) {
|
||||
const msgs = [];
|
||||
msgPanel.querySelectorAll('[id^="msg"]').forEach(line => {
|
||||
if (line.offsetWidth === 0) return;
|
||||
const textEl = line.querySelector('.gridBoxText');
|
||||
const text = (textEl || line).innerText?.trim();
|
||||
if (text) msgs.push(text);
|
||||
});
|
||||
if (msgs.length > 0) { result.messages = msgs; break; }
|
||||
}
|
||||
|
||||
// 3+4. Modal dialogs: confirmation (multiple buttons) or error (single pressDefault)
|
||||
// Uses form container ancestry to group buttons — pressButton elements often lack form-prefixed IDs
|
||||
// Note: 1C shows some modals WITHOUT #modalSurface (e.g. "Не удалось записать" uses ps*win floating window)
|
||||
// so we always scan for small forms with button patterns, regardless of modalSurface state
|
||||
const formButtons = {};
|
||||
[...document.querySelectorAll('a.press.pressButton')].forEach(btn => {
|
||||
if (btn.offsetWidth === 0) return;
|
||||
const container = btn.closest('[id$="_container"]');
|
||||
const m = container?.id?.match(/^form(\\d+)_/);
|
||||
if (!m) return;
|
||||
const fn = m[1];
|
||||
if (!formButtons[fn]) formButtons[fn] = [];
|
||||
formButtons[fn].push(btn);
|
||||
});
|
||||
|
||||
for (const [fn, buttons] of Object.entries(formButtons)) {
|
||||
const p = 'form' + fn + '_';
|
||||
const elCount = document.querySelectorAll('[id^="' + p + '"]').length;
|
||||
if (elCount > 100) continue; // Skip large content forms
|
||||
if (buttons.length > 1) {
|
||||
// Confirmation dialog (multiple buttons: Да/Нет, OK/Отмена, etc.)
|
||||
// Must have a Message element — real 1C confirmations always have form{N}_Message.
|
||||
// Without it, this is just a regular form with multiple buttons (e.g. EPF form).
|
||||
const msgEl = document.getElementById(p + 'Message');
|
||||
if (!msgEl || msgEl.offsetWidth === 0) continue;
|
||||
const message = msgEl.innerText?.trim() || '';
|
||||
const btnNames = buttons.map(el => {
|
||||
const b = { name: el.innerText?.trim() || '' };
|
||||
if (el.classList.contains('pressDefault')) b.default = true;
|
||||
return b;
|
||||
}).filter(b => b.name);
|
||||
result.confirmation = { message, buttons: btnNames.map(b => b.name), formNum: parseInt(fn) };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Single-button modal: error dialog with pressDefault + staticText
|
||||
// Skip forms with input fields — those are data entry forms (e.g. register record),
|
||||
// not error dialogs. Real error modals only have staticText + buttons.
|
||||
if (!result.confirmation) {
|
||||
for (const [fn, buttons] of Object.entries(formButtons)) {
|
||||
const p = 'form' + fn + '_';
|
||||
const elCount = document.querySelectorAll('[id^="' + p + '"]').length;
|
||||
if (elCount > 100) continue;
|
||||
if (buttons.length !== 1 || !buttons[0].classList.contains('pressDefault')) continue;
|
||||
const hasInputs = document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').length > 0;
|
||||
if (hasInputs) continue;
|
||||
const texts = [...document.querySelectorAll('[id^="' + p + '"].staticText')]
|
||||
.filter(el => el.offsetWidth > 0)
|
||||
.map(el => el.innerText?.trim())
|
||||
.filter(Boolean);
|
||||
if (texts.length > 0) {
|
||||
result.modal = { message: texts.join(' '), formNum: parseInt(fn), button: buttons[0].innerText?.trim() || '' };
|
||||
// Check if OpenReport link is available (platform exceptions have visible link text)
|
||||
const reportLink = document.getElementById(p + 'OpenReport#text');
|
||||
if (reportLink && reportLink.offsetWidth > 2 && reportLink.textContent.trim()) {
|
||||
result.modal.hasReport = true;
|
||||
}
|
||||
// Grab AdditionalInfo/ServerText if filled (may contain extra error details)
|
||||
const addInfo = document.getElementById(p + 'AdditionalInfo');
|
||||
if (addInfo && addInfo.textContent && addInfo.textContent.trim()) result.modal.additionalInfo = addInfo.textContent.trim();
|
||||
const srvText = document.getElementById(p + 'ServerText');
|
||||
if (srvText && srvText.textContent && srvText.textContent.trim()) result.modal.serverText = srvText.textContent.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. SpreadsheetDocument state window (info bar inside moxelContainer)
|
||||
// Shows messages like "Не установлено значение параметра X" or "Отчет не сформирован"
|
||||
const stateWins = [...document.querySelectorAll('.stateWindowSupportSurface')].filter(el => el.offsetWidth > 0);
|
||||
if (stateWins.length) {
|
||||
const texts = stateWins.map(el => el.innerText?.trim()).filter(Boolean);
|
||||
if (texts.length) result.stateText = texts;
|
||||
}
|
||||
|
||||
return (result.balloon || result.messages || result.modal || result.confirmation || result.stateText) ? result : null;
|
||||
})()`;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// web-test dom/filter v1.0 — DOM scripts for filterList / unfilterList
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
/**
|
||||
* Find the first grid cell on the form and return its center coords.
|
||||
* Used as a fallback target for Alt+F when there's no search input.
|
||||
*
|
||||
* Returns `{ x, y } | null`.
|
||||
*/
|
||||
export function findFirstGridCellCoordsScript(formNum) {
|
||||
return `(() => {
|
||||
const p = 'form${formNum}_';
|
||||
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.find(g => g.offsetWidth > 0);
|
||||
if (!grid) return null;
|
||||
const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
|
||||
if (!rows.length) return null;
|
||||
const cells = [...rows[0].querySelectorAll('.gridBox')];
|
||||
if (!cells.length) return null;
|
||||
const r = cells[0].getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the grid cell of the first row in the column whose header text matches `field`
|
||||
* (fuzzy: exact → startsWith → includes; normalizes ё/е and NBSP).
|
||||
*
|
||||
* If the column isn't in the grid, returns coords of the first cell + `needDlb: true`
|
||||
* so the caller can use DLB to switch FieldSelector after opening the dialog.
|
||||
*
|
||||
* Returns:
|
||||
* - `{ x, y, needDlb? } ` — coords to click (advanced search target)
|
||||
* - `{ error }` — `'no_grid' | 'no_rows' | 'no_cells' | 'cell_not_found'`
|
||||
*/
|
||||
export function findColumnFirstCellCoordsScript(formNum, field) {
|
||||
return `(() => {
|
||||
const p = 'form${formNum}_';
|
||||
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.find(g => g.offsetWidth > 0);
|
||||
if (!grid) return { error: 'no_grid' };
|
||||
const targetField = ${JSON.stringify(field)};
|
||||
const headers = [...grid.querySelectorAll('.gridHead .gridBox')];
|
||||
let colIndex = -1;
|
||||
let startsWithIdx = -1;
|
||||
let includesIdx = -1;
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
const t = headers[i].innerText?.trim().replace(/\\u00a0/g, ' ');
|
||||
if (!t) continue;
|
||||
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
||||
const tl = ny(t.toLowerCase()), fl = ny(targetField.toLowerCase());
|
||||
if (tl === fl) { colIndex = i; break; }
|
||||
if (startsWithIdx < 0 && tl.startsWith(fl)) { startsWithIdx = i; }
|
||||
else if (includesIdx < 0 && tl.includes(fl)) { includesIdx = i; }
|
||||
}
|
||||
if (colIndex < 0) colIndex = startsWithIdx >= 0 ? startsWithIdx : includesIdx;
|
||||
const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
|
||||
if (!rows.length) return { error: 'no_rows' };
|
||||
if (colIndex < 0) {
|
||||
const cells = [...rows[0].querySelectorAll('.gridBox')];
|
||||
if (!cells.length) return { error: 'no_cells' };
|
||||
const r = cells[0].getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), needDlb: true };
|
||||
}
|
||||
const cells = [...rows[0].querySelectorAll('.gridBox')];
|
||||
if (colIndex >= cells.length) return { error: 'cell_not_found' };
|
||||
const r = cells[colIndex].getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read FieldSelector input + its DLB button coords on the advanced search dialog.
|
||||
* Returns `{ current, dlbX, dlbY }` (zero coords if DLB not visible).
|
||||
*/
|
||||
export function readFieldSelectorInfoScript(dialogForm) {
|
||||
return `(() => {
|
||||
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
|
||||
const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||||
.find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id));
|
||||
const dlb = document.getElementById(p + 'FieldSelector_DLB');
|
||||
return {
|
||||
current: fsInput?.value?.trim() || '',
|
||||
dlbX: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().x + dlb.getBoundingClientRect().width / 2) : 0,
|
||||
dlbY: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().y + dlb.getBoundingClientRect().height / 2) : 0
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a field name in the FieldSelector EDD dropdown (fuzzy: exact → includes,
|
||||
* normalizes ё/е and NBSP).
|
||||
*
|
||||
* Returns:
|
||||
* - `{ x, y, name }` — coords + matched name to click
|
||||
* - `{ error, available? }` — `'no_dropdown'` or `'field_not_found'` with list of available names
|
||||
*/
|
||||
export function pickFieldInSelectorDropdownScript(field) {
|
||||
return `(() => {
|
||||
const edd = document.getElementById('editDropDown');
|
||||
if (!edd || edd.offsetWidth === 0) return { error: 'no_dropdown' };
|
||||
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
||||
const target = ny(${JSON.stringify(field.toLowerCase())});
|
||||
const items = [...edd.querySelectorAll('div')].filter(el =>
|
||||
el.offsetWidth > 0 && el.innerText?.trim() && !el.innerText.includes('\\n'));
|
||||
const match = items.find(el => ny(el.innerText.trim().toLowerCase()) === target)
|
||||
|| items.find(el => ny(el.innerText.trim().toLowerCase()).includes(target));
|
||||
if (!match) return { error: 'field_not_found', available: items.map(el => el.innerText.trim()) };
|
||||
const r = match.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), name: match.innerText.trim() };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read advanced search dialog state — FieldSelector value, Pattern input id+value,
|
||||
* and field type flags (isDate via iCalendB button, isRef via iDLB button on Pattern).
|
||||
*
|
||||
* Returns `{ fieldSelector, patternValue, patternId, isDate, isRef }`.
|
||||
*/
|
||||
export function readFilterDialogInfoScript(dialogForm) {
|
||||
return `(() => {
|
||||
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
|
||||
const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||||
.find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id));
|
||||
const ptInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||||
.find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id));
|
||||
const ptLabel = ptInput?.closest('label');
|
||||
const btns = ptLabel ? [...ptLabel.querySelectorAll('span.btn')].map(b => b.className) : [];
|
||||
const isDate = btns.some(c => c.includes('iCalendB'));
|
||||
const isRef = !isDate && btns.some(c => c.includes('iDLB'));
|
||||
return {
|
||||
fieldSelector: fsInput?.value?.trim() || '',
|
||||
patternValue: ptInput?.value?.trim() || '',
|
||||
patternId: ptInput?.id || '',
|
||||
isDate,
|
||||
isRef
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the × close button on the filter badge whose title matches `field`
|
||||
* (exact → includes; normalizes ё/е and NBSP).
|
||||
*
|
||||
* Returns:
|
||||
* - `{ x, y, field }` — coords + actual field title from the badge
|
||||
* - `{ error, available }` — `'not_found'` with list of available badge titles
|
||||
*/
|
||||
export function findFilterBadgeCloseScript(formNum, field) {
|
||||
return `(() => {
|
||||
const p = 'form${formNum}_';
|
||||
const norm = s => s?.trim().replace(/\\u00a0/g, ' ').replace(/:$/, '').replace(/\\n/g, ' ') || '';
|
||||
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
||||
const target = ny(${JSON.stringify(field.toLowerCase())});
|
||||
const items = [...document.querySelectorAll('[id^="' + p + '"].trainItem')].filter(el => el.offsetWidth > 0);
|
||||
for (const item of items) {
|
||||
const titleEl = item.querySelector('.trainName');
|
||||
const title = ny(norm(titleEl?.innerText).toLowerCase());
|
||||
if (title === target || title.includes(target)) {
|
||||
const close = item.querySelector('.trainClose');
|
||||
if (close) {
|
||||
const r = close.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), field: norm(titleEl?.innerText) };
|
||||
}
|
||||
}
|
||||
}
|
||||
const available = items.map(item => norm(item.querySelector('.trainName')?.innerText));
|
||||
return { error: 'not_found', available };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the × close button on the FIRST visible filter badge (for clear-all loop).
|
||||
* Returns `{ x, y } | null`.
|
||||
*/
|
||||
export function findFirstFilterBadgeCloseScript(formNum) {
|
||||
return `(() => {
|
||||
const p = 'form${formNum}_';
|
||||
const item = [...document.querySelectorAll('[id^="' + p + '"].trainItem')]
|
||||
.find(el => el.offsetWidth > 0);
|
||||
if (!item) return null;
|
||||
const close = item.querySelector('.trainClose');
|
||||
if (!close) return null;
|
||||
const r = close.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
})()`;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// web-test dom/form-state v1.0 — combined detectForm + readForm + open tabs
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { DETECT_FORM_FN, DETECT_FORMS_FN, READ_FORM_FN } from './_shared.mjs';
|
||||
|
||||
/**
|
||||
* Combined: detect form + read form + read open tabs.
|
||||
* Single evaluate call instead of 3. Used by browser.getFormState().
|
||||
*/
|
||||
export function getFormStateScript() {
|
||||
return `(() => {
|
||||
${DETECT_FORM_FN}
|
||||
${DETECT_FORMS_FN}
|
||||
${READ_FORM_FN}
|
||||
const formNum = detectForm();
|
||||
const meta = detectForms();
|
||||
if (formNum === null) return { form: null, formCount: 0, message: 'No form detected' };
|
||||
const p = 'form' + formNum + '_';
|
||||
const formData = readForm(p);
|
||||
// Open tabs bar (present only when tab panel is enabled in 1C settings)
|
||||
const openTabs = [];
|
||||
document.querySelectorAll('[id^="openedCell_cmd_"]').forEach(el => {
|
||||
const text = el.innerText?.trim();
|
||||
if (!text) return;
|
||||
const entry = { name: text };
|
||||
if (el.classList.contains('select')) entry.active = true;
|
||||
openTabs.push(entry);
|
||||
});
|
||||
const activeTab = openTabs.find(t => t.active)?.name || null;
|
||||
const result = { form: formNum, activeTab, openForms: meta.allForms, formCount: meta.formCount, ...formData };
|
||||
if (meta.modal) result.modal = true;
|
||||
if (openTabs.length) result.openTabs = openTabs;
|
||||
return result;
|
||||
})()`;
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
// web-test dom/forms v1.6 — form detection, content read, click-target/field-button resolution
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { DETECT_FORM_FN, READ_FORM_FN } from './_shared.mjs';
|
||||
|
||||
/**
|
||||
* Detect the active form number.
|
||||
* Picks the form with the most visible elements (excluding form0 = home page).
|
||||
*/
|
||||
export function detectFormScript() {
|
||||
return `(() => {
|
||||
${DETECT_FORM_FN}
|
||||
return detectForm();
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read full form state for a given form number.
|
||||
* Uses shared READ_FORM_FN.
|
||||
*/
|
||||
export function readFormScript(formNum) {
|
||||
const p = `form${formNum}_`;
|
||||
return `(() => {
|
||||
${READ_FORM_FN}
|
||||
return readForm(${JSON.stringify(p)});
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a clickable element on the current form (button, hyperlink, tab, frame button).
|
||||
* Returns { id, kind, name } for Playwright page.click(), or { error, available }.
|
||||
* Supports synonym matching: visible text AND internal name from DOM ID.
|
||||
* Fuzzy order: exact name -> exact label -> includes name -> includes label.
|
||||
*/
|
||||
export function findClickTargetScript(formNum, text, { tableName, gridSelector } = {}) {
|
||||
const p = `form${formNum}_`;
|
||||
return `(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const target = ${JSON.stringify(text.toLowerCase().replace(/ё/g, 'е'))};
|
||||
const p = ${JSON.stringify(p)};
|
||||
const tableName = ${JSON.stringify(tableName || '')};
|
||||
const gridSelector = ${JSON.stringify(gridSelector || '')};
|
||||
const items = [];
|
||||
|
||||
// Buttons (a.press)
|
||||
[...document.querySelectorAll('a.press[id^="' + p + '"]')].filter(el => el.offsetWidth > 0).forEach(el => {
|
||||
const idName = el.id.replace(p, '');
|
||||
if (/_(?:DLB|CLR|OB|CB)$/.test(idName)) return;
|
||||
const span = el.querySelector('.submenuText') || el.querySelector('span');
|
||||
const text = norm(span?.textContent) || norm(el.innerText);
|
||||
if (!text && !el.classList.contains('pressCommand')) return;
|
||||
const isSubmenu = /^(?:Подменю|allActions)/i.test(idName);
|
||||
const item = { id: el.id, name: text || idName, label: idName, kind: isSubmenu ? 'submenu' : 'button' };
|
||||
// Icon-only buttons: use tooltip for fuzzy match (1C puts title on parent .framePress)
|
||||
if (!text) { const tip = norm(el.title || el.parentElement?.title || ''); if (tip) item.tooltip = tip; }
|
||||
items.push(item);
|
||||
});
|
||||
|
||||
// Hyperlinks (staticTextHyper)
|
||||
[...document.querySelectorAll('[id^="' + p + '"].staticTextHyper')].filter(el => el.offsetWidth > 0).forEach(el => {
|
||||
const idName = el.id.replace(p, '');
|
||||
const text = norm(el.innerText);
|
||||
items.push({ id: el.id, name: text, label: idName, kind: 'hyperlink' });
|
||||
});
|
||||
|
||||
// Frame buttons
|
||||
[...document.querySelectorAll('[id^="' + p + '"] .frameButton, [id^="' + p + '"].frameButton')].filter(el => el.offsetWidth > 0).forEach(el => {
|
||||
const text = norm(el.innerText);
|
||||
const idName = el.id.replace(p, '');
|
||||
if (!text && !idName) return;
|
||||
items.push({ id: el.id, name: text || idName, label: text ? '' : idName, kind: 'frameButton' });
|
||||
});
|
||||
|
||||
// Tumbler items (toggle switch segments)
|
||||
[...document.querySelectorAll('[id^="' + p + '"].tumblerItem')].filter(el => el.offsetWidth > 0).forEach(el => {
|
||||
const idName = el.id.replace(p, '');
|
||||
const text = norm(el.innerText);
|
||||
items.push({ id: el.id, name: text || idName, label: idName, kind: 'tumbler' });
|
||||
});
|
||||
|
||||
// Checkboxes (div.checkbox) — match by label or internal name
|
||||
[...document.querySelectorAll('[id^="' + p + '"].checkbox')].filter(el => el.offsetWidth > 0).forEach(el => {
|
||||
const idName = el.id.replace(p, '');
|
||||
const titleEl = document.getElementById(p + idName + '#title_text');
|
||||
const label = norm(titleEl?.innerText || '').replace(/:/g, '').trim();
|
||||
items.push({ id: el.id, name: label || idName, label: idName, kind: 'checkbox' });
|
||||
});
|
||||
|
||||
// Tabs (scoped to form)
|
||||
[...document.querySelectorAll('[data-content]')].filter(el => {
|
||||
if (el.offsetWidth === 0) return false;
|
||||
let node = el.parentElement;
|
||||
while (node) {
|
||||
if (node.id && node.id.startsWith(p)) return true;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
}).forEach(el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
items.push({ id: el.id, name: el.dataset.content, label: '', kind: 'tab',
|
||||
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
||||
});
|
||||
|
||||
// Navigation panel items (FormNavigationPanel) — in parent page{N}
|
||||
const formEl = document.querySelector('[id^="' + p + '"]');
|
||||
if (formEl) {
|
||||
let pageEl = formEl.parentElement;
|
||||
while (pageEl && !(pageEl.id && /^page\\d+$/.test(pageEl.id))) pageEl = pageEl.parentElement;
|
||||
if (pageEl) {
|
||||
pageEl.querySelectorAll('.navigationItem').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const nameEl = el.querySelector('.navigationItemName');
|
||||
const text = norm(nameEl?.innerText || '');
|
||||
if (!text) return;
|
||||
items.push({ id: el.id, name: text, label: '', kind: 'navigation' });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// When table is specified, scope button search to grid's parent container
|
||||
if (gridSelector) {
|
||||
const gridEl = document.querySelector(gridSelector);
|
||||
if (gridEl) {
|
||||
// Find parent container that has id with formPrefix and contains the grid
|
||||
let container = gridEl.parentElement;
|
||||
while (container && container !== document.body) {
|
||||
if (container.id && container.id.startsWith(p)) break;
|
||||
container = container.parentElement;
|
||||
}
|
||||
// Filter items to those inside the container
|
||||
const containerItems = container && container !== document.body
|
||||
? items.filter(i => { const el = document.getElementById(i.id); return el && container.contains(el); })
|
||||
: [];
|
||||
// Try fuzzy match within container first
|
||||
let cf = containerItems.find(i => i.name.toLowerCase() === target);
|
||||
if (!cf) cf = containerItems.find(i => i.label && i.label.toLowerCase() === target);
|
||||
if (!cf && target.length >= 4) cf = containerItems.find(i => i.name.toLowerCase().includes(target));
|
||||
if (!cf && target.length >= 4) cf = containerItems.find(i => i.label && i.label.toLowerCase().includes(target));
|
||||
if (cf) { const res = { id: cf.id, kind: cf.kind, name: cf.name }; if (cf.x != null) { res.x = cf.x; res.y = cf.y; } return res; }
|
||||
// Fallback: filter by gridName id-prefix (e.g. ИсходящиеКоманднаяПанель_Добавить)
|
||||
const gridName = gridEl.id ? gridEl.id.replace(p, '') : '';
|
||||
if (gridName) {
|
||||
const prefixItems = items.filter(i => i.label && i.label.includes(gridName));
|
||||
let pf = prefixItems.find(i => i.name.toLowerCase() === target);
|
||||
if (!pf && target.length >= 4) pf = prefixItems.find(i => i.label && i.label.toLowerCase().includes(target));
|
||||
if (!pf && target.length >= 4) pf = prefixItems.find(i => i.name.toLowerCase().includes(target));
|
||||
if (pf) { const res = { id: pf.id, kind: pf.kind, name: pf.name }; if (pf.x != null) { res.x = pf.x; res.y = pf.y; } return res; }
|
||||
}
|
||||
}
|
||||
// Fall through to unscoped search
|
||||
}
|
||||
|
||||
// Fuzzy match: exact name -> exact label -> exact tooltip -> startsWith name -> startsWith label -> includes name -> includes label -> includes tooltip
|
||||
// Skip includes() for short strings (< 4 chars) to avoid false positives
|
||||
// e.g. "Да" matching "КомандаУстановитьВсе"
|
||||
let found = items.find(i => i.name.toLowerCase() === target);
|
||||
if (!found) found = items.find(i => i.label && i.label.toLowerCase() === target);
|
||||
if (!found) found = items.find(i => i.tooltip && i.tooltip.toLowerCase() === target);
|
||||
if (!found) found = items.find(i => i.name.toLowerCase().startsWith(target));
|
||||
if (!found) found = items.find(i => i.label && i.label.toLowerCase().startsWith(target));
|
||||
if (!found && target.length >= 4) found = items.find(i => i.name.toLowerCase().includes(target));
|
||||
if (!found && target.length >= 4) found = items.find(i => i.label && i.label.toLowerCase().includes(target));
|
||||
if (!found && target.length >= 4) found = items.find(i => i.tooltip && i.tooltip.toLowerCase().includes(target));
|
||||
|
||||
if (found) {
|
||||
const res = { id: found.id, kind: found.kind, name: found.name };
|
||||
if (found.x != null) { res.x = found.x; res.y = found.y; }
|
||||
return res;
|
||||
}
|
||||
|
||||
// Grid rows — fallback: search in table rows (for hierarchical/tree navigation)
|
||||
// Search ALL visible grids (or specific grid when table parameter is set)
|
||||
let grids;
|
||||
if (gridSelector) {
|
||||
const g = document.querySelector(gridSelector);
|
||||
grids = g ? [g] : [];
|
||||
} else {
|
||||
grids = [...document.querySelectorAll('[id^="' + p + '"].grid')].filter(g => g.offsetWidth > 0);
|
||||
}
|
||||
for (const grid of grids) {
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) continue;
|
||||
const lines = [...body.querySelectorAll('.gridLine')];
|
||||
for (const line of lines) {
|
||||
const textBoxes = [...line.querySelectorAll('.gridBoxText')].filter(b => b.offsetWidth > 0);
|
||||
const rowTexts = textBoxes.map(b => norm(b.innerText) || '').filter(Boolean);
|
||||
const firstCell = rowTexts[0]?.toLowerCase() || '';
|
||||
const rowText = rowTexts.join(' ').toLowerCase();
|
||||
if (firstCell === target || rowText === target || (target.length >= 4 && (firstCell.includes(target) || rowText.includes(target)))) {
|
||||
const imgBox = line.querySelector('.gridBoxImg');
|
||||
const isGroup = imgBox?.querySelector('.gridListH') !== null;
|
||||
const isParent = imgBox?.querySelector('.gridListV') !== null;
|
||||
const isTreeNode = line.querySelector('.gridBoxTree') !== null;
|
||||
const hasChildren = line.querySelector('[tree="true"]') !== null;
|
||||
let kind;
|
||||
if (isGroup) kind = 'gridGroup';
|
||||
else if (isParent) kind = 'gridParent';
|
||||
else if (isTreeNode && hasChildren) kind = 'gridTreeNode';
|
||||
else kind = 'gridRow';
|
||||
const r = line.getBoundingClientRect();
|
||||
return { id: '', kind, name: rowTexts[0] || '', gridId: grid.id,
|
||||
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Form input fields — LAST resort: focus a field by name/label without changing its value.
|
||||
// Only when no table scope is given ("если нет уточнения таблицы"): grid cells are handled elsewhere.
|
||||
// Reached only after every clickable target (button/link/tab/nav/grid row) failed to match,
|
||||
// so collisions between a field name and a real control are unlikely.
|
||||
const fields = [];
|
||||
if (!tableName) {
|
||||
[...document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]')].forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
// Skip inputs inside a grid — those are table cells, not form fields.
|
||||
let n = el.parentElement; let inGrid = false;
|
||||
while (n) { if (n.classList && n.classList.contains('grid')) { inGrid = true; break; } n = n.parentElement; }
|
||||
if (inGrid) return;
|
||||
const idName = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
||||
const titleEl = document.getElementById(p + idName + '#title_text') || document.getElementById(p + idName + '#title_div');
|
||||
const label = norm(titleEl?.innerText || '').replace(/:/g, '').trim();
|
||||
fields.push({ id: el.id, name: idName, label });
|
||||
});
|
||||
let ff = fields.find(f => f.label && f.label.toLowerCase() === target);
|
||||
if (!ff) ff = fields.find(f => f.name.toLowerCase() === target);
|
||||
if (!ff) ff = fields.find(f => f.label && f.label.toLowerCase().startsWith(target));
|
||||
if (!ff) ff = fields.find(f => f.name.toLowerCase().startsWith(target));
|
||||
if (!ff && target.length >= 4) ff = fields.find(f => f.label && f.label.toLowerCase().includes(target));
|
||||
if (!ff && target.length >= 4) ff = fields.find(f => f.name.toLowerCase().includes(target));
|
||||
if (ff) return { id: ff.id, kind: 'field', name: ff.label || ff.name };
|
||||
}
|
||||
|
||||
const available = items.map(i => i.tooltip ? i.name + ' [' + i.tooltip + ']' : i.name).filter(Boolean);
|
||||
for (const f of fields) { const nm = f.label || f.name; if (nm && !available.includes(nm)) available.push(nm); }
|
||||
return { error: 'not_found', available };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a field's action button (DLB, OB, CLR, CB) by fuzzy field name.
|
||||
* Returns { fieldName, buttonId, buttonType } or { error, available }.
|
||||
*/
|
||||
export function findFieldButtonScript(formNum, fieldName, buttonSuffix = 'DLB') {
|
||||
const p = `form${formNum}_`;
|
||||
return `(() => {
|
||||
const p = ${JSON.stringify(p)};
|
||||
const target = ${JSON.stringify(fieldName.toLowerCase().replace(/ё/g, 'е'))};
|
||||
const suffix = ${JSON.stringify(buttonSuffix)};
|
||||
const allFields = [];
|
||||
document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
||||
const titleEl = document.getElementById(p + name + '#title_text')
|
||||
|| document.getElementById(p + name + '#title_div');
|
||||
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
||||
allFields.push({ name, label });
|
||||
});
|
||||
// Also collect checkboxes for DCS pair matching
|
||||
const allCheckboxes = [];
|
||||
document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const name = el.id.replace(p, '');
|
||||
const titleEl = document.getElementById(p + name + '#title_text');
|
||||
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
||||
allCheckboxes.push({ inputId: el.id, name, label });
|
||||
});
|
||||
// Build DCS pairs: checkbox label → paired value field
|
||||
const dcsPairs = {};
|
||||
for (const f of [...allFields, ...allCheckboxes]) {
|
||||
const m = f.name.match(/^(.+Элемент\\d+)(Использование|Значение)$/);
|
||||
if (!m) continue;
|
||||
if (!dcsPairs[m[1]]) dcsPairs[m[1]] = {};
|
||||
dcsPairs[m[1]][m[2]] = f;
|
||||
}
|
||||
let found = allFields.find(f => f.name.toLowerCase() === target);
|
||||
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase() === target);
|
||||
if (!found) found = allFields.find(f => f.name.toLowerCase().includes(target));
|
||||
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase().includes(target));
|
||||
// DCS pair: match checkbox or value label → resolve to paired value field
|
||||
let dcsCheckbox = null;
|
||||
if (!found) {
|
||||
for (const pair of Object.values(dcsPairs)) {
|
||||
const cb = pair['Использование'];
|
||||
const val = pair['Значение'];
|
||||
if (!cb || !val) continue;
|
||||
const pairLabel = ((val.label || cb.label || '').replace(/:$/, '')).toLowerCase();
|
||||
if (pairLabel && (pairLabel === target || pairLabel.includes(target) || target.includes(pairLabel))) {
|
||||
found = val;
|
||||
dcsCheckbox = cb;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
return { error: 'field_not_found', available: allFields.map(f => f.label ? f.name + ' (' + f.label + ')' : f.name) };
|
||||
}
|
||||
const btnId = p + found.name + '_' + suffix;
|
||||
const btn = document.getElementById(btnId);
|
||||
if (!btn || btn.offsetWidth === 0) {
|
||||
return { error: 'button_not_found', fieldName: found.name, message: suffix + ' button not visible for field ' + found.name };
|
||||
}
|
||||
const result = { fieldName: found.name, buttonId: btnId, buttonType: suffix };
|
||||
if (dcsCheckbox) result.dcsCheckbox = { inputId: dcsCheckbox.inputId };
|
||||
return result;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve field names to element IDs for Playwright page.fill().
|
||||
* Returns [{ field, inputId, name, label }] or [{ field, error, available }].
|
||||
* Supports synonym matching: internal name AND visible label.
|
||||
* Fuzzy order: exact name -> exact label -> includes name -> includes label.
|
||||
*/
|
||||
export function resolveFieldsScript(formNum, fields) {
|
||||
const p = `form${formNum}_`;
|
||||
return `(() => {
|
||||
const p = ${JSON.stringify(p)};
|
||||
const fieldNames = ${JSON.stringify(Object.keys(fields))};
|
||||
const results = [];
|
||||
|
||||
// Build field map with name + label for synonym matching
|
||||
const allFields = [];
|
||||
document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
||||
const titleEl = document.getElementById(p + name + '#title_text')
|
||||
|| document.getElementById(p + name + '#title_div');
|
||||
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
||||
const last = { inputId: el.id, name, label };
|
||||
if (document.getElementById(p + name + '_DLB')?.offsetWidth > 0) last.hasSelect = true;
|
||||
const cbEl = document.getElementById(p + name + '_CB');
|
||||
if (cbEl?.offsetWidth > 0) {
|
||||
last.hasPick = true;
|
||||
if (cbEl.classList.contains('iCalendB')) last.isDate = true;
|
||||
else if (cbEl.classList.contains('iCalcB')) last.isCalc = true;
|
||||
}
|
||||
allFields.push(last);
|
||||
});
|
||||
// Checkboxes
|
||||
document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const name = el.id.replace(p, '');
|
||||
const titleEl = document.getElementById(p + name + '#title_text');
|
||||
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
||||
const checked = el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select');
|
||||
allFields.push({ inputId: el.id, name, label, isCheckbox: true, checked });
|
||||
});
|
||||
// Radio button groups — base element = option 0, others are #N#radio
|
||||
const radioSeen = new Set();
|
||||
document.querySelectorAll('[id^="' + p + '"].radio').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const id = el.id.replace(p, '');
|
||||
// Skip if already processed or if it's a sub-element (#N#radio)
|
||||
const m = id.match(/^(.+?)#(\\d+)#radio$/);
|
||||
const groupName = m ? m[1] : (!id.includes('#') ? id : null);
|
||||
if (!groupName || radioSeen.has(groupName)) return;
|
||||
radioSeen.add(groupName);
|
||||
const titleEl = document.getElementById(p + groupName + '#title_text');
|
||||
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
||||
// Collect options: option 0 is the base element, options 1+ have #N#radio
|
||||
const options = [];
|
||||
// Option 0: base element
|
||||
const base = document.getElementById(p + groupName);
|
||||
if (base && base.classList.contains('radio') && base.offsetWidth > 0) {
|
||||
const textEl = document.getElementById(p + groupName + '#0#radio_text');
|
||||
options.push({ index: 0, label: textEl?.innerText?.trim() || '', selected: base.classList.contains('select') });
|
||||
}
|
||||
// Options 1+
|
||||
for (let i = 1; i < 20; i++) {
|
||||
const opt = document.getElementById(p + groupName + '#' + i + '#radio');
|
||||
if (!opt || opt.offsetWidth === 0) break;
|
||||
const textEl = document.getElementById(p + groupName + '#' + i + '#radio_text');
|
||||
options.push({ index: i, label: textEl?.innerText?.trim() || '', selected: opt.classList.contains('select') });
|
||||
}
|
||||
allFields.push({ inputId: p + groupName, name: groupName, label, isRadio: true, options });
|
||||
});
|
||||
|
||||
// Build DCS pairs: checkbox label → paired value field
|
||||
const dcsPairs = {};
|
||||
for (const f of allFields) {
|
||||
const m = f.name.match(/^(.+Элемент\\d+)(Использование|Значение)$/);
|
||||
if (!m) continue;
|
||||
if (!dcsPairs[m[1]]) dcsPairs[m[1]] = {};
|
||||
dcsPairs[m[1]][m[2]] = f;
|
||||
}
|
||||
|
||||
for (const fieldName of fieldNames) {
|
||||
const target = fieldName.toLowerCase().replace(/\\n/g, ' ').replace(/:$/, '');
|
||||
// Fuzzy: exact name -> exact label -> includes name -> includes label
|
||||
let found = allFields.find(f => f.name.toLowerCase() === target);
|
||||
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase() === target);
|
||||
if (!found) found = allFields.find(f => f.name.toLowerCase().includes(target));
|
||||
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase().includes(target));
|
||||
// DCS pair: match checkbox or value label → resolve to paired value field
|
||||
if (!found) {
|
||||
for (const pair of Object.values(dcsPairs)) {
|
||||
const cb = pair['Использование'];
|
||||
const val = pair['Значение'];
|
||||
if (!cb || !val) continue;
|
||||
const pairLabel = ((val.label || cb.label || '').replace(/:$/, '')).toLowerCase();
|
||||
if (pairLabel && (pairLabel === target || pairLabel.includes(target) || target.includes(pairLabel))) {
|
||||
found = val;
|
||||
found._dcsCheckbox = cb;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (found) {
|
||||
const entry = { field: fieldName, inputId: found.inputId, name: found.name, label: found.label };
|
||||
if (found.isCheckbox) { entry.isCheckbox = true; entry.checked = found.checked; }
|
||||
if (found.isRadio) { entry.isRadio = true; entry.options = found.options; }
|
||||
if (found.hasSelect) entry.hasSelect = true;
|
||||
if (found.hasPick) entry.hasPick = true;
|
||||
if (found.isDate) entry.isDate = true;
|
||||
if (found.isCalc) entry.isCalc = true;
|
||||
if (found._dcsCheckbox) {
|
||||
entry.dcsCheckbox = { inputId: found._dcsCheckbox.inputId, checked: found._dcsCheckbox.checked };
|
||||
delete found._dcsCheckbox;
|
||||
}
|
||||
results.push(entry);
|
||||
} else {
|
||||
const available = allFields.map(f => f.label ? f.name + ' (' + f.label + ')' : f.name);
|
||||
results.push({ field: fieldName, error: 'not_found', available });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a new form opened above `prevFormNum`. Two modes:
|
||||
* default (broad) — counts any visible `[id]` element; finds dialogs whose
|
||||
* `a.press` buttons have empty IDs. Used by selectValue / fillTableRow.
|
||||
* `{ strict: true }` — only counts visible interactive elements
|
||||
* (`input.editInput[id], a.press[id]`); used by fillReferenceField.
|
||||
*
|
||||
* Returns the highest new form number or `null`.
|
||||
*/
|
||||
export function detectNewFormScript(prevFormNum, { strict = false } = {}) {
|
||||
const selector = strict ? 'input.editInput[id], a.press[id]' : '[id]';
|
||||
const visibleCheck = strict
|
||||
? 'el.offsetWidth === 0'
|
||||
: 'el.offsetWidth === 0 && el.offsetHeight === 0';
|
||||
return `(() => {
|
||||
const forms = {};
|
||||
document.querySelectorAll(${JSON.stringify(selector)}).forEach(el => {
|
||||
if (${visibleCheck}) return;
|
||||
const m = el.id.match(/^form(\\d+)_/);
|
||||
if (m) forms[m[1]] = true;
|
||||
});
|
||||
const nums = Object.keys(forms).map(Number).filter(n => n > ${prevFormNum});
|
||||
return nums.length > 0 ? Math.max(...nums) : null;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the search input on a list form (matches `SearchString` / `ПоискаСтроки` id).
|
||||
* Returns `{ id, value } | null`.
|
||||
*/
|
||||
export function findSearchInputScript(formNum) {
|
||||
return `(() => {
|
||||
const p = 'form${formNum}_';
|
||||
const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||||
.find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id));
|
||||
return el ? { id: el.id, value: el.value || '' } : null;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a visible `a.press` button by its exact innerText (after trim).
|
||||
* Returns `{ x, y } | null` for `page.mouse.click(x, y)`.
|
||||
*
|
||||
* Used for modal dialog buttons (Найти, OK) where page.click may be blocked.
|
||||
*/
|
||||
export function findNamedButtonScript(buttonText) {
|
||||
return `(() => {
|
||||
const btns = [...document.querySelectorAll('a.press')].filter(el => el.offsetWidth > 0);
|
||||
const btn = btns.find(el => el.innerText?.trim() === ${JSON.stringify(buttonText)});
|
||||
if (!btn) return null;
|
||||
const r = btn.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a CompareType radio button by index (1 = "contains", 2 = "exact", etc.)
|
||||
* on a search/filter dialog.
|
||||
*
|
||||
* Returns:
|
||||
* - `{ already: true }` — the group is disabled OR the radio is already selected
|
||||
* - `{ x, y } | null` — coords to click, or null if radio not present
|
||||
*/
|
||||
export function findCompareTypeRadioScript(dialogForm, radioIndex) {
|
||||
return `(() => {
|
||||
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
|
||||
const group = document.getElementById(p + 'CompareType');
|
||||
if (group && group.classList.contains('disabled')) return { already: true };
|
||||
const el = document.getElementById(p + 'CompareType#' + ${JSON.stringify(String(radioIndex))} + '#radio');
|
||||
if (!el || el.offsetWidth === 0) return null;
|
||||
if (el.classList.contains('select')) return { already: true };
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is any element of `form{dialogForm}_` currently visible?
|
||||
* Used to poll dialog dismissal after Escape.
|
||||
*/
|
||||
export function isFormVisibleScript(dialogForm) {
|
||||
return `(() => {
|
||||
const p = 'form${dialogForm}_';
|
||||
return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0);
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the Pattern input id on a search/filter dialog. Returns `id | null`.
|
||||
*/
|
||||
export function findPatternInputIdScript(dialogForm) {
|
||||
return `(() => {
|
||||
const p = 'form${dialogForm}_';
|
||||
const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||||
.find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id));
|
||||
return el ? el.id : null;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the given form a type selection dialog ("Выбор типа данных")?
|
||||
*
|
||||
* Detection signals (any one is sufficient):
|
||||
* - `form{N}_OK` element exists (selection forms use "Выбрать", not "OK")
|
||||
* - `form{N}_ValueList` grid exists (specific to type/value list dialogs)
|
||||
* - window title contains "Выбор типа" on a visible `.toplineBoxTitle`
|
||||
*
|
||||
* Returns boolean.
|
||||
*/
|
||||
export function isTypeDialogScript(formNum) {
|
||||
return `(() => {
|
||||
const p = 'form' + ${formNum} + '_';
|
||||
const hasOK = !!document.getElementById(p + 'OK');
|
||||
const hasValueList = !!document.getElementById(p + 'ValueList');
|
||||
const hasTitle = [...document.querySelectorAll('.toplineBoxTitle')]
|
||||
.some(el => el.offsetWidth > 0 && /выбор типа/i.test(el.getAttribute('title') || ''));
|
||||
return hasOK || hasValueList || hasTitle;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "Показать все" / "Show all" link inside the "нет в списке"
|
||||
* cloud popup via `dispatchEvent`. Returns boolean — whether clicked.
|
||||
*/
|
||||
export function clickShowAllInNotInListCloudScript() {
|
||||
return `(() => {
|
||||
for (const el of document.querySelectorAll('div')) {
|
||||
if (el.offsetWidth === 0 || el.offsetHeight === 0) continue;
|
||||
const s = getComputedStyle(el);
|
||||
if (s.position !== 'absolute' && s.position !== 'fixed') continue;
|
||||
if ((parseInt(s.zIndex) || 0) < 100) continue;
|
||||
if (!(el.innerText || '').includes('нет в списке')) continue;
|
||||
const links = [...el.querySelectorAll('a, span, div')]
|
||||
.filter(e => e.offsetWidth > 0 && e.children.length === 0);
|
||||
const showAll = links.find(e => {
|
||||
const t = (e.innerText?.trim() || '').toLowerCase();
|
||||
return t === 'показать все' || t === 'show all';
|
||||
});
|
||||
if (showAll) {
|
||||
const r = showAll.getBoundingClientRect();
|
||||
const opts = { bubbles:true, cancelable:true,
|
||||
clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
|
||||
showAll.dispatchEvent(new MouseEvent('mousedown', opts));
|
||||
showAll.dispatchEvent(new MouseEvent('mouseup', opts));
|
||||
showAll.dispatchEvent(new MouseEvent('click', opts));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the "нет в списке" cloud popup visible? 1C shows it as a positioned div
|
||||
* (absolute/fixed, high z-index) whose text contains "нет в списке".
|
||||
* Returns boolean.
|
||||
*/
|
||||
export function isNotInListCloudVisibleScript() {
|
||||
return `(() => {
|
||||
const divs = document.querySelectorAll('div');
|
||||
for (const el of divs) {
|
||||
if (el.offsetWidth === 0 || el.offsetHeight === 0) continue;
|
||||
const style = getComputedStyle(el);
|
||||
if (style.position !== 'absolute' && style.position !== 'fixed') continue;
|
||||
const z = parseInt(style.zIndex) || 0;
|
||||
if (z < 100) continue;
|
||||
if ((el.innerText || '').includes('нет в списке')) return true;
|
||||
}
|
||||
return false;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a child form opened above `prevFormNum` whose `form{N}_{buttonName}` button is visible.
|
||||
* Used by type-dialog Ctrl+F flow to locate the "Найти" sub-dialog form number.
|
||||
* Returns the form number or `null`.
|
||||
*/
|
||||
export function findChildFormByButtonScript(prevFormNum, buttonName, range = 20) {
|
||||
return `(() => {
|
||||
for (let n = ${prevFormNum} + 1; n < ${prevFormNum} + ${range}; n++) {
|
||||
const btn = document.getElementById('form' + n + '_' + ${JSON.stringify(buttonName)});
|
||||
if (btn && btn.offsetWidth > 0) return n;
|
||||
}
|
||||
return null;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read visible rows of a type-dialog ValueList grid and return rows that fuzzy-match `typeNorm`.
|
||||
*
|
||||
* `typeNorm` should already be lowercased, NBSP-normalized, ё→е normalized (use `normYo`).
|
||||
*
|
||||
* Returns `{ visible: string[], matches: Array<{ text, x, y }> }`.
|
||||
*/
|
||||
export function readTypeDialogVisibleRowsScript(formNum, typeNorm) {
|
||||
return `(() => {
|
||||
const grid = document.getElementById('form${formNum}_ValueList');
|
||||
if (!grid) return { visible: [], matches: [] };
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return { visible: [], matches: [] };
|
||||
const lines = body.querySelectorAll('.gridLine');
|
||||
const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim();
|
||||
const typeNorm = ${JSON.stringify(typeNorm)};
|
||||
const visible = [];
|
||||
const matches = [];
|
||||
for (const line of lines) {
|
||||
const text = norm(line.innerText);
|
||||
if (!text) continue;
|
||||
visible.push(text);
|
||||
if (text.toLowerCase().replace(/ё/gi, 'е').includes(typeNorm)) {
|
||||
const r = line.getBoundingClientRect();
|
||||
matches.push({ text, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
||||
}
|
||||
}
|
||||
return { visible, matches };
|
||||
})()`;
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// web-test dom/grid-edit v1.0 — DOM scripts for row-fill (grid edit-time operations)
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// All helpers below accept an optional `gridSelector`. When passed, they target
|
||||
// that exact grid; when null/undefined they pick the LAST visible `.grid` on
|
||||
// the page (this matches the implicit "current grid" used by row-fill).
|
||||
|
||||
/** Inline JS fragment that resolves the target grid into `const grid`. */
|
||||
function gridResolver(gridSelector) {
|
||||
return gridSelector
|
||||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||||
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the grid's column header texts paired with their `colindex` attribute,
|
||||
* fuzzy-match `fieldKeys` (lowercased) against them, and return the keys in
|
||||
* left-to-right colindex order.
|
||||
*
|
||||
* Keys that don't match a column get colindex `999` (pushed to the end);
|
||||
* caller is expected to preserve their original relative order.
|
||||
*
|
||||
* Returns `string[] | null` (null when no grid or no head).
|
||||
*/
|
||||
export function sortFieldKeysByColindexScript(gridSelector, fieldKeys) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return null;
|
||||
const head = grid.querySelector('.gridHead');
|
||||
if (!head) return null;
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
const cols = [];
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const t = ((box.querySelector('.gridBoxText') || box).innerText?.trim() || '').toLowerCase();
|
||||
const ci = parseInt(box.getAttribute('colindex') || '-1');
|
||||
if (t) cols.push({ text: t, colindex: ci });
|
||||
});
|
||||
const keys = ${JSON.stringify(fieldKeys)};
|
||||
const mapped = keys.map(k => {
|
||||
const exact = cols.find(c => c.text === k);
|
||||
if (exact) return { key: k, colindex: exact.colindex };
|
||||
const inc = cols.find(c => c.text.includes(k) || k.includes(c.text));
|
||||
return { key: k, colindex: inc ? inc.colindex : 999 };
|
||||
});
|
||||
mapped.sort((a, b) => a.colindex - b.colindex);
|
||||
return mapped.map(m => m.key);
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve cell coords for row `row` by matching the first column whose header
|
||||
* fuzzy-matches any of `fieldKeys` (lowercased). Falls back to the second
|
||||
* visible (non-`.gridBoxComp`) box when no header matches.
|
||||
*
|
||||
* Returns one of:
|
||||
* - `{ x, y, currentText }` — coords + cell text
|
||||
* - `{ error: 'no_grid' | 'no_grid_body' | 'no_cell' }`
|
||||
* - `{ error: 'row_out_of_range', total }`
|
||||
*/
|
||||
export function findCellCoordsByFieldsScript(gridSelector, row, fieldKeys) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return { error: 'no_grid' };
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!head || !body) return { error: 'no_grid_body' };
|
||||
|
||||
// Read column headers to find target colindex
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
const cols = [];
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const t = box.querySelector('.gridBoxText');
|
||||
const ci = box.getAttribute('colindex');
|
||||
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() });
|
||||
});
|
||||
|
||||
const keys = ${JSON.stringify(fieldKeys)};
|
||||
let targetColindex = null;
|
||||
for (const key of keys) {
|
||||
const exact = cols.find(c => c.text === key);
|
||||
if (exact) { targetColindex = exact.colindex; break; }
|
||||
const inc = cols.find(c => c.text.includes(key) || key.includes(c.text));
|
||||
if (inc) { targetColindex = inc.colindex; break; }
|
||||
}
|
||||
|
||||
const rows = [...body.querySelectorAll('.gridLine')];
|
||||
if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length };
|
||||
const line = rows[${row}];
|
||||
|
||||
// Find body cell by colindex (reliable across merged headers)
|
||||
let box = null;
|
||||
if (targetColindex != null) {
|
||||
box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex);
|
||||
}
|
||||
// Fallback: second visible box (skip checkbox/N column)
|
||||
if (!box) {
|
||||
const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
|
||||
box = boxes.length > 1 ? boxes[1] : boxes[0];
|
||||
}
|
||||
if (!box) return { error: 'no_cell' };
|
||||
box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
const cell = box.querySelector('.gridBoxText') || box;
|
||||
const r = cell.getBoundingClientRect();
|
||||
const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `findCellCoordsByFieldsScript` but for a SINGLE key, with extra
|
||||
* "no-space/no-dash" fuzzy fallback (e.g. "Группа Контрагентов" header matches
|
||||
* key "ГруппаКонтрагентов").
|
||||
*
|
||||
* Returns `{ x, y, currentText } | null`.
|
||||
*/
|
||||
export function findNextCellCoordsByKeyScript(gridSelector, row, key) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return null;
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!head || !body) return null;
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
const cols = [];
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const t = box.querySelector('.gridBoxText');
|
||||
const ci = box.getAttribute('colindex');
|
||||
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() });
|
||||
});
|
||||
const kl = ${JSON.stringify(key.toLowerCase())};
|
||||
const klNoSpace = kl.replace(/[\\s\\-]+/g, '');
|
||||
let targetColindex = null;
|
||||
const exact = cols.find(c => c.text === kl);
|
||||
if (exact) targetColindex = exact.colindex;
|
||||
else {
|
||||
const inc = cols.find(c => c.text.includes(kl) || kl.includes(c.text)
|
||||
|| c.text.includes(klNoSpace) || klNoSpace.includes(c.text));
|
||||
if (inc) targetColindex = inc.colindex;
|
||||
}
|
||||
if (targetColindex == null) return null;
|
||||
const rows = [...body.querySelectorAll('.gridLine')];
|
||||
if (${row} >= rows.length) return null;
|
||||
const line = rows[${row}];
|
||||
const box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex);
|
||||
if (!box) return null;
|
||||
box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
const cell = box.querySelector('.gridBoxText') || box;
|
||||
const r = cell.getBoundingClientRect();
|
||||
const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect the element at point `(x, y)`. If it's inside a `.gridBox` containing
|
||||
* a `.checkbox`, return `{ checked, x, y }` (coords of the checkbox center for
|
||||
* direct click).
|
||||
*
|
||||
* Returns `null` when there's no cell, or the cell isn't a checkbox cell.
|
||||
*/
|
||||
export function findCheckboxAtPointScript(x, y) {
|
||||
return `(() => {
|
||||
const el = document.elementFromPoint(${x}, ${y});
|
||||
const cell = el?.closest('.gridBox');
|
||||
if (!cell) return null;
|
||||
const chk = cell.querySelector('.checkbox');
|
||||
if (!chk) return null;
|
||||
const r = chk.getBoundingClientRect();
|
||||
return { checked: chk.classList.contains('select'), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find center coords of the first VISIBLE non-`.gridBoxComp` cell on a row
|
||||
* OTHER than `row` (used to commit an edit by clicking off the edited row —
|
||||
* Escape would cancel in tree grids).
|
||||
*
|
||||
* For `row === 0`, targets row 1; otherwise targets row 0.
|
||||
*
|
||||
* Returns `{ x, y } | null` (null when there's no other row).
|
||||
*/
|
||||
export function findRowCommitClickCoordsScript(gridSelector, row) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return null;
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return null;
|
||||
const rows = [...body.querySelectorAll('.gridLine')];
|
||||
const otherIdx = ${row} === 0 ? 1 : 0;
|
||||
const other = rows[otherIdx];
|
||||
if (!other) return null;
|
||||
const visBoxes = [...other.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
|
||||
const box = visBoxes.length > 1 ? visBoxes[1] : visBoxes[0];
|
||||
if (!box) return null;
|
||||
const r = box.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic: are we in grid edit mode (active INPUT inside `.grid` or
|
||||
* `.gridContent`)? Returns an OBJECT (not a boolean) suitable for diagnostics:
|
||||
* - `{ inEdit: true }` — good
|
||||
* - `{ inEdit: false, tag: 'DIV' }` — active element wasn't INPUT
|
||||
* - `{ inEdit: false, hint: 'input not inside grid' }` — input but no grid ancestor
|
||||
*/
|
||||
export function getGridEditCheckScript() {
|
||||
return `(() => {
|
||||
const f = document.activeElement;
|
||||
if (!f || f.tagName !== 'INPUT') return { inEdit: false, tag: f?.tagName };
|
||||
let node = f;
|
||||
while (node) {
|
||||
if (node.classList?.contains('grid') || node.classList?.contains('gridContent')) return { inEdit: true };
|
||||
node = node.parentElement;
|
||||
}
|
||||
return { inEdit: false, hint: 'input not inside grid' };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the currently focused element if it's an editable grid cell (INPUT or
|
||||
* TEXTAREA inside `.grid` / `.gridContent`). Resolves the header text by
|
||||
* matching x-overlap of the input's bounding rect against header boxes.
|
||||
*
|
||||
* Returns one of:
|
||||
* - `{ tag: 'INPUT', id, fullName, headerText }` — editable cell
|
||||
* - `{ tag: 'DIV' | 'BODY' | ... }` — focused but not an editable cell
|
||||
* - `{ tag: 'none' }` — nothing focused
|
||||
*
|
||||
* `fullName` strips both `form{N}_` prefix and `_i{M}` suffix.
|
||||
*/
|
||||
export function readActiveGridCellScript() {
|
||||
return `(() => {
|
||||
const f = document.activeElement;
|
||||
if (!f) return { tag: 'none' };
|
||||
if (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA') {
|
||||
const inGrid = (() => { let n = f; while (n) { if (n.classList?.contains('grid') || n.classList?.contains('gridContent')) return true; n = n.parentElement; } return false; })();
|
||||
if (inGrid) {
|
||||
let headerText = '';
|
||||
let grid = f; while (grid && !grid.classList?.contains('grid')) grid = grid.parentElement;
|
||||
if (grid) {
|
||||
const fr = f.getBoundingClientRect();
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const hl = head?.querySelector('.gridLine') || head;
|
||||
if (hl) for (const h of hl.children) {
|
||||
if (h.offsetWidth === 0) continue;
|
||||
const hr = h.getBoundingClientRect();
|
||||
if (fr.x >= hr.x && fr.x < hr.x + hr.width) {
|
||||
const t = h.querySelector('.gridBoxText');
|
||||
headerText = (t || h).innerText?.trim() || '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Classify the cell's choice button (if any): ref (_DLB), calc/date (_CB iCalcB/iCalendB),
|
||||
// or bare 'choice' (_CB iCB — value picked from a programmatic list, e.g. НачалоВыбора).
|
||||
let buttonKind = null;
|
||||
const base = f.id.replace(/_i\\d+$/, '');
|
||||
const dlb = document.getElementById(base + '_DLB');
|
||||
const cb = document.getElementById(base + '_CB');
|
||||
if (dlb && dlb.offsetWidth > 0) buttonKind = 'ref';
|
||||
else if (cb && cb.offsetWidth > 0) {
|
||||
if (cb.classList.contains('iCalcB')) buttonKind = 'calc';
|
||||
else if (cb.classList.contains('iCalendB')) buttonKind = 'date';
|
||||
else buttonKind = 'choice';
|
||||
}
|
||||
return {
|
||||
tag: 'INPUT', id: f.id,
|
||||
fullName: f.id.replace(/^form\\d+_/, '').replace(/_i\\d+$/, ''),
|
||||
headerText, buttonKind
|
||||
};
|
||||
}
|
||||
}
|
||||
return { tag: f.tagName || 'none' };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return center coords of the element with the given id.
|
||||
* Returns `{ x, y } | null`.
|
||||
*/
|
||||
export function getElementCenterCoordsByIdScript(elementId) {
|
||||
return `(() => {
|
||||
const el = document.getElementById(${JSON.stringify(elementId)});
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||
})()`;
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
// web-test dom/grid v1.9 — grid resolution + table reading + edit-time helpers
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
/**
|
||||
* Resolve a specific grid by semantic name (table parameter).
|
||||
* Cascade: exact gridName match → gridName contains → column contains.
|
||||
* Returns { gridSelector, gridId, gridName, gridIndex, columns } or { error, available }.
|
||||
*/
|
||||
export function resolveGridScript(formNum, tableName) {
|
||||
const p = `form${formNum}_`;
|
||||
return `(() => {
|
||||
const p = ${JSON.stringify(p)};
|
||||
const target = ${JSON.stringify(tableName.toLowerCase().replace(/ё/g, 'е'))};
|
||||
const norm = s => (s || '').replace(/ё/gi, 'е');
|
||||
const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
|
||||
if (!allGrids.length) return { error: 'no_grids', message: 'No grids found on form' };
|
||||
const infos = allGrids.map((g, idx) => {
|
||||
const gridId = g.id || '';
|
||||
const gridName = gridId.replace(p, '');
|
||||
const head = g.querySelector('.gridHead');
|
||||
const columns = [];
|
||||
if (head) {
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const textEl = box.querySelector('.gridBoxText');
|
||||
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||||
if (text) columns.push(text);
|
||||
});
|
||||
}
|
||||
// Visual label from group title element
|
||||
const titleEl = document.getElementById(p + gridName + '#title_div')
|
||||
|| document.getElementById(p + 'Группа' + gridName + '#title_div');
|
||||
const label = titleEl ? (titleEl.innerText?.trim().replace(/:\s*$/, '').replace(/ /g, ' ') || '') : '';
|
||||
return { idx, gridId, gridName, label, columns, el: g };
|
||||
});
|
||||
// 1. Exact gridName match (case-insensitive)
|
||||
let found = infos.find(i => norm(i.gridName).toLowerCase() === target);
|
||||
// 2. Exact label match
|
||||
if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase() === target);
|
||||
// 3. gridName contains target
|
||||
if (!found) found = infos.find(i => norm(i.gridName).toLowerCase().includes(target));
|
||||
// 4. Label contains target
|
||||
if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase().includes(target));
|
||||
// 5. Any column contains target
|
||||
if (!found) found = infos.find(i => i.columns.some(c => norm(c).toLowerCase().includes(target)));
|
||||
if (found) {
|
||||
return {
|
||||
gridSelector: found.gridId ? '#' + CSS.escape(found.gridId) : null,
|
||||
gridId: found.gridId,
|
||||
gridName: found.gridName,
|
||||
gridIndex: found.idx,
|
||||
columns: found.columns
|
||||
};
|
||||
}
|
||||
return {
|
||||
error: 'not_found',
|
||||
message: 'Table "' + ${JSON.stringify(tableName)} + '" not found',
|
||||
available: infos.map(i => ({ name: i.gridName, ...(i.label ? { label: i.label } : {}), columns: i.columns }))
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read table/grid data with pagination.
|
||||
* Parses grid.innerText — \n separates rows, \t separates cells.
|
||||
* First row = column headers.
|
||||
* Returns { name, columns[], rows[{col:val}], total, offset, shown }.
|
||||
*/
|
||||
export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelector } = {}) {
|
||||
const p = `form${formNum}_`;
|
||||
return `(() => {
|
||||
const p = ${JSON.stringify(p)};
|
||||
const grid = ${gridSelector
|
||||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||||
: `[...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.find(g => g.offsetWidth > 0 && g.offsetHeight > 0)`};
|
||||
if (!grid) return { error: 'no_table', message: 'No table found on form ${formNum}' };
|
||||
const name = grid.id ? grid.id.replace(p, '') : '';
|
||||
|
||||
// Detect a "picture value" cell: a sprite from a picture collection
|
||||
// (.gridBoxImg .dIB with background-image .../pictureCollection/picture/<id>?...&gx=<N>).
|
||||
// Excludes decorative tree/group markers (gridListH/gridListV/[tree]/gridBoxTree).
|
||||
// Returns { gx } — the sprite frame index that encodes the cell state, or null.
|
||||
function picInfo(cell) {
|
||||
if (!cell) return null;
|
||||
if (cell.querySelector('.gridListH, .gridListV, [tree="true"], .gridBoxTree')) return null;
|
||||
const dib = cell.querySelector('.gridBoxImg .dIB');
|
||||
if (!dib) return null;
|
||||
const bg = dib.style.backgroundImage || '';
|
||||
if (!bg.includes('pictureCollection/picture/')) return null;
|
||||
const m = bg.match(/[?&]gx=(\\d+)/);
|
||||
return { gx: m ? m[1] : '0' };
|
||||
}
|
||||
|
||||
// DOM-based parsing: gridHead → columns, gridBody → gridLine rows → gridBox cells
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!head || !body) {
|
||||
// Fallback: innerText-based (for non-standard grids)
|
||||
const gText = grid.innerText?.trim() || '';
|
||||
const lines = gText.split('\\n').filter(Boolean);
|
||||
return { name, columns: [], rows: [], total: lines.length, offset: 0, shown: 0,
|
||||
hint: 'Grid has no gridHead/gridBody structure' };
|
||||
}
|
||||
|
||||
// Extract column headers with X-coordinates for alignment
|
||||
const columns = [];
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const textEl = box.querySelector('.gridBoxText');
|
||||
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||||
if (!text) {
|
||||
// Unnamed column — check if data cells contain checkboxes or pictures.
|
||||
// Picture columns have no header text (only an icon + a title tooltip); 1С
|
||||
// doesn't expose the technical column name in the DOM, so we name them by
|
||||
// the header's title attribute, falling back to '(picture)'.
|
||||
const firstLine = body?.querySelector('.gridLine');
|
||||
const visibleHeaders = [...headLine.children].filter(c => c.offsetWidth > 0);
|
||||
const idx = visibleHeaders.indexOf(box);
|
||||
const cells = firstLine ? [...firstLine.children].filter(c => c.offsetWidth > 0) : [];
|
||||
const r = box.getBoundingClientRect();
|
||||
if (cells[idx]?.querySelector('.checkbox')) {
|
||||
columns.push({ text: '(checkbox)', x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
|
||||
} else if (picInfo(box) || picInfo(cells[idx])) {
|
||||
let title = (box.getAttribute('title') || '').trim() || '(picture)';
|
||||
// Disambiguate duplicate picture-column names with a numeric suffix.
|
||||
if (columns.some(c => c.text === title)) {
|
||||
let n = 2;
|
||||
while (columns.some(c => c.text === title + ' ' + n)) n++;
|
||||
title = title + ' ' + n;
|
||||
}
|
||||
columns.push({ text: title, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const r = box.getBoundingClientRect();
|
||||
columns.push({ text, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
|
||||
});
|
||||
|
||||
// Multi-row grid support: detect stacked/merged headers.
|
||||
// Group headers by X-range. For each group, count data sub-rows from first line.
|
||||
// - Stacked headers (2+ headers at same X) with multiple data rows → match by Y-order
|
||||
// - Single merged header with multiple data rows → expand to numbered columns (e.g. "Субконто Дт 1")
|
||||
const xGroups = new Map();
|
||||
columns.forEach(c => {
|
||||
const key = Math.round(c.x) + ':' + Math.round(c.right);
|
||||
if (!xGroups.has(key)) xGroups.set(key, []);
|
||||
xGroups.get(key).push(c);
|
||||
});
|
||||
for (const [, hdrs] of xGroups) hdrs.sort((a, b) => a.y - b.y);
|
||||
|
||||
const firstDataLine = body?.querySelector('.gridLine');
|
||||
const subRowMap = new Map();
|
||||
if (firstDataLine) {
|
||||
[...firstDataLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const r = box.getBoundingClientRect();
|
||||
const cx = r.x + r.width / 2;
|
||||
for (const [key, hdrs] of xGroups) {
|
||||
const h0 = hdrs[0];
|
||||
if (cx >= h0.x && cx < h0.right) {
|
||||
if (!subRowMap.has(key)) subRowMap.set(key, []);
|
||||
subRowMap.get(key).push({ y: r.y });
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
for (const [, subs] of subRowMap) subs.sort((a, b) => a.y - b.y);
|
||||
}
|
||||
|
||||
const multiRowGroups = new Map();
|
||||
for (const [key, hdrs] of xGroups) {
|
||||
const subs = subRowMap.get(key);
|
||||
if (!subs || subs.length <= 1) continue;
|
||||
if (hdrs.length >= 2) {
|
||||
multiRowGroups.set(key, hdrs);
|
||||
} else if (hdrs.length === 1 && subs.length > 1) {
|
||||
const base = hdrs[0];
|
||||
const baseIdx = columns.indexOf(base);
|
||||
columns.splice(baseIdx, 1);
|
||||
const expanded = [];
|
||||
for (let si = 0; si < subs.length; si++) {
|
||||
const numbered = {
|
||||
text: base.text + ' ' + (si + 1),
|
||||
x: base.x, w: base.w, right: base.right,
|
||||
y: base.y + si, h: base.h / subs.length, _subIdx: si
|
||||
};
|
||||
columns.splice(baseIdx + si, 0, numbered);
|
||||
expanded.push(numbered);
|
||||
}
|
||||
multiRowGroups.set(key, expanded);
|
||||
}
|
||||
}
|
||||
|
||||
function matchColumn(cellX, cellW, cellY) {
|
||||
const cx = cellX + cellW / 2;
|
||||
for (const [key, hdrs] of multiRowGroups) {
|
||||
const h0 = hdrs[0];
|
||||
if (cx >= h0.x && cx < h0.right) {
|
||||
const subs = subRowMap.get(key);
|
||||
if (subs) {
|
||||
const subIdx = subs.findIndex(s => Math.abs(s.y - cellY) < 5);
|
||||
if (subIdx >= 0 && subIdx < hdrs.length) return hdrs[subIdx];
|
||||
}
|
||||
let best = hdrs[0], bestDist = Infinity;
|
||||
for (const h of hdrs) {
|
||||
const dist = Math.abs(cellY - h.y);
|
||||
if (dist < bestDist) { bestDist = dist; best = h; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
return columns.find(c => cx >= c.x && cx < c.right);
|
||||
}
|
||||
|
||||
// Extract data rows from gridBody
|
||||
const allLines = body.querySelectorAll('.gridLine');
|
||||
const total = allLines.length;
|
||||
const rows = [];
|
||||
const end = Math.min(${offset} + ${maxRows}, total);
|
||||
for (let i = ${offset}; i < end; i++) {
|
||||
const line = allLines[i];
|
||||
if (!line) break;
|
||||
const row = {};
|
||||
columns.forEach(c => { row[c.text] = ''; });
|
||||
[...line.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const textEl = box.querySelector('.gridBoxText');
|
||||
const chk = box.querySelector('.checkbox');
|
||||
let val;
|
||||
if (chk) {
|
||||
val = chk.classList.contains('select') ? 'true' : 'false';
|
||||
} else {
|
||||
val = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||||
if (!val) {
|
||||
// Empty text → maybe a picture cell. 'pic:<gx>' encodes the sprite frame
|
||||
// (state). Absent picture stays '' (truthy check distinguishes presence).
|
||||
const pic = picInfo(box);
|
||||
if (pic) val = 'pic:' + pic.gx;
|
||||
else return;
|
||||
}
|
||||
}
|
||||
// Match cell to column by X+Y overlap (multi-row aware)
|
||||
const r = box.getBoundingClientRect();
|
||||
const col = matchColumn(r.x, r.width, r.y);
|
||||
if (col) {
|
||||
row[col.text] = row[col.text] ? row[col.text] + ' / ' + val : val;
|
||||
}
|
||||
});
|
||||
// Detect row kind: group (gridListH), parent/up (gridListV), or element
|
||||
const imgBox = line.querySelector('.gridBoxImg');
|
||||
if (imgBox) {
|
||||
if (imgBox.querySelector('.gridListH')) row._kind = 'group';
|
||||
else if (imgBox.querySelector('.gridListV')) row._kind = 'parent';
|
||||
}
|
||||
// Tree mode: detect expand/collapse state and indent level
|
||||
const treeBox = line.querySelector('.gridBoxTree');
|
||||
if (treeBox) {
|
||||
const treeIcon = imgBox?.querySelector('[tree="true"]');
|
||||
if (treeIcon) {
|
||||
const bg = treeIcon.style.backgroundImage || '';
|
||||
row._tree = bg.includes('gx=0') ? 'expanded' : 'collapsed';
|
||||
}
|
||||
row._level = imgBox ? imgBox.querySelectorAll('.dIB').length - 1 : 0;
|
||||
}
|
||||
// Selection state: selRow = selected row in grid
|
||||
if (line.classList.contains('selRow') || line.classList.contains('select')) row._selected = true;
|
||||
rows.push(row);
|
||||
}
|
||||
const isTree = !!body.querySelector('.gridBoxTree');
|
||||
const hasGroups = rows.some(r => r._kind === 'group');
|
||||
// Virtualization-aware hasMore signal. Three sources in priority order:
|
||||
// 1. Dynamic-list turn buttons (#vertButtonScroll_<gridId>, sibling of grid).
|
||||
// Buttons carry data-home/data-up (above) and data-down/data-end (below);
|
||||
// class "disabled" on a direction means nothing to show there.
|
||||
// 2. Tabular-section scrollbar (#vertScroll_<gridId>, class scrollV) —
|
||||
// track-back/track-next pixel heights tell us above/below precisely.
|
||||
// 3. Fallback: scrollHeight>clientHeight for "below"; "above" unknown.
|
||||
let hasMore;
|
||||
const turnsBox = document.getElementById('vertButtonScroll_' + grid.id);
|
||||
if (turnsBox && turnsBox.offsetHeight > 0) {
|
||||
const upBtns = turnsBox.querySelectorAll('[data-home], [data-up]');
|
||||
const dnBtns = turnsBox.querySelectorAll('[data-down], [data-end]');
|
||||
hasMore = {
|
||||
above: [...upBtns].some(b => !b.classList.contains('disabled')),
|
||||
below: [...dnBtns].some(b => !b.classList.contains('disabled')),
|
||||
};
|
||||
} else {
|
||||
const vsId = 'vertScroll_' + grid.id;
|
||||
const vs = document.getElementById(vsId);
|
||||
if (vs && vs.classList.contains('scrollV') && vs.offsetWidth > 0) {
|
||||
const back = vs.querySelector('[data-track-back]')?.offsetHeight ?? 0;
|
||||
const next = vs.querySelector('[data-track-next]')?.offsetHeight ?? 0;
|
||||
hasMore = { above: back > 0, below: next > 0 };
|
||||
} else {
|
||||
hasMore = { below: body.scrollHeight > body.clientHeight };
|
||||
}
|
||||
}
|
||||
const result = { name, columns: columns.map(c => c.text), rows, total, offset: ${offset}, shown: rows.length, hasMore };
|
||||
if (isTree) result.viewMode = 'tree';
|
||||
if (hasGroups) result.hierarchical = true;
|
||||
return result;
|
||||
})()`;
|
||||
}
|
||||
|
||||
// ─── Edit-time grid helpers (for fillTableRow / row-fill) ────────────────────
|
||||
//
|
||||
// All helpers below accept an optional `gridSelector`. When passed, they target
|
||||
// that exact grid; when null/undefined they pick the LAST visible `.grid` on
|
||||
// the page (this matches the implicit "current grid" used by row-fill).
|
||||
|
||||
/** Inline JS fragment that resolves the target grid into `const grid`. */
|
||||
function gridResolver(gridSelector) {
|
||||
return gridSelector
|
||||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||||
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find center coords of a target row for click-select (used by deleteTableRow).
|
||||
* Picks the second visible gridBox container in the row (skips row-number/checkbox col).
|
||||
*
|
||||
* Returns `{ x, y, total } | { error: 'no_grid'|'no_grid_body'|'row_out_of_range'|'no_cell', total? }`.
|
||||
*/
|
||||
export function findDeleteRowCoordsScript(gridSelector, row) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return { error: 'no_grid' };
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return { error: 'no_grid_body' };
|
||||
const rows = [...body.querySelectorAll('.gridLine')];
|
||||
if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length };
|
||||
const line = rows[${row}];
|
||||
// Use visible gridBox containers (not gridBoxText) to avoid clicking checkboxes
|
||||
const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
|
||||
// Skip first column (row number / checkbox) — pick second visible box
|
||||
const box = boxes.length > 1 ? boxes[1] : boxes[0];
|
||||
if (!box) return { error: 'no_cell' };
|
||||
const cell = box.querySelector('.gridBoxText') || box;
|
||||
const r = cell.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), total: rows.length };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count `.gridLine` rows in the body of the target grid.
|
||||
* Returns the row count, or `0` when grid/body absent.
|
||||
*/
|
||||
export function countGridRowsScript(gridSelector) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
const body = grid?.querySelector('.gridBody');
|
||||
return body ? body.querySelectorAll('.gridLine').length : 0;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the target grid a tree grid? (presence of `.gridBoxTree`)
|
||||
* Returns boolean.
|
||||
*/
|
||||
export function isTreeGridScript(gridSelector) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
return grid ? !!grid.querySelector('.gridBoxTree') : false;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return center coords of the grid's `.gridHead` element.
|
||||
* Used as a click target to commit a pending cell edit (clicking the header
|
||||
* defocuses the input without selecting another row).
|
||||
*
|
||||
* Returns `{ x, y } | null`.
|
||||
*/
|
||||
export function findGridHeadCenterCoordsScript(gridSelector) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return null;
|
||||
const head = grid.querySelector('.gridHead');
|
||||
if (!head) return null;
|
||||
const r = head.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the index of the currently selected row in the target grid, or
|
||||
* fall back to the last row when nothing is selected.
|
||||
*
|
||||
* Returns row index, or `-1` when no rows.
|
||||
*/
|
||||
export function getSelectedOrLastRowIndexScript(gridSelector) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return -1;
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return -1;
|
||||
const lines = [...body.querySelectorAll('.gridLine')];
|
||||
const sel = lines.findIndex(l => l.classList.contains('selected'));
|
||||
return sel >= 0 ? sel : lines.length - 1;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a form's grid for a row matching `searchLower` (case- and ё-insensitive,
|
||||
* NBSP-normalised). Match order: exact → startsWith → includes.
|
||||
*
|
||||
* When `searchLower` is empty, returns coords of the first row (fallback).
|
||||
*
|
||||
* Returns `{ rowCount, x, y, isGroup } | { rowCount: 0 } | null`.
|
||||
*/
|
||||
export function scanGridRowsScript(formNum, searchLower) {
|
||||
return `(() => {
|
||||
const p = 'form${formNum}_';
|
||||
const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid');
|
||||
if (!grid) return null;
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return null;
|
||||
const lines = [...body.querySelectorAll('.gridLine')];
|
||||
if (!lines.length) return { rowCount: 0 };
|
||||
const searchLower = ${JSON.stringify(searchLower || '')};
|
||||
let sel = null;
|
||||
if (searchLower) {
|
||||
const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim().toLowerCase().replace(/ё/gi, 'е');
|
||||
const rowData = lines.map(l => ({ el: l, text: norm(l.innerText) }));
|
||||
sel = rowData.find(r => r.text === searchLower)?.el
|
||||
|| rowData.find(r => r.text.startsWith(searchLower))?.el
|
||||
|| rowData.find(r => r.text.includes(searchLower))?.el;
|
||||
} else {
|
||||
sel = lines[0]; // empty search → first row
|
||||
}
|
||||
if (!sel) return null;
|
||||
const imgBox = sel.querySelector('.gridBoxImg');
|
||||
const isGroup = imgBox ? !!imgBox.querySelector('.gridListH') : false;
|
||||
const r = sel.getBoundingClientRect();
|
||||
return { rowCount: lines.length, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isGroup };
|
||||
})()`;
|
||||
}
|
||||
|
||||
// ─── Cell-click DOM scripts (for clickElement({row, column}) on grids) ───────
|
||||
|
||||
/**
|
||||
* Resolve a target cell in a grid by (row, column).
|
||||
* - `column` matched: exact (case+ё-insensitive) → endsWith ' / X' → includes.
|
||||
* - `row`: number = index in current DOM window; object = {col: value, ...} filter
|
||||
* (matches first non-group/parent row where every column condition passes).
|
||||
*
|
||||
* Returns `{ x, y, cellX, cellRight, gridX, gridRight, columnText, rowIdx, cellText, visible } | { error, ... }`.
|
||||
*
|
||||
* Visibility (`visible`) is true when the cell is fully within the grid's horizontal viewport.
|
||||
* Callers should horizontally scroll first if `visible === false`.
|
||||
*/
|
||||
export function findGridCellScript(formNum, gridSelector, { row, column }) {
|
||||
const p = `form${formNum}_`;
|
||||
return `(() => {
|
||||
const norm = s => (s || '').replace(/\\u00a0/g, ' ').replace(/ё/gi, 'е').trim();
|
||||
const lo = s => norm(s).toLowerCase();
|
||||
|
||||
const p = ${JSON.stringify(p)};
|
||||
const grid = ${gridSelector
|
||||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||||
: `[...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.find(g => g.offsetWidth > 0 && g.offsetHeight > 0)`};
|
||||
if (!grid) return { error: 'no_grid' };
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!head || !body) return { error: 'no_grid_structure' };
|
||||
|
||||
// Header X-ranges (mirror of readTableScript logic, simplified). We also
|
||||
// remember whether each header is frozen (gridBoxFix) — frozen and scrollable
|
||||
// columns can share X coordinates after horizontal scroll, so cell matching
|
||||
// must respect the frozen/scrollable partition.
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
const headers = [...headLine.children]
|
||||
.filter(c => c.offsetWidth > 0)
|
||||
.map(c => {
|
||||
const textEl = c.querySelector('.gridBoxText');
|
||||
const text = (textEl || c).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||||
// Picture/icon columns have no header text — fall back to the title tooltip
|
||||
// (mirrors readTable naming) so they can still be targeted for clicking.
|
||||
const title = (c.getAttribute('title') || '').trim();
|
||||
const r = c.getBoundingClientRect();
|
||||
return { text, title, name: text || title, x: r.x, right: r.x + r.width, fixed: c.classList.contains('gridBoxFix') };
|
||||
})
|
||||
.filter(h => h.name);
|
||||
|
||||
const resolveCol = (name) => {
|
||||
const suffix = ' / ' + name;
|
||||
const cand = h => [h.text, h.title].filter(Boolean);
|
||||
return headers.find(h => cand(h).some(t => lo(t) === lo(name)))
|
||||
|| headers.find(h => cand(h).some(t => t.endsWith(suffix)))
|
||||
|| headers.find(h => cand(h).some(t => lo(t).includes(lo(name))));
|
||||
};
|
||||
|
||||
const targetCol = ${JSON.stringify(column)};
|
||||
const col = resolveCol(targetCol);
|
||||
if (!col) return { error: 'column_not_found', column: targetCol, available: headers.map(h => h.name) };
|
||||
|
||||
const lines = [...body.querySelectorAll('.gridLine')];
|
||||
if (lines.length === 0) return { error: 'empty_grid' };
|
||||
|
||||
// Match cell to column by X overlap, but only among cells with the same
|
||||
// fixed/scrollable kind as the header. After horizontal scroll a scrollable
|
||||
// cell may have the same x as a frozen one — without this guard cellAtColX
|
||||
// would silently return the frozen cell for a scrollable header.
|
||||
const cellAtColX = (line, c) => [...line.children]
|
||||
.filter(b => b.offsetWidth > 0 && b.classList.contains('gridBoxFix') === c.fixed)
|
||||
.find(b => {
|
||||
const r = b.getBoundingClientRect();
|
||||
const cx = r.x + r.width / 2;
|
||||
return cx >= c.x && cx < c.right;
|
||||
});
|
||||
const cellText = (b) => norm(b?.querySelector('.gridBoxText')?.innerText || b?.innerText || '');
|
||||
|
||||
const target = ${JSON.stringify(row)};
|
||||
let line, rowIdx;
|
||||
if (typeof target === 'number') {
|
||||
if (target < 0 || target >= lines.length) {
|
||||
return { error: 'row_out_of_range', row: target, loaded: lines.length };
|
||||
}
|
||||
line = lines[target];
|
||||
rowIdx = target;
|
||||
} else if (target && typeof target === 'object') {
|
||||
const entries = Object.entries(target);
|
||||
const colsByKey = {};
|
||||
for (const [k] of entries) {
|
||||
const c = resolveCol(k);
|
||||
if (!c) return { error: 'filter_column_not_found', column: k, available: headers.map(h => h.name) };
|
||||
colsByKey[k] = c;
|
||||
}
|
||||
const matches = (ln) => {
|
||||
for (const [k, v] of entries) {
|
||||
const c = colsByKey[k];
|
||||
const cell = cellAtColX(ln, c);
|
||||
const txt = cellText(cell);
|
||||
const wanted = lo(v);
|
||||
if (!txt) return false;
|
||||
const t = txt.toLowerCase();
|
||||
if (!(t === wanted || t.includes(wanted))) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
rowIdx = lines.findIndex(matches);
|
||||
if (rowIdx < 0) return { error: 'row_not_found', filter: target };
|
||||
line = lines[rowIdx];
|
||||
} else {
|
||||
return { error: 'invalid_row_type' };
|
||||
}
|
||||
|
||||
const cell = cellAtColX(line, col);
|
||||
if (!cell) return { error: 'cell_not_in_dom', column: col.name, rowIdx };
|
||||
const r = cell.getBoundingClientRect();
|
||||
const gridBox = grid.getBoundingClientRect();
|
||||
// Frozen columns (.gridBoxFix) stay pinned at the left edge of the grid even
|
||||
// when the rest scrolls horizontally. For non-frozen cells, "visible" means
|
||||
// inside the SCROLLABLE viewport (right of any frozen columns). Frozen cells
|
||||
// are always visible by definition.
|
||||
const isFixed = cell.classList.contains('gridBoxFix');
|
||||
let scrollableLeft = gridBox.x;
|
||||
if (!isFixed) {
|
||||
[...line.children].forEach(b => {
|
||||
if (b.offsetWidth > 0 && b.classList.contains('gridBoxFix')) {
|
||||
const br = b.getBoundingClientRect();
|
||||
if (br.x + br.width > scrollableLeft) scrollableLeft = br.x + br.width;
|
||||
}
|
||||
});
|
||||
}
|
||||
// "Visible enough to click" — the cell's CENTER is inside the scrollable area
|
||||
// and the cell's right edge is inside the grid. Strict left-edge check would
|
||||
// reject cells that 1С rendered touching the frozen-column boundary (off by 1px).
|
||||
const center = r.x + r.width / 2;
|
||||
const visible = center >= scrollableLeft && center <= (gridBox.x + gridBox.width) && (r.x + r.width) <= (gridBox.x + gridBox.width);
|
||||
return {
|
||||
x: Math.round(r.x + r.width / 2),
|
||||
y: Math.round(r.y + r.height / 2),
|
||||
cellX: Math.round(r.x), cellRight: Math.round(r.x + r.width),
|
||||
gridX: Math.round(gridBox.x), gridRight: Math.round(gridBox.x + gridBox.width),
|
||||
scrollableLeft: Math.round(scrollableLeft),
|
||||
columnText: col.name, rowIdx, isFixed,
|
||||
cellText: cellText(cell),
|
||||
visible
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick coordinates for a focus-click on a safe cell within the grid.
|
||||
*
|
||||
* Used both for vertical reveal-loop focus and for horizontal-scroll edge focus.
|
||||
* The caller passes a profile that selects which row, which cells to exclude,
|
||||
* and (for horizontal scroll) which edge of the row to take.
|
||||
*
|
||||
* @param {string} gridSelector
|
||||
* @param {object} opts
|
||||
* @param {number} [opts.rowIdx] - Pick from this row; falls back to first non-group/parent data row.
|
||||
* @param {'ArrowRight'|'ArrowLeft'} [opts.direction]
|
||||
* - When set, restricts to non-frozen FULLY visible cells and picks the edge
|
||||
* cell in that direction (rightmost for ArrowRight, leftmost for ArrowLeft).
|
||||
* - When omitted, picks a generic safe cell (skips first column to avoid tree-toggles).
|
||||
*
|
||||
* Always prefers non-checkbox cells (center-click on a checkbox would toggle it).
|
||||
*
|
||||
* Returns `{ x, y } | null`.
|
||||
*/
|
||||
export function findFocusCellScript(gridSelector, { rowIdx, direction } = {}) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return null;
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return null;
|
||||
const lines = [...body.querySelectorAll('.gridLine')];
|
||||
if (!lines.length) return null;
|
||||
|
||||
const rowIdx = ${rowIdx == null ? 'null' : JSON.stringify(rowIdx)};
|
||||
const direction = ${direction ? JSON.stringify(direction) : 'null'};
|
||||
|
||||
const line = (rowIdx != null && lines[rowIdx])
|
||||
|| lines.find(ln => {
|
||||
const imgBox = ln.querySelector('.gridBoxImg');
|
||||
return !imgBox?.querySelector('.gridListH, .gridListV');
|
||||
})
|
||||
|| lines[0];
|
||||
if (!line) return null;
|
||||
|
||||
let candidates;
|
||||
if (direction) {
|
||||
// Horizontal-scroll mode: edge cell in the scrollable area, exclude frozen.
|
||||
const gridBox = grid.getBoundingClientRect();
|
||||
let scrollableLeft = gridBox.x;
|
||||
[...line.children].forEach(b => {
|
||||
if (b.offsetWidth > 0 && b.classList.contains('gridBoxFix')) {
|
||||
const br = b.getBoundingClientRect();
|
||||
if (br.x + br.width > scrollableLeft) scrollableLeft = br.x + br.width;
|
||||
}
|
||||
});
|
||||
const visible = [...line.children]
|
||||
.filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxFix'))
|
||||
.map(b => ({ b, r: b.getBoundingClientRect(), checkbox: !!b.querySelector('.checkbox') }))
|
||||
.filter(({ r }) => r.x >= scrollableLeft && (r.x + r.width) <= (gridBox.x + gridBox.width));
|
||||
if (!visible.length) return null;
|
||||
visible.sort((a, b) => a.r.x - b.r.x);
|
||||
candidates = direction === 'ArrowRight' ? [...visible].reverse() : visible;
|
||||
} else {
|
||||
// Generic focus mode (used by reveal-loop): pick the FIRST visible cell —
|
||||
// typically a Reference column (Номенклатура in документах) which doesn't
|
||||
// auto-enter edit mode on click. Number/Date/String cells auto-edit and
|
||||
// break subsequent PageDown navigation.
|
||||
// For tree grids (presence of .gridBoxTree), skip first column to avoid
|
||||
// toggling expand/collapse of the row.
|
||||
const isTree = !!body.querySelector('.gridBoxTree');
|
||||
const cells = [...line.children]
|
||||
.filter(b => b.offsetWidth > 0)
|
||||
.map(b => ({ b, r: b.getBoundingClientRect(), checkbox: !!b.querySelector('.checkbox') }));
|
||||
if (!cells.length) return null;
|
||||
candidates = isTree && cells.length > 1 ? cells.slice(1) : cells;
|
||||
}
|
||||
const pick = candidates.find(v => !v.checkbox) || candidates[0];
|
||||
if (!pick) return null;
|
||||
return { x: Math.round(pick.r.x + pick.r.width / 2), y: Math.round(pick.r.y + pick.r.height / 2) };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot grid state for reveal-loop end detection.
|
||||
* Returns `{ firstText, lastText, lineCount, selIdx, hasBelow }`.
|
||||
*
|
||||
* `firstText`/`lastText` use the first cell's `.gridBoxText` content.
|
||||
* `hasBelow` is derived from scrollbar widget tracks when visible, else from scrollHeight>clientHeight.
|
||||
*/
|
||||
export function snapshotGridScript(gridSelector) {
|
||||
return `(() => {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return null;
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return null;
|
||||
const lines = body.querySelectorAll('.gridLine');
|
||||
// Full-row signature: join EVERY cell's text, not just the first column.
|
||||
// A low-cardinality first column (e.g. all "Товар 0X") would otherwise make
|
||||
// two different windows look identical and abort the reveal-loop early.
|
||||
const txt = ln => ln ? [...ln.querySelectorAll('.gridBoxText')].map(b => (b.innerText || '').trim()).join('|') : '';
|
||||
const selIdx = [...lines].findIndex(l => l.classList.contains('selRow') || l.classList.contains('select'));
|
||||
// hasBelow priority: (1) dynamic-list turn buttons, (2) tabular scrollbar tracks, (3) scrollHeight.
|
||||
let hasBelow;
|
||||
const turnsBox = document.getElementById('vertButtonScroll_' + grid.id);
|
||||
if (turnsBox && turnsBox.offsetHeight > 0) {
|
||||
const dnBtns = turnsBox.querySelectorAll('[data-down], [data-end]');
|
||||
hasBelow = [...dnBtns].some(b => !b.classList.contains('disabled'));
|
||||
} else {
|
||||
const vs = document.getElementById('vertScroll_' + grid.id);
|
||||
if (vs && vs.classList.contains('scrollV') && vs.offsetWidth > 0) {
|
||||
hasBelow = (vs.querySelector('[data-track-next]')?.offsetHeight ?? 0) > 0;
|
||||
} else {
|
||||
hasBelow = body.scrollHeight > body.clientHeight;
|
||||
}
|
||||
}
|
||||
return {
|
||||
firstText: txt(lines[0]),
|
||||
lastText: txt(lines[lines.length - 1]),
|
||||
lineCount: lines.length,
|
||||
selIdx,
|
||||
hasBelow
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the click target kind for `clickElement({row, column})`.
|
||||
*
|
||||
* Routing:
|
||||
* - `tableName` specified: try to match a visible grid by name (exact → contains).
|
||||
* If matched → grid. Else if form has a spreadsheet iframe → spreadsheet. Else error.
|
||||
* - `tableName` omitted: spreadsheet iframe present → spreadsheet (backward-compat).
|
||||
* Else first visible grid. Else error.
|
||||
*
|
||||
* Returns `{ kind: 'spreadsheet' } | { kind: 'grid', gridSelector, gridName } | { error, ... }`.
|
||||
*/
|
||||
export function resolveCellTargetScript(formNum, tableName) {
|
||||
const p = `form${formNum}_`;
|
||||
return `(() => {
|
||||
const p = ${JSON.stringify(p)};
|
||||
const tableName = ${JSON.stringify(tableName || '')};
|
||||
// Spreadsheet = iframe under form prefix with non-trivial width.
|
||||
const hasSpreadsheet = [...document.querySelectorAll('iframe')].some(f => {
|
||||
if (f.offsetWidth < 100) return false;
|
||||
let el = f.parentElement;
|
||||
for (let d = 0; el && d < 30; d++, el = el.parentElement) {
|
||||
if (el.id && el.id.startsWith(p)) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const grids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
|
||||
const norm = s => (s || '').replace(/ё/gi, 'е').toLowerCase();
|
||||
|
||||
if (tableName) {
|
||||
const target = norm(tableName);
|
||||
const matched = grids.find(g => norm(g.id.replace(p, '')) === target)
|
||||
|| grids.find(g => norm(g.id.replace(p, '')).includes(target));
|
||||
if (matched) {
|
||||
return { kind: 'grid', gridSelector: '#' + CSS.escape(matched.id), gridName: matched.id.replace(p, '') };
|
||||
}
|
||||
if (hasSpreadsheet) return { kind: 'spreadsheet' };
|
||||
return { error: 'table_not_found', table: tableName, availableGrids: grids.map(g => g.id.replace(p, '')) };
|
||||
}
|
||||
if (hasSpreadsheet) return { kind: 'spreadsheet' };
|
||||
if (grids.length > 0) {
|
||||
const g = grids[0];
|
||||
return { kind: 'grid', gridSelector: '#' + CSS.escape(g.id), gridName: g.id.replace(p, '') };
|
||||
}
|
||||
return { error: 'no_spreadsheet_or_grid' };
|
||||
})()`;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// web-test dom/nav v1.0 — sections panel, tabs bar, function panel commands
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
/** Read sections panel (left sidebar). */
|
||||
export function readSectionsScript() {
|
||||
return `(() => {
|
||||
const sections = [];
|
||||
document.querySelectorAll('[id^="themesCell_theme_"]').forEach(el => {
|
||||
const entry = { name: el.innerText?.trim() || '' };
|
||||
if (el.classList.contains('select')) entry.active = true;
|
||||
sections.push(entry);
|
||||
});
|
||||
return sections;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/** Read open tabs bar. */
|
||||
export function readTabsScript() {
|
||||
return `(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const tabs = [];
|
||||
document.querySelectorAll('[id^="openedCell_cmd_"]').forEach(el => {
|
||||
const text = norm(el.innerText);
|
||||
if (!text) return;
|
||||
const entry = { name: text };
|
||||
if (el.classList.contains('select')) entry.active = true;
|
||||
tabs.push(entry);
|
||||
});
|
||||
return tabs;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/** Switch to a tab by name (fuzzy match). Returns matched name or { error, available }. */
|
||||
export function switchTabScript(name) {
|
||||
return `(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е'))};
|
||||
const tabs = [...document.querySelectorAll('[id^="openedCell_cmd_"]')].filter(el => el.offsetWidth > 0 && norm(el.innerText));
|
||||
let best = tabs.find(el => norm(el.innerText).toLowerCase() === target);
|
||||
if (!best) best = tabs.find(el => norm(el.innerText).toLowerCase().includes(target));
|
||||
if (best) { best.click(); return norm(best.innerText); }
|
||||
return { error: 'not_found', available: tabs.map(el => norm(el.innerText)) };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/** Read commands in the function panel (current section). */
|
||||
export function readCommandsScript() {
|
||||
return `(() => {
|
||||
const groups = [];
|
||||
const container = document.querySelector('#funcPanel_container table tr');
|
||||
if (!container) return groups;
|
||||
for (const td of container.children) {
|
||||
const commands = [];
|
||||
td.querySelectorAll('[id^="cmd_"][id$="_txt"]').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
commands.push(el.innerText?.trim() || '');
|
||||
});
|
||||
if (commands.length > 0) groups.push(commands);
|
||||
}
|
||||
return groups;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a section by name (fuzzy match).
|
||||
* Returns the matched section name, or { error, available }.
|
||||
*/
|
||||
export function navigateSectionScript(name) {
|
||||
return `(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ').replace(/[\\r\\n]+/g, ' ').replace(/ +/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е').replace(/[\r\n]+/g, ' ').replace(/ +/g, ' '))};
|
||||
const els = [...document.querySelectorAll('[id^="themesCell_theme_"]')];
|
||||
let bestEl = els.find(el => norm(el.innerText).toLowerCase() === target);
|
||||
if (!bestEl) bestEl = els.find(el => norm(el.innerText).toLowerCase().includes(target));
|
||||
if (bestEl) { bestEl.click(); return norm(bestEl.innerText); }
|
||||
return { error: 'not_found', available: els.map(el => norm(el.innerText)).filter(Boolean) };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a command from function panel by name (fuzzy match).
|
||||
*/
|
||||
export function openCommandScript(name) {
|
||||
return `(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е'))};
|
||||
const els = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(el => el.offsetWidth > 0);
|
||||
let bestEl = els.find(el => norm(el.innerText).toLowerCase() === target);
|
||||
if (!bestEl) bestEl = els.find(el => norm(el.innerText).toLowerCase().includes(target));
|
||||
if (bestEl) { bestEl.click(); return norm(bestEl.innerText); }
|
||||
return { error: 'not_found', available: els.map(el => norm(el.innerText)).filter(Boolean) };
|
||||
})()`;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// web-test dom/submenu v1.0 — popup/submenu reading and clicking
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
/**
|
||||
* Read open popup/submenu items.
|
||||
* Looks for absolutely positioned visible popup containers with a.press items inside.
|
||||
* Returns [{ id, name }] or { error }.
|
||||
*/
|
||||
export function readSubmenuScript() {
|
||||
return `(() => {
|
||||
const items = [];
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||
|
||||
// 1. DLB dropdown (#editDropDown with .eddText items)
|
||||
const edd = document.getElementById('editDropDown');
|
||||
if (edd && edd.offsetWidth > 0 && edd.offsetHeight > 0) {
|
||||
edd.querySelectorAll('.eddText').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const text = norm(el.innerText);
|
||||
if (!text) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
items.push({ id: '', name: text, kind: 'dropdown',
|
||||
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
||||
});
|
||||
// Detect "Показать все" link in EDD footer
|
||||
// Structure: div.eddBottom > div > span.hyperlink "Показать все"
|
||||
let showAllEl = edd.querySelector('.eddBottom .hyperlink');
|
||||
if (!showAllEl || showAllEl.offsetWidth === 0) {
|
||||
// Fallback: scan all visible elements for text match
|
||||
const candidates = [...edd.querySelectorAll('a.press, a, span, div')]
|
||||
.filter(el => el.offsetWidth > 0 && el.children.length === 0);
|
||||
showAllEl = candidates.find(el => {
|
||||
const t = norm(el.innerText).toLowerCase();
|
||||
return t === 'показать все' || t === 'show all';
|
||||
});
|
||||
}
|
||||
if (showAllEl) {
|
||||
const r = showAllEl.getBoundingClientRect();
|
||||
items.push({ id: showAllEl.id || '', name: norm(showAllEl.innerText), kind: 'showAll',
|
||||
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
||||
}
|
||||
if (items.length > 0) return items;
|
||||
}
|
||||
|
||||
// 2. Cloud submenu (allActions / command panel menus — div.cloud with .submenuText items)
|
||||
// Read ALL visible high-z clouds (main menu + nested submenus)
|
||||
const clouds = [...document.querySelectorAll('.cloud')].filter(c => c.offsetWidth > 0 && c.offsetHeight > 0);
|
||||
const seen = new Set();
|
||||
clouds.forEach(c => {
|
||||
const z = parseInt(getComputedStyle(c).zIndex) || 0;
|
||||
if (z <= 1000) return;
|
||||
c.querySelectorAll('.submenuText').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const text = norm(el.innerText);
|
||||
if (!text || seen.has(text)) return;
|
||||
seen.add(text);
|
||||
const block = el.closest('.submenuBlock');
|
||||
if (block && block.classList.contains('submenuBlockDisabled')) return;
|
||||
const hasSub = block && /_sub$/.test(block.id);
|
||||
const r = el.getBoundingClientRect();
|
||||
items.push({ id: block?.id || '', name: text, kind: hasSub ? 'submenuArrow' : 'submenu',
|
||||
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
||||
});
|
||||
});
|
||||
if (items.length > 0) return items;
|
||||
|
||||
// 3. Submenu popups — find the topmost positioned container with non-form a.press items
|
||||
const popups = [...document.querySelectorAll('div')].filter(c => {
|
||||
const style = getComputedStyle(c);
|
||||
return (style.position === 'absolute' || style.position === 'fixed')
|
||||
&& c.offsetWidth > 0 && c.offsetHeight > 0;
|
||||
}).sort((a, b) => {
|
||||
const za = parseInt(getComputedStyle(a).zIndex) || 0;
|
||||
const zb = parseInt(getComputedStyle(b).zIndex) || 0;
|
||||
return zb - za;
|
||||
});
|
||||
for (const container of popups) {
|
||||
// Only direct a.press children or those not nested in another positioned div
|
||||
const menuItems = [...container.querySelectorAll('a.press')].filter(el => {
|
||||
if (el.offsetWidth === 0) return false;
|
||||
if (el.id && /^form\\d+_/.test(el.id)) return false;
|
||||
// Skip if this a.press is inside a deeper positioned container
|
||||
let parent = el.parentElement;
|
||||
while (parent && parent !== container) {
|
||||
const ps = getComputedStyle(parent).position;
|
||||
if (ps === 'absolute' || ps === 'fixed') return false;
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (menuItems.length < 2) continue; // Not a real menu
|
||||
const seen = new Set();
|
||||
menuItems.forEach(el => {
|
||||
const text = norm(el.innerText);
|
||||
if (!text) return;
|
||||
if (seen.has(text)) return;
|
||||
seen.add(text);
|
||||
const r = el.getBoundingClientRect();
|
||||
items.push({ id: el.id || '', name: text, kind: 'submenu',
|
||||
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
||||
});
|
||||
if (items.length > 0) break; // Found the popup menu
|
||||
}
|
||||
|
||||
if (items.length === 0) return { error: 'no_popup', message: 'No open popup/submenu found' };
|
||||
return items;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a popup/dropdown item by text match (evaluate-based for items without IDs).
|
||||
* Returns true if clicked, false if not found.
|
||||
*/
|
||||
export function clickPopupItemScript(text) {
|
||||
return `(() => {
|
||||
const target = ${JSON.stringify(text.toLowerCase().replace(/ё/g, 'е'))};
|
||||
// 1. DLB dropdown (#editDropDown .eddText items)
|
||||
const edd = document.getElementById('editDropDown');
|
||||
if (edd && edd.offsetWidth > 0) {
|
||||
for (const el of edd.querySelectorAll('.eddText')) {
|
||||
if (el.offsetWidth === 0) continue;
|
||||
const t = el.innerText?.trim() || '';
|
||||
if (t.toLowerCase() === target || t.toLowerCase().includes(target)) {
|
||||
el.click();
|
||||
return t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Submenu popups (a.press in absolutely positioned containers)
|
||||
const containers = [...document.querySelectorAll('div')].filter(c => {
|
||||
const style = getComputedStyle(c);
|
||||
return (style.position === 'absolute' || style.position === 'fixed')
|
||||
&& c.offsetWidth > 0 && c.offsetHeight > 0;
|
||||
});
|
||||
for (const container of containers) {
|
||||
const items = [...container.querySelectorAll('a.press')]
|
||||
.filter(el => el.offsetWidth > 0);
|
||||
for (const el of items) {
|
||||
const t = el.innerText?.trim() || '';
|
||||
if (t.toLowerCase() === target || t.toLowerCase().includes(target)) {
|
||||
el.click();
|
||||
return t;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})()`;
|
||||
}
|
||||
Reference in New Issue
Block a user