Files
cc-1c-skills/.agents/skills/web-test/scripts/dom/grid.mjs
T
2026-06-30 15:48:28 +00:00

967 lines
44 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// web-test dom/grid v1.12 — grid resolution + table reading + edit-time helpers
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { ROW_CLICK_POINT_FN, HEADERLESS_GRID_FN } from './_shared.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' };
}
${HEADERLESS_GRID_FN}
// 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)
const imgBox = line.querySelector('.gridBoxImg');
if (imgBox) {
if (imgBox.querySelector('.gridListH')) row._kind = 'group';
else if (imgBox.querySelector('.gridListV')) row._kind = 'parent';
}
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;
}
// Extract column headers with X-coordinates for alignment
const columns = [];
const headLine = head.querySelector('.gridLine') || head;
[...headLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const textEl = box.querySelector('.gridBoxText');
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
if (!text) {
// Unnamed column — check if data cells contain checkboxes or pictures.
// Picture columns have no header text (only an icon + a title tooltip); 1С
// doesn't expose the technical column name in the DOM, so we name them by
// the header's title attribute, falling back to '(picture)'.
const firstLine = body?.querySelector('.gridLine');
const visibleHeaders = [...headLine.children].filter(c => c.offsetWidth > 0);
const idx = visibleHeaders.indexOf(box);
const cells = firstLine ? [...firstLine.children].filter(c => c.offsetWidth > 0) : [];
const r = box.getBoundingClientRect();
if (cells[idx]?.querySelector('.checkbox')) {
columns.push({ text: '(checkbox)', x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
} else if (picInfo(box) || picInfo(cells[idx])) {
let title = (box.getAttribute('title') || '').trim() || '(picture)';
// Disambiguate duplicate picture-column names with a numeric suffix.
if (columns.some(c => c.text === title)) {
let n = 2;
while (columns.some(c => c.text === title + ' ' + n)) n++;
title = title + ' ' + n;
}
columns.push({ text: title, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
}
return;
}
const r = box.getBoundingClientRect();
columns.push({ text, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
});
// Multi-row grid support: detect stacked/merged headers.
// Group headers by X-range. For each group, count data sub-rows from first line.
// - Stacked headers (2+ headers at same X) with multiple data rows → match by Y-order
// - Single merged header with multiple data rows → expand to numbered columns (e.g. "Субконто Дт 1")
const xGroups = new Map();
columns.forEach(c => {
const key = Math.round(c.x) + ':' + Math.round(c.right);
if (!xGroups.has(key)) xGroups.set(key, []);
xGroups.get(key).push(c);
});
for (const [, hdrs] of xGroups) hdrs.sort((a, b) => a.y - b.y);
const firstDataLine = body?.querySelector('.gridLine');
const subRowMap = new Map();
if (firstDataLine) {
[...firstDataLine.children].forEach(box => {
if (box.offsetWidth === 0) return;
const r = box.getBoundingClientRect();
const cx = r.x + r.width / 2;
for (const [key, hdrs] of xGroups) {
const h0 = hdrs[0];
if (cx >= h0.x && cx < h0.right) {
if (!subRowMap.has(key)) subRowMap.set(key, []);
subRowMap.get(key).push({ y: r.y });
break;
}
}
});
for (const [, subs] of subRowMap) subs.sort((a, b) => a.y - b.y);
}
const multiRowGroups = new Map();
for (const [key, hdrs] of xGroups) {
const subs = subRowMap.get(key);
if (!subs || subs.length <= 1) continue;
if (hdrs.length >= 2) {
multiRowGroups.set(key, hdrs);
} else if (hdrs.length === 1 && subs.length > 1) {
const base = hdrs[0];
const baseIdx = columns.indexOf(base);
columns.splice(baseIdx, 1);
const expanded = [];
for (let si = 0; si < subs.length; si++) {
const numbered = {
text: base.text + ' ' + (si + 1),
x: base.x, w: base.w, right: base.right,
y: base.y + si, h: base.h / subs.length, _subIdx: si
};
columns.splice(baseIdx + si, 0, numbered);
expanded.push(numbered);
}
multiRowGroups.set(key, expanded);
}
}
function matchColumn(cellX, cellW, cellY) {
const cx = cellX + cellW / 2;
for (const [key, hdrs] of multiRowGroups) {
const h0 = hdrs[0];
if (cx >= h0.x && cx < h0.right) {
const subs = subRowMap.get(key);
if (subs) {
const subIdx = subs.findIndex(s => Math.abs(s.y - cellY) < 5);
if (subIdx >= 0 && subIdx < hdrs.length) return hdrs[subIdx];
}
let best = hdrs[0], bestDist = Infinity;
for (const h of hdrs) {
const dist = Math.abs(cellY - h.y);
if (dist < bestDist) { bestDist = dist; best = h; }
}
return best;
}
}
return columns.find(c => cx >= c.x && cx < c.right);
}
// Extract data rows from gridBody
const allLines = body.querySelectorAll('.gridLine');
const total = allLines.length;
const rows = [];
const end = Math.min(${offset} + ${maxRows}, total);
for (let i = ${offset}; i < end; i++) {
const line = allLines[i];
if (!line) break;
const row = {};
columns.forEach(c => { row[c.text] = ''; });
[...line.children].forEach(box => {
if (box.offsetWidth === 0) return;
const textEl = box.querySelector('.gridBoxText');
const chk = box.querySelector('.checkbox');
let val;
if (chk) {
val = chk.classList.contains('select') ? 'true' : 'false';
} else {
val = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
if (!val) {
// Empty text → maybe a picture cell. 'pic:<gx>' encodes the sprite frame
// (state). Absent picture stays '' (truthy check distinguishes presence).
const pic = picInfo(box);
if (pic) val = 'pic:' + pic.gx;
else return;
}
}
// Match cell to column by X+Y overlap (multi-row aware)
const r = box.getBoundingClientRect();
const col = matchColumn(r.x, r.width, r.y);
if (col) {
row[col.text] = row[col.text] ? row[col.text] + ' / ' + val : val;
}
});
// Detect row kind: group (gridListH), parent/up (gridListV), or element
const imgBox = line.querySelector('.gridBoxImg');
if (imgBox) {
if (imgBox.querySelector('.gridListH')) row._kind = 'group';
else if (imgBox.querySelector('.gridListV')) row._kind = 'parent';
}
// Tree mode: detect expand/collapse state and indent level
const treeBox = line.querySelector('.gridBoxTree');
if (treeBox) {
const treeIcon = imgBox?.querySelector('[tree="true"]');
if (treeIcon) {
const bg = treeIcon.style.backgroundImage || '';
row._tree = bg.includes('gx=0') ? 'expanded' : 'collapsed';
}
row._level = imgBox ? imgBox.querySelectorAll('.dIB').length - 1 : 0;
}
// Selection state: selRow = selected row in grid
if (line.classList.contains('selRow') || line.classList.contains('select')) row._selected = true;
rows.push(row);
}
const isTree = !!body.querySelector('.gridBoxTree');
const hasGroups = rows.some(r => r._kind === 'group');
// Virtualization-aware hasMore signal. Three sources in priority order:
// 1. Dynamic-list turn buttons (#vertButtonScroll_<gridId>, sibling of grid).
// Buttons carry data-home/data-up (above) and data-down/data-end (below);
// class "disabled" on a direction means nothing to show there.
// 2. Tabular-section scrollbar (#vertScroll_<gridId>, class scrollV) —
// track-back/track-next pixel heights tell us above/below precisely.
// 3. Fallback: scrollHeight>clientHeight for "below"; "above" unknown.
let hasMore;
const turnsBox = document.getElementById('vertButtonScroll_' + grid.id);
if (turnsBox && turnsBox.offsetHeight > 0) {
const upBtns = turnsBox.querySelectorAll('[data-home], [data-up]');
const dnBtns = turnsBox.querySelectorAll('[data-down], [data-end]');
hasMore = {
above: [...upBtns].some(b => !b.classList.contains('disabled')),
below: [...dnBtns].some(b => !b.classList.contains('disabled')),
};
} else {
const vsId = 'vertScroll_' + grid.id;
const vs = document.getElementById(vsId);
if (vs && vs.classList.contains('scrollV') && vs.offsetWidth > 0) {
const back = vs.querySelector('[data-track-back]')?.offsetHeight ?? 0;
const next = vs.querySelector('[data-track-next]')?.offsetHeight ?? 0;
hasMore = { above: back > 0, below: next > 0 };
} else {
hasMore = { below: body.scrollHeight > body.clientHeight };
}
}
const result = { name, columns: columns.map(c => c.text), rows, total, offset: ${offset}, shown: rows.length, hasMore };
if (isTree) result.viewMode = 'tree';
if (hasGroups) result.hierarchical = true;
return result;
})()`;
}
// ─── Edit-time grid helpers (for fillTableRow / row-fill) ────────────────────
//
// All helpers below accept an optional `gridSelector`. When passed, they target
// that exact grid; when null/undefined they pick the LAST visible `.grid` on
// the page (this matches the implicit "current grid" used by row-fill).
/** Inline JS fragment that resolves the target grid into `const grid`. */
function gridResolver(gridSelector) {
return gridSelector
? `document.querySelector(${JSON.stringify(gridSelector)})`
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`;
}
/**
* Find center coords of a target row for click-select (used by deleteTableRow).
* Picks the second visible gridBox container in the row (skips row-number/checkbox col).
*
* Returns `{ x, y, total } | { error: 'no_grid'|'no_grid_body'|'row_out_of_range'|'no_cell', total? }`.
*/
export function findDeleteRowCoordsScript(gridSelector, row) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return { error: 'no_grid' };
const body = grid.querySelector('.gridBody');
if (!body) return { error: 'no_grid_body' };
const rows = [...body.querySelectorAll('.gridLine')];
if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length };
const line = rows[${row}];
// Use visible gridBox containers (not gridBoxText) to avoid clicking checkboxes
const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
// Skip first column (row number / checkbox) — pick second visible box
const box = boxes.length > 1 ? boxes[1] : boxes[0];
if (!box) return { error: 'no_cell' };
const cell = box.querySelector('.gridBoxText') || box;
const r = cell.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), total: rows.length };
})()`;
}
/**
* Count `.gridLine` rows in the body of the target grid.
* Returns the row count, or `0` when grid/body absent.
*/
export function countGridRowsScript(gridSelector) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
const body = grid?.querySelector('.gridBody');
return body ? body.querySelectorAll('.gridLine').length : 0;
})()`;
}
/**
* Is the target grid a tree grid? (presence of `.gridBoxTree`)
* Returns boolean.
*/
export function isTreeGridScript(gridSelector) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
return grid ? !!grid.querySelector('.gridBoxTree') : false;
})()`;
}
/**
* Return center coords of the grid's `.gridHead` element.
* Used as a click target to commit a pending cell edit (clicking the header
* defocuses the input without selecting another row).
*
* Returns `{ x, y } | null`.
*/
export function findGridHeadCenterCoordsScript(gridSelector) {
return `(() => {
const grid = ${gridResolver(gridSelector)};
if (!grid) return null;
const head = grid.querySelector('.gridHead');
if (!head) {
// 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}
${HEADERLESS_GRID_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) {
// Resolve each key to a header column (fuzzy, normalised) — mirror resolveCol.
const headLine = grid.querySelector('.gridHead .gridLine') || grid.querySelector('.gridHead');
let headers;
if (headLine) {
headers = [...headLine.children]
.filter(c => c.offsetWidth > 0)
.map(c => {
const t = (c.querySelector('.gridBoxText') || c).innerText || '';
const title = c.getAttribute('title') || '';
const r = c.getBoundingClientRect();
return { name: disp(t) || disp(title), text: t, title, x: r.x, right: r.x + r.width };
})
.filter(h => h.name);
} else {
// Headerless: synthesized columns anchored by colindex.
headers = synthHeaderlessColumns(grid).map(c => ({ name: c.name, text: c.name, title: '', x: 0, right: 0, colindex: c.colindex }));
}
const resolveCol = name => {
const n = norm(name);
const cand = h => [h.text, h.title].filter(Boolean);
return headers.find(h => cand(h).some(t => norm(t) === n))
|| headers.find(h => cand(h).some(t => norm(t).includes(n)));
};
const cellAtCol = (line, col) => {
if (col.colindex != null) return visCells(line).find(b => b.getAttribute('colindex') === col.colindex);
return visCells(line).find(b => {
const r = b.getBoundingClientRect();
const cx = r.x + r.width / 2;
return cx >= col.x && cx < col.right;
});
};
const keys = Object.keys(search);
const cols = {};
for (const k of keys) {
const c = resolveCol(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' };
${HEADERLESS_GRID_FN}
const isHeadless = !head;
// Header X-ranges (mirror of readTableScript logic, simplified). We also
// remember whether each header is frozen (gridBoxFix) — frozen and scrollable
// columns can share X coordinates after horizontal scroll, so cell matching
// must respect the frozen/scrollable partition.
let headers;
if (head) {
const headLine = head.querySelector('.gridLine') || head;
headers = [...headLine.children]
.filter(c => c.offsetWidth > 0)
.map(c => {
const textEl = c.querySelector('.gridBoxText');
const text = (textEl || c).innerText?.trim().replace(/\\n/g, ' ') || '';
// Picture/icon columns have no header text — fall back to the title tooltip
// (mirrors readTable naming) so they can still be targeted for clicking.
const title = (c.getAttribute('title') || '').trim();
const r = c.getBoundingClientRect();
return { text, title, name: text || title, x: r.x, right: r.x + r.width, fixed: c.classList.contains('gridBoxFix') };
})
.filter(h => h.name);
} else {
// Headerless: synthesized columns anchored by colindex (cellAtColX matches by colindex).
headers = synthHeaderlessColumns(grid).map(c => ({ text: c.name, title: '', name: c.name, x: 0, right: 0, fixed: false, colindex: c.colindex, subTarget: c.subTarget }));
}
const resolveCol = (name) => {
const suffix = ' / ' + name;
const cand = h => [h.text, h.title].filter(Boolean);
return headers.find(h => cand(h).some(t => lo(t) === lo(name)))
|| headers.find(h => cand(h).some(t => t.endsWith(suffix)))
|| headers.find(h => cand(h).some(t => lo(t).includes(lo(name))));
};
const targetCol = ${JSON.stringify(column)};
const col = resolveCol(targetCol);
if (!col) return { error: 'column_not_found', column: targetCol, available: headers.map(h => h.name) };
const lines = [...body.querySelectorAll('.gridLine')];
if (lines.length === 0) return { error: 'empty_grid' };
// Match cell to column by X overlap, but only among cells with the same
// fixed/scrollable kind as the header. After horizontal scroll a scrollable
// cell may have the same x as a frozen one — without this guard cellAtColX
// would silently return the frozen cell for a scrollable header.
const cellAtColX = (line, c) => {
// Headerless columns carry colindex → match the body cell directly (robust,
// and returns the same box for both logical columns of a combined mark-box).
if (c.colindex != null) {
return [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === c.colindex);
}
return [...line.children]
.filter(b => b.offsetWidth > 0 && b.classList.contains('gridBoxFix') === c.fixed)
.find(b => {
const r = b.getBoundingClientRect();
const cx = r.x + r.width / 2;
return cx >= c.x && cx < c.right;
});
};
const cellText = (b) => norm(b?.querySelector('.gridBoxText')?.innerText || b?.innerText || '');
const target = ${JSON.stringify(row)};
let line, rowIdx;
if (typeof target === 'number') {
if (target < 0 || target >= lines.length) {
return { error: 'row_out_of_range', row: target, loaded: lines.length };
}
line = lines[target];
rowIdx = target;
} else if (target && typeof target === 'object') {
const entries = Object.entries(target);
const colsByKey = {};
for (const [k] of entries) {
const c = resolveCol(k);
if (!c) return { error: 'filter_column_not_found', column: k, available: headers.map(h => h.name) };
colsByKey[k] = c;
}
const matches = (ln) => {
for (const [k, v] of entries) {
const c = colsByKey[k];
const cell = cellAtColX(ln, c);
const txt = cellText(cell);
const wanted = lo(v);
if (!txt) return false;
const t = txt.toLowerCase();
if (!(t === wanted || t.includes(wanted))) return false;
}
return true;
};
rowIdx = lines.findIndex(matches);
if (rowIdx < 0) return { error: 'row_not_found', filter: target };
line = lines[rowIdx];
} else {
return { error: 'invalid_row_type' };
}
const cell = cellAtColX(line, col);
if (!cell) return { error: 'cell_not_in_dom', column: col.name, rowIdx };
// 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' };
})()`;
}