mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-18 08:39:40 +03:00
Auto-build: copilot (powershell) from 37f39a5
This commit is contained in:
@@ -0,0 +1,697 @@
|
||||
// web-test dom shared v1.4 — 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;
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Click point INSIDE a grid row's first visible text cell — NOT the row-line centre.
|
||||
*
|
||||
* A wide multi-column row's centre `x = line.x + line.width/2` lands far beyond the
|
||||
* form's horizontal viewport (the `.gridLine` spans ALL columns, frozen + scrollable),
|
||||
* so `mouse.click` at that X falls on an overlay outside the visible grid and the row
|
||||
* is never hit — the click silently does nothing. Seen on narrow modal selection forms
|
||||
* with many columns (множественный выбор) and the `not_selectable` bug on selection forms.
|
||||
*
|
||||
* Picks the first visible non-checkbox cell that HAS text (so center-clicking never
|
||||
* toggles a checkbox/picture mark), skips the first column on tree grids (it holds the
|
||||
* expand toggle), and clamps X near the left edge (`min(width/2, 60)`) so a wide first
|
||||
* column still lands in the viewport.
|
||||
*
|
||||
* @param line a `.gridLine` element
|
||||
* @param body the grid's `.gridBody` (for tree detection); may be null
|
||||
* @returns `{ x, y }` rounded, or `null` when the row has no usable cell.
|
||||
*/
|
||||
export const ROW_CLICK_POINT_FN = `function rowClickPoint(line, body) {
|
||||
const isTree = !!(body && body.querySelector('.gridBoxTree'));
|
||||
let cells = [...line.children]
|
||||
.filter(b => b.offsetWidth > 0)
|
||||
.map(b => ({ r: b.getBoundingClientRect(), checkbox: !!b.querySelector('.checkbox'), hasText: !!b.querySelector('.gridBoxText') }));
|
||||
if (isTree && cells.length > 1) cells = cells.slice(1);
|
||||
const pick = cells.find(c => !c.checkbox && c.hasText) || cells.find(c => !c.checkbox) || cells[0];
|
||||
if (!pick) return null;
|
||||
return { x: Math.round(pick.r.x + Math.min(pick.r.width / 2, 60)), y: Math.round(pick.r.y + pick.r.height / 2) };
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Single source of truth for column derivation on HEADERLESS grids (no `.gridHead`).
|
||||
* 1C still puts `colindex` on body cells, so anchoring works without a header.
|
||||
* Returns ordered descriptors consumed identically by readers (readTable, getFormState)
|
||||
* and resolvers (findCellCoords, findGridCell, scanGridRows) so a synthesized name like
|
||||
* "Колонка1" always maps to the same physical cell on both read and write.
|
||||
*
|
||||
* Descriptor: { name, kind:'data'|'checkbox'|'picture', colindex, subTarget:'checkbox'|'title'|'text'|null }
|
||||
* - colindex — anchor: find the cell via line.children box with matching getAttribute('colindex').
|
||||
* - subTarget — node inside that box: 'checkbox' → .checkbox, 'title' → .gridBoxTitle,
|
||||
* 'text' → .gridBoxText, null → box itself.
|
||||
*
|
||||
* A COMBINED mark-box (one box holding BOTH .checkbox AND non-empty .gridBoxTitle, e.g. the
|
||||
* value-list checkbox mark-lists) is split into TWO logical columns sharing one colindex:
|
||||
* "(checkbox)" (subTarget:checkbox) + "КолонкаN" (subTarget:title). Data columns are numbered
|
||||
* КолонкаN among themselves (checkbox/picture don't consume a number); duplicate
|
||||
* "(checkbox)"/"(picture)" get a " 2", " 3" suffix.
|
||||
*/
|
||||
export const HEADERLESS_GRID_FN = `function synthHeaderlessColumns(grid) {
|
||||
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' };
|
||||
}
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return [];
|
||||
const line = body.querySelector('.gridLine');
|
||||
if (!line) return [];
|
||||
const cols = [];
|
||||
let dataN = 0;
|
||||
const uniq = (base) => {
|
||||
if (!cols.some(c => c.name === base)) return base;
|
||||
let n = 2; while (cols.some(c => c.name === base + ' ' + n)) n++;
|
||||
return base + ' ' + n;
|
||||
};
|
||||
[...line.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const ci = box.getAttribute('colindex');
|
||||
if (ci == null) return;
|
||||
const chk = box.querySelector('.checkbox');
|
||||
const titleEl = box.querySelector('.gridBoxTitle');
|
||||
const textEl = box.querySelector('.gridBoxText');
|
||||
const titleTxt = ((titleEl ? titleEl.innerText : '') || '').trim();
|
||||
if (chk && titleTxt) {
|
||||
cols.push({ name: uniq('(checkbox)'), kind: 'checkbox', colindex: ci, subTarget: 'checkbox' });
|
||||
cols.push({ name: 'Колонка' + (++dataN), kind: 'data', colindex: ci, subTarget: 'title' });
|
||||
} else if (chk) {
|
||||
cols.push({ name: uniq('(checkbox)'), kind: 'checkbox', colindex: ci, subTarget: 'checkbox' });
|
||||
} else if (picInfo(box)) {
|
||||
cols.push({ name: uniq('(picture)'), kind: 'picture', colindex: ci, subTarget: null });
|
||||
} else {
|
||||
cols.push({ name: 'Колонка' + (++dataN), kind: 'data', colindex: ci, subTarget: textEl ? 'text' : (titleEl ? 'title' : null) });
|
||||
}
|
||||
});
|
||||
return cols;
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Single source of truth for columns of a grid WITH a header — the headed twin of
|
||||
* synthHeaderlessColumns above, and for the same reason: a column name must map to the same
|
||||
* physical cell for readers (readTable) and resolvers (click, row search, filter, fill).
|
||||
*
|
||||
* Column identity is `colindex` — 1С's own column id, present on both header boxes and body
|
||||
* cells. Geometry is the FALLBACK, used only for cells that have no header of their own
|
||||
* (sub-rows of a merged header, e.g. «Субконто Дт» over three stacked cells).
|
||||
*
|
||||
* Why colindex first: a wide header (ERP task list, «Исполнитель» spanning x 1085…1515) covers
|
||||
* the narrow headers below it («Срок» 1085…1251, «Выполнена» 1251…1515). Matching a cell by its
|
||||
* center-x alone puts the «Исполнитель» cell (center 1300) into the «Выполнена» group — which
|
||||
* both fakes a merged header (phantom «Выполнена 1/2») and glues foreign values together.
|
||||
* The write path (grid-edit.mjs) already resolves cells by colindex for exactly this reason.
|
||||
*
|
||||
* Column: { name, text, title, ci, x, right, y, h, fixed, kind?, subIdx? }
|
||||
* - ci — anchor; null for expanded sub-columns (their cells carry a different colindex).
|
||||
* - subIdx — set on «Имя 1/2/3» columns expanded from ONE header over several sub-rows;
|
||||
* such a cell is found by its Y order inside the header's x-range.
|
||||
*/
|
||||
export const COLUMN_MODEL_FN = HEADERLESS_GRID_FN + `
|
||||
function picInfoShared(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' };
|
||||
}
|
||||
|
||||
function buildColumnModel(grid) {
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const body = grid.querySelector('.gridBody');
|
||||
const empty = { columns: [], byCi: {}, groups: new Map(), subRows: {}, multiRow: {}, headless: !head };
|
||||
if (!body) return empty;
|
||||
|
||||
if (!head) {
|
||||
const cols = synthHeaderlessColumns(grid).map(c => ({
|
||||
name: c.name, text: c.name, title: '', ci: c.colindex, subTarget: c.subTarget,
|
||||
kind: c.kind, x: 0, right: 0, y: 0, h: 0, fixed: false,
|
||||
}));
|
||||
const byCi = {};
|
||||
cols.forEach(c => { if (c.ci != null && byCi[c.ci] === undefined) byCi[c.ci] = c; });
|
||||
return { columns: cols, byCi, groups: new Map(), subRows: {}, multiRow: {}, headless: true };
|
||||
}
|
||||
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
const lines = [...body.querySelectorAll('.gridLine')];
|
||||
const cellByCi = (line, ci) => [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === ci);
|
||||
const columns = [];
|
||||
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const ci = box.getAttribute('colindex');
|
||||
const textEl = box.querySelector('.gridBoxText');
|
||||
const text = ((textEl || box).innerText || '').trim().replace(/\\n/g, ' ');
|
||||
const title = (box.getAttribute('title') || '').trim();
|
||||
const r = box.getBoundingClientRect();
|
||||
const base = { ci, x: r.x, right: r.x + r.width, y: r.y, h: r.height,
|
||||
fixed: box.classList.contains('gridBoxFix') };
|
||||
if (text) { columns.push(Object.assign(base, { name: text, text: text, title: title })); return; }
|
||||
|
||||
// Unnamed header — a column only if its cells hold a checkbox or a picture. 1С doesn't
|
||||
// expose the technical name, so it is named by the header tooltip.
|
||||
// Sample SEVERAL rows: a picture bound to a Boolean draws nothing for false, so an empty
|
||||
// first row is not evidence that the column has no pictures at all.
|
||||
let kind = null;
|
||||
for (const line of lines.slice(0, 10)) {
|
||||
const cell = ci != null ? cellByCi(line, ci) : null;
|
||||
if (!cell) continue;
|
||||
if (cell.querySelector('.checkbox')) { kind = 'checkbox'; break; }
|
||||
if (picInfoShared(cell)) { kind = 'picture'; break; }
|
||||
}
|
||||
if (!kind && picInfoShared(box)) kind = 'picture';
|
||||
if (!kind) return;
|
||||
let name = kind === 'checkbox' ? '(checkbox)' : (title || '(picture)');
|
||||
if (columns.some(c => c.name === name)) {
|
||||
let n = 2;
|
||||
while (columns.some(c => c.name === name + ' ' + n)) n++;
|
||||
name = name + ' ' + n;
|
||||
}
|
||||
columns.push(Object.assign(base, { name: name, text: '', title: title, kind: kind }));
|
||||
});
|
||||
|
||||
const keyOf = c => Math.round(c.x) + ':' + Math.round(c.right);
|
||||
const groups = new Map();
|
||||
columns.forEach(c => { const k = keyOf(c); if (!groups.has(k)) groups.set(k, []); groups.get(k).push(c); });
|
||||
for (const hdrs of groups.values()) hdrs.sort((a, b) => a.y - b.y);
|
||||
const byCi = {};
|
||||
columns.forEach(c => { if (c.ci != null && byCi[c.ci] === undefined) byCi[c.ci] = c; });
|
||||
|
||||
// Sub-rows per x-group, measured on the first data line. A cell belongs to the group of its
|
||||
// OWN header whenever colindex says so; only header-less cells are placed geometrically.
|
||||
const subRows = {};
|
||||
if (lines[0]) {
|
||||
[...lines[0].children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const ci = box.getAttribute('colindex');
|
||||
const own = ci != null ? byCi[ci] : null;
|
||||
let key = null;
|
||||
const r = box.getBoundingClientRect();
|
||||
if (own) key = keyOf(own);
|
||||
else {
|
||||
const cx = r.x + r.width / 2;
|
||||
for (const [k, hdrs] of groups) {
|
||||
if (cx >= hdrs[0].x && cx < hdrs[0].right) { key = k; break; }
|
||||
}
|
||||
}
|
||||
if (key == null) return;
|
||||
(subRows[key] = subRows[key] || []).push({ y: r.y });
|
||||
});
|
||||
Object.keys(subRows).forEach(k => subRows[k].sort((a, b) => a.y - b.y));
|
||||
}
|
||||
|
||||
// Stacked headers (2+ over several sub-rows) → match by Y order.
|
||||
// ONE header over several sub-rows → merged header: expand into «Имя 1..N».
|
||||
const multiRow = {};
|
||||
for (const [k, hdrs] of groups) {
|
||||
const subs = subRows[k];
|
||||
if (!subs || subs.length <= 1) continue;
|
||||
if (hdrs.length >= 2) { multiRow[k] = hdrs; continue; }
|
||||
const base = hdrs[0];
|
||||
const at = columns.indexOf(base);
|
||||
columns.splice(at, 1);
|
||||
if (base.ci != null && byCi[base.ci] === base) delete byCi[base.ci];
|
||||
const expanded = [];
|
||||
for (let si = 0; si < subs.length; si++) {
|
||||
const col = Object.assign({}, base, {
|
||||
name: base.name + ' ' + (si + 1), ci: null,
|
||||
y: base.y + si, h: base.h / subs.length, subIdx: si,
|
||||
});
|
||||
columns.splice(at + si, 0, col);
|
||||
expanded.push(col);
|
||||
}
|
||||
groups.set(k, expanded);
|
||||
multiRow[k] = expanded;
|
||||
}
|
||||
|
||||
return { columns: columns, byCi: byCi, groups: groups, subRows: subRows, multiRow: multiRow, headless: false };
|
||||
}
|
||||
|
||||
/** Cell → column. colindex first; geometry only for cells without a header of their own. */
|
||||
function columnForCell(model, box) {
|
||||
const ci = box.getAttribute('colindex');
|
||||
if (ci != null && model.byCi[ci]) return model.byCi[ci];
|
||||
const r = box.getBoundingClientRect();
|
||||
const cx = r.x + r.width / 2;
|
||||
const fixed = box.classList.contains('gridBoxFix');
|
||||
for (const k of Object.keys(model.multiRow)) {
|
||||
const hdrs = model.multiRow[k];
|
||||
if (cx < hdrs[0].x || cx >= hdrs[0].right) continue;
|
||||
const subs = model.subRows[k];
|
||||
if (subs) {
|
||||
const si = subs.findIndex(s => Math.abs(s.y - r.y) < 5);
|
||||
if (si >= 0 && si < hdrs.length) return hdrs[si];
|
||||
}
|
||||
let best = hdrs[0], bd = Infinity;
|
||||
for (const h of hdrs) { const d = Math.abs(r.y - h.y); if (d < bd) { bd = d; best = h; } }
|
||||
return best;
|
||||
}
|
||||
return model.columns.find(c => cx >= c.x && cx < c.right && c.fixed === fixed) || null;
|
||||
}
|
||||
|
||||
/** Column → cell inside a given line. Mirror of columnForCell, same precedence. */
|
||||
function cellForColumn(model, line, col) {
|
||||
const boxes = [...line.children].filter(b => b.offsetWidth > 0);
|
||||
if (col.subIdx != null) {
|
||||
const inGroup = boxes
|
||||
.filter(b => {
|
||||
const r = b.getBoundingClientRect();
|
||||
const cx = r.x + r.width / 2;
|
||||
return cx >= col.x && cx < col.right && b.classList.contains('gridBoxFix') === col.fixed;
|
||||
})
|
||||
.sort((a, b) => a.getBoundingClientRect().y - b.getBoundingClientRect().y);
|
||||
return inGroup[col.subIdx] || null;
|
||||
}
|
||||
if (col.ci != null) {
|
||||
const hit = boxes.find(b => b.getAttribute('colindex') === col.ci);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return boxes
|
||||
.filter(b => b.classList.contains('gridBoxFix') === col.fixed)
|
||||
.find(b => {
|
||||
const r = b.getBoundingClientRect();
|
||||
const cx = r.x + r.width / 2;
|
||||
return cx >= col.x && cx < col.right;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
/** Column by user-supplied name: exact → «Группа / Имя» suffix → substring. */
|
||||
function resolveColumnByName(model, name) {
|
||||
const lo = s => (s || '').toLowerCase().replace(/ё/g, 'е').trim();
|
||||
const cand = c => [c.name, c.text, c.title].filter(Boolean);
|
||||
const n = lo(name);
|
||||
const suffix = lo(' / ' + name);
|
||||
return model.columns.find(c => cand(c).some(t => lo(t) === n))
|
||||
|| model.columns.find(c => cand(c).some(t => lo(t).endsWith(suffix)))
|
||||
|| model.columns.find(c => cand(c).some(t => lo(t).includes(n)))
|
||||
|| null;
|
||||
}`;
|
||||
|
||||
/** 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 = HEADERLESS_GRID_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;
|
||||
if (el.classList.contains('checkboxDisabled')) field.disabled = true;
|
||||
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;
|
||||
if (document.getElementById(p + name)?.classList.contains('radioDisabled')) field.disabled = true;
|
||||
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;
|
||||
// frameButton disabled uses the same class as a.press buttons (pressDisabled).
|
||||
const btn = { name: text || idName, frame: true };
|
||||
if (el.classList.contains('pressDisabled')) btn.disabled = true;
|
||||
buttons.push(btn);
|
||||
});
|
||||
|
||||
// Tumbler items. Disabled state lives on the group element .frameTumbler
|
||||
// (class tumblerDisabled), not on the individual segments.
|
||||
document.querySelectorAll('[id^="' + p + '"].tumblerItem').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const text = el.innerText?.trim();
|
||||
const idName = el.id?.replace(p, '') || '';
|
||||
const btn = { name: text || idName, tumbler: true };
|
||||
if (el.closest('.frameTumbler')?.classList.contains('tumblerDisabled')) btn.disabled = true;
|
||||
buttons.push(btn);
|
||||
});
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (body) {
|
||||
// Headerless grid — synthesize columns by colindex (single source).
|
||||
synthHeaderlessColumns(grid).forEach(c => columns.push({ text: c.name, x: 0, right: 0, 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,182 @@
|
||||
// web-test dom/filter v1.1 — DOM scripts for filterList / unfilterList
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { COLUMN_MODEL_FN } from './_shared.mjs';
|
||||
|
||||
/**
|
||||
* 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' };
|
||||
${COLUMN_MODEL_FN}
|
||||
const targetField = ${JSON.stringify(field)};
|
||||
const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
|
||||
if (!rows.length) return { error: 'no_rows' };
|
||||
|
||||
// Column resolution — shared model (dom/_shared.mjs), same as readTable/click. The old
|
||||
// positional index (Nth header box → Nth cell box) breaks on multi-row headers, where
|
||||
// header and cell counts and order differ.
|
||||
const model = buildColumnModel(grid);
|
||||
const col = resolveColumnByName(model, targetField);
|
||||
if (!col) {
|
||||
// Unknown column → click the first cell and let the caller fall back to DLB.
|
||||
const cells = [...rows[0].querySelectorAll('.gridBox')];
|
||||
if (!cells.length) return { error: 'no_cells' };
|
||||
const r0 = cells[0].getBoundingClientRect();
|
||||
return { x: Math.round(r0.x + r0.width / 2), y: Math.round(r0.y + r0.height / 2), needDlb: true };
|
||||
}
|
||||
const cell = cellForColumn(model, rows[0], col);
|
||||
if (!cell) return { error: 'cell_not_found' };
|
||||
const r = cell.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,712 @@
|
||||
// web-test dom/forms v1.10 — 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, ROW_CLICK_POINT_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();
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Id of the cross that closes the topmost closable surface, or null when there is nothing to
|
||||
* close — which is exactly how the platform says "this is the desktop": the home page has no
|
||||
* cross at all. That `null` is the reset-between-tests "clean" signal; no baseline snapshot and
|
||||
* no exception list needed. Measured on a live stand:
|
||||
* desktop, 1 form → form=0 formCount=1, no cross
|
||||
* desktop, 3 forms → form=5 formCount=3 openForms=[5,6,7], no cross
|
||||
* list open → cross VW_page2headerTopLine_cmd_CloseButton
|
||||
*
|
||||
* The id must leak out for the caller to click — Escape is not enough: on a real stand it closed
|
||||
* neither a modal nor even a plain list.
|
||||
*
|
||||
* Order matters, and each step was measured:
|
||||
* 1. floating window (`ps<N>`) — a modal sits ON TOP of the form that opened it, so its cross
|
||||
* wins. The index grows per window (ps0 → ps1 → …): a literal `ps0` breaks on the second
|
||||
* modal of a session.
|
||||
* 2. the form's own page cross — works whether or not the tab panel is switched on, which is
|
||||
* why it beats the tab cross. With the panel hidden it is the ONLY path.
|
||||
* 3. the ACTIVE tab's cross — last resort. `select` marks the active tab (same signal as
|
||||
* dom/form-state.mjs); NOT `querySelector('.openedClose')` like closeModals does — that
|
||||
* grabs the first tab in the DOM and would close someone else's form.
|
||||
* Anchored on ids rather than title="Закрыть", so a non-Russian locale keeps working.
|
||||
*/
|
||||
export function closeCrossScript() {
|
||||
return `(() => {
|
||||
const vis = (e) => e.offsetWidth > 0;
|
||||
const crosses = [...document.querySelectorAll('[id*="headerTopLine_cmd_CloseButton"]')].filter(vis);
|
||||
const floating = crosses.filter(e => /ps\\d+headerTopLine_cmd_CloseButton$/.test(e.id));
|
||||
if (floating.length) return floating.pop().id;
|
||||
const page = crosses.filter(e => /^VW_page\\d+headerTopLine_cmd_CloseButton$/.test(e.id));
|
||||
if (page.length) return page.pop().id;
|
||||
const tab = [...document.querySelectorAll('[id^="openedCell_cmd_"]')]
|
||||
.find(e => e.classList.contains('select') && vis(e));
|
||||
const tabCross = tab && tab.querySelector('.openedClose');
|
||||
return (tabCross && vis(tabCross)) ? tabCross.id : null;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `(() => {
|
||||
${ROW_CLICK_POINT_FN}
|
||||
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' };
|
||||
if (el.classList.contains('pressDisabled')) item.disabled = true;
|
||||
// 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;
|
||||
const item = { id: el.id, name: text || idName, label: text ? '' : idName, kind: 'frameButton' };
|
||||
if (el.classList.contains('pressDisabled')) item.disabled = true;
|
||||
items.push(item);
|
||||
});
|
||||
|
||||
// Tumbler items (toggle switch segments). Disabled state lives on the group
|
||||
// element .frameTumbler (class tumblerDisabled), not on the segments themselves.
|
||||
[...document.querySelectorAll('[id^="' + p + '"].tumblerItem')].filter(el => el.offsetWidth > 0).forEach(el => {
|
||||
const idName = el.id.replace(p, '');
|
||||
const text = norm(el.innerText);
|
||||
const item = { id: el.id, name: text || idName, label: idName, kind: 'tumbler' };
|
||||
if (el.closest('.frameTumbler')?.classList.contains('tumblerDisabled')) item.disabled = true;
|
||||
items.push(item);
|
||||
});
|
||||
|
||||
// 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();
|
||||
const item = { id: el.id, name: label || idName, label: idName, kind: 'checkbox' };
|
||||
if (el.classList.contains('checkboxDisabled')) item.disabled = true;
|
||||
items.push(item);
|
||||
});
|
||||
|
||||
// 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.disabled) res.disabled = true; 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.disabled) res.disabled = true; 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.disabled) res.disabled = true;
|
||||
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';
|
||||
// Click point: first visible text cell of the row, NOT the row-line centre.
|
||||
// A wide multi-column row's centre lands beyond the form's viewport (e.g. on
|
||||
// narrow modal selection forms) so mouse.click misses the row. See ROW_CLICK_POINT_FN.
|
||||
const pt = rowClickPoint(line, body);
|
||||
const r = line.getBoundingClientRect();
|
||||
return { id: '', kind, name: rowTexts[0] || '', gridId: grid.id,
|
||||
x: pt ? pt.x : Math.round(r.x + r.width / 2),
|
||||
y: pt ? pt.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, disabled: !!el.disabled });
|
||||
});
|
||||
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, ...(ff.disabled ? { disabled: true } : {}) };
|
||||
}
|
||||
|
||||
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 };
|
||||
// Disabled reference field keeps its DLB/CB visible (won't hit button_not_found),
|
||||
// so flag disabled off the field input for selectValue to guard against a silent no-op.
|
||||
const fieldInput = document.getElementById(p + found.name + '_i0')
|
||||
|| document.querySelector('input[id^="' + p + found.name + '_i"]');
|
||||
if (fieldInput?.disabled) result.disabled = true;
|
||||
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 (el.disabled) last.disabled = true;
|
||||
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');
|
||||
const cb = { inputId: el.id, name, label, isCheckbox: true, checked };
|
||||
if (el.classList.contains('checkboxDisabled')) cb.disabled = true;
|
||||
allFields.push(cb);
|
||||
});
|
||||
// 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') });
|
||||
}
|
||||
const groupEl = document.getElementById(p + groupName);
|
||||
const radio = { inputId: p + groupName, name: groupName, label, isRadio: true, options };
|
||||
if (groupEl?.classList.contains('radioDisabled')) radio.disabled = true;
|
||||
allFields.push(radio);
|
||||
});
|
||||
|
||||
// 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.disabled) entry.disabled = true;
|
||||
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,308 @@
|
||||
// web-test dom/grid-edit v1.3 — DOM scripts for row-fill (grid edit-time operations)
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
import { HEADERLESS_GRID_FN, COLUMN_MODEL_FN } from './_shared.mjs';
|
||||
//
|
||||
// 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 `(() => {
|
||||
${HEADERLESS_GRID_FN}
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return null;
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const cols = [];
|
||||
if (head) {
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
[...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 });
|
||||
});
|
||||
} else {
|
||||
// Headerless: synthesized columns (КолонкаN/(checkbox)) ordered by colindex
|
||||
synthHeaderlessColumns(grid).forEach(c => cols.push({ text: c.name.toLowerCase(), colindex: parseInt(c.colindex) }));
|
||||
}
|
||||
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 `(() => {
|
||||
${COLUMN_MODEL_FN}
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return { error: 'no_grid' };
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return { error: 'no_grid_body' };
|
||||
|
||||
// Column resolution — shared model (dom/_shared.mjs): same names and same physical cell
|
||||
// as readTable/click report, including «Имя 1/2/3» of an expanded merged header.
|
||||
const model = buildColumnModel(grid);
|
||||
const keys = ${JSON.stringify(fieldKeys)};
|
||||
let targetCol = null;
|
||||
for (const key of keys) {
|
||||
targetCol = resolveColumnByName(model, key);
|
||||
if (targetCol) break;
|
||||
}
|
||||
const targetSub = targetCol ? (targetCol.subTarget || null) : null;
|
||||
|
||||
const rows = [...body.querySelectorAll('.gridLine')];
|
||||
if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length };
|
||||
const line = rows[${row}];
|
||||
|
||||
let box = targetCol ? cellForColumn(model, line, targetCol) : null;
|
||||
// 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' });
|
||||
// subTarget-aware cell node: 'checkbox' → box itself (row-fill then uses findCheckboxAtPoint),
|
||||
// 'title' → .gridBoxTitle, else (headed default / 'text') → .gridBoxText || box.
|
||||
let cell;
|
||||
if (targetSub === 'checkbox') cell = box;
|
||||
else if (targetSub === 'title') cell = box.querySelector('.gridBoxTitle') || box;
|
||||
else 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 `(() => {
|
||||
${COLUMN_MODEL_FN}
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return null;
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return null;
|
||||
const model = buildColumnModel(grid);
|
||||
const kl = ${JSON.stringify(key.toLowerCase())};
|
||||
// Extra "no-space/no-dash" fuzz on top of the shared resolver: header «Группа Контрагентов»
|
||||
// must match the key «ГруппаКонтрагентов».
|
||||
let targetCol = resolveColumnByName(model, kl);
|
||||
if (!targetCol) {
|
||||
const squash = s => (s || '').toLowerCase().replace(/ё/g, 'е').replace(/[\\s\\-]+/g, '');
|
||||
const klNoSpace = squash(kl);
|
||||
targetCol = model.columns.find(c => {
|
||||
const t = squash(c.name);
|
||||
return t && (t.includes(klNoSpace) || klNoSpace.includes(t));
|
||||
}) || null;
|
||||
}
|
||||
if (!targetCol) return null;
|
||||
const targetSub = targetCol.subTarget || null;
|
||||
const rows = [...body.querySelectorAll('.gridLine')];
|
||||
if (${row} >= rows.length) return null;
|
||||
const line = rows[${row}];
|
||||
const box = cellForColumn(model, line, targetCol);
|
||||
if (!box) return null;
|
||||
box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
let cell;
|
||||
if (targetSub === 'checkbox') cell = box;
|
||||
else if (targetSub === 'title') cell = box.querySelector('.gridBoxTitle') || box;
|
||||
else 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 `(() => {
|
||||
${HEADERLESS_GRID_FN}
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (!head) {
|
||||
// Headerless: the editing INPUT is rendered in an overlay (.inputs) OUTSIDE
|
||||
// the .gridBox, so walking ancestors for colindex fails. Resolve colindex by
|
||||
// matching the input's x against the body cells (same idea as the headed branch).
|
||||
const bl = grid.querySelector('.gridBody .gridLine');
|
||||
let ci = null;
|
||||
if (bl) for (const b of bl.children) {
|
||||
if (b.offsetWidth === 0) continue;
|
||||
const br = b.getBoundingClientRect();
|
||||
if (fr.x >= br.x && fr.x < br.x + br.width) { ci = b.getAttribute('colindex'); break; }
|
||||
}
|
||||
if (ci != null) {
|
||||
const sc = synthHeaderlessColumns(grid).find(c => c.kind === 'data' && c.colindex === ci);
|
||||
if (sc) headerText = sc.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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,801 @@
|
||||
// web-test dom/grid v1.14 — grid resolution + table reading + edit-time helpers
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { ROW_CLICK_POINT_FN, HEADERLESS_GRID_FN, COLUMN_MODEL_FN } from './_shared.mjs';
|
||||
import { ROW_STATE_FN } from './row-state.mjs';
|
||||
|
||||
/**
|
||||
* 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' };
|
||||
}
|
||||
${COLUMN_MODEL_FN}
|
||||
${ROW_STATE_FN}
|
||||
|
||||
// Write the leading row-state sprite onto a row: _rowPic (raw, always) + decoded booleans
|
||||
// (only when the sprite path AND frame are known — absence means "unknown", never false).
|
||||
function applyRowState(row, line) {
|
||||
const st = rowStateInfo(line);
|
||||
if (!st) return;
|
||||
row._rowPic = st.rowPic;
|
||||
if (st.axes) Object.keys(st.axes).forEach(k => { row['_' + k] = st.axes[k]; });
|
||||
}
|
||||
|
||||
// DOM-based parsing: gridHead → columns, gridBody → gridLine rows → gridBox cells
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) {
|
||||
// Fallback: innerText-based (for non-standard grids without a body)
|
||||
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' };
|
||||
}
|
||||
|
||||
// HEADERLESS grid (body but no .gridHead) — synthesize columns by colindex.
|
||||
// Single source: synthHeaderlessColumns. Headed path below is left untouched.
|
||||
if (!head) {
|
||||
const synth = synthHeaderlessColumns(grid);
|
||||
const colNames = synth.map(c => c.name);
|
||||
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 = {};
|
||||
const boxes = [...line.children].filter(b => b.offsetWidth > 0);
|
||||
synth.forEach(c => {
|
||||
const box = boxes.find(b => b.getAttribute('colindex') === c.colindex);
|
||||
let val = '';
|
||||
if (box) {
|
||||
if (c.subTarget === 'checkbox') {
|
||||
const chk = box.querySelector('.checkbox');
|
||||
val = chk && chk.classList.contains('select') ? 'true' : 'false';
|
||||
} else if (c.subTarget === 'title') {
|
||||
val = (box.querySelector('.gridBoxTitle')?.innerText || '').trim().replace(/\\n/g, ' ');
|
||||
} else if (c.subTarget === 'text') {
|
||||
val = (box.querySelector('.gridBoxText')?.innerText || '').trim().replace(/\\n/g, ' ');
|
||||
} else {
|
||||
const pic = picInfo(box);
|
||||
val = pic ? 'pic:' + pic.gx : ((box.innerText || '').trim().replace(/\\n/g, ' '));
|
||||
}
|
||||
}
|
||||
row[c.name] = val;
|
||||
});
|
||||
// Row meta — mirrors the headed path (group/parent/tree/level/selected/state)
|
||||
const imgBox = line.querySelector('.gridBoxImg');
|
||||
if (imgBox) {
|
||||
if (imgBox.querySelector('.gridListH')) row._kind = 'group';
|
||||
else if (imgBox.querySelector('.gridListV')) row._kind = 'parent';
|
||||
}
|
||||
applyRowState(row, line);
|
||||
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;
|
||||
}
|
||||
if (line.classList.contains('selRow') || line.classList.contains('select')) row._selected = true;
|
||||
rows.push(row);
|
||||
}
|
||||
// hasMore — mirrors the headed path
|
||||
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 vs = document.getElementById('vertScroll_' + grid.id);
|
||||
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 isTree = !!body.querySelector('.gridBoxTree');
|
||||
const hasGroups = rows.some(r => r._kind === 'group');
|
||||
const result = { name, columns: colNames, rows, total, offset: ${offset}, shown: rows.length, hasMore };
|
||||
if (isTree) result.viewMode = 'tree';
|
||||
if (hasGroups) result.hierarchical = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Columns + cell↔column mapping — single source of truth in dom/_shared.mjs.
|
||||
// colindex first, geometry only for cells without a header of their own.
|
||||
const model = buildColumnModel(grid);
|
||||
const columns = model.columns;
|
||||
|
||||
// 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.name] = ''; });
|
||||
[...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;
|
||||
}
|
||||
}
|
||||
const col = columnForCell(model, box);
|
||||
if (col) {
|
||||
row[col.name] = row[col.name] ? row[col.name] + ' / ' + 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';
|
||||
}
|
||||
// Leading row-state sprite → _rowPic + decoded booleans
|
||||
applyRowState(row, line);
|
||||
// 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.name), 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) {
|
||||
// Headerless editable grid: no header to click for commit-defocus. Click the
|
||||
// thin strip at the grid's very top edge (above the first row) so the active
|
||||
// edit commits without landing on a .gridLine (which would re-enter edit).
|
||||
const gr = grid.getBoundingClientRect();
|
||||
return { x: Math.round(gr.x + gr.width / 2), y: Math.round(gr.y + 1) };
|
||||
}
|
||||
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 selection-form grid for the row matching `search` and return a click
|
||||
* point INSIDE that row's first visible text cell — NOT the row-line centre.
|
||||
* (A wide multi-column row's centre `x = r.x + r.width/2` lands beyond the form's
|
||||
* horizontal viewport, on an overlay, so `mouse.click` misses the row → Enter
|
||||
* doesn't select → form stays open. That was the `not_selectable` bug.)
|
||||
*
|
||||
* `search` is either:
|
||||
* - a string — matched per-cell (case/ё/NBSP-insensitive), preferring
|
||||
* exact-cell → startsWith → includes (so "Кабель" wins over "Кабель ВВГ");
|
||||
* - an object `{ column: value, ... }` — each key fuzzy-resolved to a header
|
||||
* column, a row matches when EVERY column's cell includes its value (AND),
|
||||
* preferring rows where every column's cell equals its value exactly.
|
||||
* Empty `search` → first row (fallback).
|
||||
*
|
||||
* Returns:
|
||||
* `{ rowCount, x, y, isGroup, matchKind, visibleSample }` when found,
|
||||
* `{ rowCount, visibleSample, error? }` when rows present but unmatched,
|
||||
* `{ rowCount: 0 }` for an empty grid, or `null` when no grid.
|
||||
* `visibleSample` = first-cell text of visible rows, for actionable error messages.
|
||||
*/
|
||||
export function scanGridRowsScript(formNum, search) {
|
||||
return `(() => {
|
||||
${ROW_CLICK_POINT_FN}
|
||||
${COLUMN_MODEL_FN}
|
||||
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 search = ${JSON.stringify(search ?? '')};
|
||||
const isObj = search && typeof search === 'object';
|
||||
const norm = s => (s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim().toLowerCase().replace(/ё/gi, 'е');
|
||||
const disp = s => (s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
|
||||
const cellText = b => (b.querySelector('.gridBoxText') ? b.querySelector('.gridBoxText').innerText : b.innerText) || '';
|
||||
const visCells = line => [...line.children].filter(b => b.offsetWidth > 0);
|
||||
const visibleSample = lines.slice(0, 10)
|
||||
.map(l => disp(l.querySelector('.gridBoxText') ? l.querySelector('.gridBoxText').innerText : ''))
|
||||
.filter(Boolean);
|
||||
|
||||
let sel = null, matchKind = null;
|
||||
|
||||
if (!search || (isObj && !Object.keys(search).length)) {
|
||||
sel = lines[0]; matchKind = 'first';
|
||||
} else if (isObj) {
|
||||
// Column resolution — shared model (dom/_shared.mjs): colindex first, geometry only
|
||||
// for cells without a header of their own. Same names as readTable reports.
|
||||
const model = buildColumnModel(grid);
|
||||
const cellAtCol = (line, col) => cellForColumn(model, line, col);
|
||||
const keys = Object.keys(search);
|
||||
const cols = {};
|
||||
for (const k of keys) {
|
||||
const c = resolveColumnByName(model, k);
|
||||
if (!c) return { rowCount: lines.length, error: 'filter_column_not_found', column: k, visibleSample };
|
||||
cols[k] = c;
|
||||
}
|
||||
let bestRank = 0;
|
||||
for (const line of lines) {
|
||||
let allIncludes = true, allExact = true;
|
||||
for (const k of keys) {
|
||||
const v = norm(search[k]);
|
||||
if (!v) continue;
|
||||
const cell = cellAtCol(line, cols[k]);
|
||||
const t = norm(cell ? cellText(cell) : '');
|
||||
if (!t.includes(v)) { allIncludes = false; break; }
|
||||
if (t !== v) allExact = false;
|
||||
}
|
||||
if (!allIncludes) continue;
|
||||
const rank = allExact ? 2 : 1;
|
||||
if (rank > bestRank) { bestRank = rank; sel = line; matchKind = allExact ? 'object-exact' : 'object'; if (rank === 2) break; }
|
||||
}
|
||||
} else {
|
||||
// String: per-cell, prefer exact-cell → startsWith → includes.
|
||||
const v = norm(search);
|
||||
let bestRank = 0;
|
||||
for (const line of lines) {
|
||||
let rowRank = 0;
|
||||
for (const b of visCells(line)) {
|
||||
const t = norm(cellText(b));
|
||||
if (!t) continue;
|
||||
let r = 0;
|
||||
if (t === v) r = 3; else if (t.startsWith(v)) r = 2; else if (t.includes(v)) r = 1;
|
||||
if (r > rowRank) rowRank = r;
|
||||
}
|
||||
if (rowRank > bestRank) { bestRank = rowRank; sel = line; matchKind = rowRank === 3 ? 'exact' : rowRank === 2 ? 'startsWith' : 'includes'; if (rowRank === 3) break; }
|
||||
}
|
||||
}
|
||||
|
||||
if (!sel) return { rowCount: lines.length, visibleSample };
|
||||
|
||||
// Click point: first visible text cell of the row (skip checkboxes; on tree grids
|
||||
// skip the expand-toggle column; clamp X near the left). Shared with the
|
||||
// clickElement row-select path — see ROW_CLICK_POINT_FN.
|
||||
const pt = rowClickPoint(sel, body);
|
||||
if (!pt) return { rowCount: lines.length, visibleSample };
|
||||
|
||||
const imgBox = sel.querySelector('.gridBoxImg');
|
||||
const isGroup = imgBox ? !!imgBox.querySelector('.gridListH') : false;
|
||||
return {
|
||||
rowCount: lines.length,
|
||||
x: pt.x,
|
||||
y: pt.y,
|
||||
isGroup, matchKind, visibleSample
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
// ─── 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 (!body) return { error: 'no_grid_structure' };
|
||||
${COLUMN_MODEL_FN}
|
||||
const isHeadless = !head;
|
||||
|
||||
// Columns + name→column + column→cell — single source of truth in dom/_shared.mjs,
|
||||
// shared with readTable, so a name it reports (incl. expanded «Имя 1/2/3») is clickable.
|
||||
const model = buildColumnModel(grid);
|
||||
const headers = model.columns;
|
||||
|
||||
const targetCol = ${JSON.stringify(column)};
|
||||
const col = resolveColumnByName(model, 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' };
|
||||
|
||||
const cellAtColX = (line, c) => cellForColumn(model, line, c);
|
||||
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 = resolveColumnByName(model, 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 };
|
||||
// Headerless: click coords target the subTarget node (checkbox image / title),
|
||||
// no frozen/scroll partition in these narrow grids → trivially visible.
|
||||
if (isHeadless) {
|
||||
let node = cell;
|
||||
if (col.subTarget === 'checkbox') node = cell.querySelector('.checkbox') || cell;
|
||||
else if (col.subTarget === 'title') node = cell.querySelector('.gridBoxTitle') || cell;
|
||||
else if (col.subTarget === 'text') node = cell.querySelector('.gridBoxText') || cell;
|
||||
const rr = node.getBoundingClientRect();
|
||||
const gb = grid.getBoundingClientRect();
|
||||
return { x: Math.round(rr.x + rr.width / 2), y: Math.round(rr.y + rr.height / 2),
|
||||
cellX: Math.round(rr.x), cellRight: Math.round(rr.x + rr.width),
|
||||
gridX: Math.round(gb.x), gridRight: Math.round(gb.x + gb.width), scrollableLeft: Math.round(gb.x),
|
||||
columnText: col.name, rowIdx, isFixed: false, cellText: cellText(cell), visible: true };
|
||||
}
|
||||
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,116 @@
|
||||
// web-test dom/row-state v1.0 — leading row-state sprite decoding
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
/**
|
||||
* Ведущая иконка состояния строки списка.
|
||||
*
|
||||
* 1С рисует состояние строки (проведён / помечен на удаление / выполнена / …) отдельным спрайтом,
|
||||
* приходящим из `e1cib/convertPicture?url=e1csys/<dir>/<file>.zip&…&gx=<N>`, где `gx` — индекс кадра.
|
||||
* Это НЕ то же самое, что именованные pic-колонки (`pictureCollection/picture/<id>`) — у тех другой
|
||||
* URL и другой смысл (значение ячейки), их разбирает picInfo() в grid.mjs.
|
||||
*
|
||||
* Ключ словаря — ПОЛНЫЙ путь спрайта, не имя файла: `basic/folder.zip` (справочники) и
|
||||
* `accnt/folder.zip` (план счетов) — разные файлы с одинаковым именем и РАЗНОЙ раскладкой gx
|
||||
* (у basic gx=1 — элемент, у accnt gx=1 — предопределённый). Нормализация до имени файла склеила бы
|
||||
* их и дала неверную расшифровку.
|
||||
*
|
||||
* Набор осей свой у каждого спрайта: docList → deleted+posted, folder → deleted+predefined,
|
||||
* Task → deleted+completed. Неприменимая ось не эмитится. Незнакомый путь или нерасшифрованный gx →
|
||||
* только `_rowPic`, без булевых: ОТСУТСТВИЕ БУЛЕВА ЗНАЧИТ «НЕ ЗНАЮ», а не false.
|
||||
*
|
||||
* Раскладки сняты живьём на ERP и подтверждены отрисовкой кадров спрайта (convertPicture&scale).
|
||||
*/
|
||||
export const ROW_STATE_FN = `function rowStateInfo(line) {
|
||||
// Словарь: полный путь → (gx → набор осей). null = кадр не расшифрован (только _rowPic).
|
||||
const SPRITES = {
|
||||
// Документы, журналы документов. Спрайт — СЕТКА: база + 3·пометка_удаления.
|
||||
// База: 0 записан, 1 проведён, 2 «загнутый угол» (не расшифрован).
|
||||
// Кадров ровно 6 (gx 6..8 пустые). gx4 (проведён+помечен) на практике недостижим:
|
||||
// пометка проведённого документа его распроводит.
|
||||
'e1csys/basic/docList.zip': function (gx) {
|
||||
if (gx > 5) return null;
|
||||
const base = gx % 3, deleted = gx >= 3;
|
||||
if (base === 2) return { deleted: deleted }; // база не расшифрована → posted не эмитим
|
||||
return { deleted: deleted, posted: base === 1 };
|
||||
},
|
||||
// Справочники, планы видов характеристик. Ось группа/элемент СОЗНАТЕЛЬНО не выводим —
|
||||
// _kind живёт от .gridListH/.gridListV, на нём завязана иерархия.
|
||||
//
|
||||
// Кадры 4/5 — не «неизвестное состояние», а ВТОРОЕ измерение: у справочника с иерархией
|
||||
// элементов (ERP «Партнеры») родитель — элемент, а не папка, и платформа берёт кадр 4.
|
||||
// Кадры попарно байт-в-байт равны: gx4 ≡ gx1 (чистый элемент), gx5 ≡ gx3 (элемент + красный
|
||||
// крест), gx8 ≡ gx7. Что означает второе измерение — знать не нужно: обе НАШИ оси кадр
|
||||
// задаёт однозначно (крест есть → помечен).
|
||||
'e1csys/basic/folder.zip': {
|
||||
0: { deleted: false, predefined: false }, // группа
|
||||
1: { deleted: false, predefined: false }, // элемент
|
||||
2: { deleted: true, predefined: false }, // группа, помечена
|
||||
3: { deleted: true, predefined: false }, // элемент, помечен
|
||||
4: { deleted: false, predefined: false }, // элемент-узел иерархии (кадр ≡ gx1)
|
||||
5: { deleted: true, predefined: false }, // элемент-узел, помечен (кадр ≡ gx3)
|
||||
6: { deleted: false, predefined: true }, // группа, предопределённая
|
||||
7: { deleted: false, predefined: true }, // элемент, предопределённый
|
||||
8: { deleted: false, predefined: true }, // кадр ≡ gx7
|
||||
},
|
||||
// План счетов — ОТДЕЛЬНЫЙ спрайт с той же basename, но иной раскладкой.
|
||||
'e1csys/accnt/folder.zip': {
|
||||
0: { deleted: false, predefined: false },
|
||||
1: { deleted: false, predefined: true },
|
||||
2: { deleted: true, predefined: false },
|
||||
},
|
||||
// Задачи. Бит-поле: 1·Выполнена + 2·ПометкаУдаления. Сверено с данными на ERP.
|
||||
'e1csys/bp/Task.zip': function (gx) {
|
||||
if (gx > 3) return null;
|
||||
return { completed: (gx & 1) === 1, deleted: (gx & 2) === 2 };
|
||||
},
|
||||
// Бизнес-процессы. Бит-поле: 4·Стартован + 2·Завершён + 1·ПометкаУдаления. Сверено с данными.
|
||||
'e1csys/bp/BusinessProcess.zip': function (gx) {
|
||||
if (gx > 7) return null;
|
||||
return { started: (gx & 4) === 4, finished: (gx & 2) === 2, deleted: (gx & 1) === 1 };
|
||||
},
|
||||
// Планы видов расчёта.
|
||||
'e1csys/calc/calcKindImg.zip': {
|
||||
0: { deleted: false, predefined: false },
|
||||
1: { deleted: true, predefined: false },
|
||||
2: { deleted: false, predefined: true },
|
||||
},
|
||||
// Планы обмена. gx 1, 4..7 не сняты → только _rowPic.
|
||||
'e1csys/backend/DataExchangeImages.zip': {
|
||||
0: { thisNode: false },
|
||||
2: { thisNode: true },
|
||||
},
|
||||
};
|
||||
|
||||
function decodeUrl(s) {
|
||||
// В DOM путь закодирован ДВАЖДЫ: url=e1csys%252Fbasic%252FdocList.zip
|
||||
let v = s;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
let d;
|
||||
try { d = decodeURIComponent(v); } catch (e) { break; }
|
||||
if (d === v) break;
|
||||
v = d;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// Ведущих значков в строке может быть несколько в РАЗНЫХ боксах: вид операции
|
||||
// (pictureCollection, произвольная картинка конфигурации), tree-toggle и состояние.
|
||||
// Поэтому перебираем все .dIB строки, а не берём первый .gridBoxImg.
|
||||
const dibs = line.querySelectorAll('.gridBoxImg .dIB');
|
||||
for (let i = 0; i < dibs.length; i++) {
|
||||
const d = dibs[i];
|
||||
if (d.getAttribute('tree') === 'true') continue;
|
||||
const bg = d.style.backgroundImage || '';
|
||||
if (!bg.includes('convertPicture')) continue;
|
||||
const um = bg.match(/[?&]url=([^&"')]+)/);
|
||||
if (!um) continue;
|
||||
const path = decodeUrl(um[1]);
|
||||
if (!/\\.zip$/.test(path)) continue;
|
||||
const gm = bg.match(/[?&]gx=(\\d+)/);
|
||||
const gx = gm ? parseInt(gm[1], 10) : 0;
|
||||
|
||||
const entry = SPRITES[path];
|
||||
const axes = typeof entry === 'function' ? entry(gx) : (entry ? entry[gx] : null);
|
||||
return { rowPic: path + ':' + gx, axes: axes || null };
|
||||
}
|
||||
return null;
|
||||
}`;
|
||||
@@ -0,0 +1,178 @@
|
||||
// web-test dom/submenu v1.1 — 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;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a platform "cloud dropdown" checkbox list (`.cloudDD`) — the inline
|
||||
* quick-choice multi-select dropdown (e.g. a catalog with QuickChoice opened via F4).
|
||||
* NOT handled by readSubmenuScript (which reads `.eddText`/`.cloud`/popup `a.press`).
|
||||
*
|
||||
* Returns `[{ text, checked, x, y }]` — `checked` from `.checkbox.select`, `x/y` =
|
||||
* the checkbox center (click there to toggle). Toggle via page.mouse.click (a
|
||||
* `.surface` backdrop swallows selector clicks); confirm by clicking OUTSIDE the panel.
|
||||
* Returns `{ error: 'no_clouddd' }` when no visible `.cloudDD` panel.
|
||||
*/
|
||||
export function readCloudDDScript() {
|
||||
return `(() => {
|
||||
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/\\s+/g, ' ').replace(/ё/gi, 'е');
|
||||
const panel = [...document.querySelectorAll('.cloudDD')].find(p => p.offsetWidth > 0 && p.offsetHeight > 0);
|
||||
if (!panel) return { error: 'no_clouddd' };
|
||||
const items = [];
|
||||
panel.querySelectorAll('.checkbox').forEach(chk => {
|
||||
if (chk.offsetWidth === 0) return;
|
||||
// The text label sits beside the checkbox — climb until a container has text.
|
||||
let row = chk;
|
||||
for (let k = 0; k < 4 && row.parentElement; k++) { row = row.parentElement; if ((row.innerText || '').trim()) break; }
|
||||
const r = chk.getBoundingClientRect();
|
||||
items.push({ text: norm(row.innerText || ''), checked: chk.classList.contains('select'),
|
||||
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
||||
});
|
||||
return items;
|
||||
})()`;
|
||||
}
|
||||
Reference in New Issue
Block a user