mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-26 14:41:02 +03:00
Новое в dom/grid.mjs (все принимают опциональный gridSelector): - countGridRowsScript — кол-во .gridLine в body - isTreeGridScript — тип grid'а (есть .gridBoxTree) - findGridHeadCenterCoordsScript — центр .gridHead для commit-клика - getSelectedOrLastRowIndexScript — selected row index, fallback на последний Также: - isInputFocusedInGrid wrapper (S1) применён в add-row "ready" поллинге - isNotInListCloudVisibleScript (S3) применён вместо локального notInList - clickShowAllInNotInListCloudScript — новая в dom/forms.mjs (клик "Показать все" в "нет в списке" cloud popup через dispatchEvent) Метрики row-fill: 1041 → 971 LOC (−70), evaluates 17 → 10. Регресс 05/08/16/10 — зелёный. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
322 lines
13 KiB
JavaScript
322 lines
13 KiB
JavaScript
// web-test dom/grid v1.1 — grid resolution + table reading + edit-time helpers
|
||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||
|
||
/**
|
||
* Resolve a specific grid by semantic name (table parameter).
|
||
* Cascade: exact gridName match → gridName contains → column contains.
|
||
* Returns { gridSelector, gridId, gridName, gridIndex, columns } or { error, available }.
|
||
*/
|
||
export function resolveGridScript(formNum, tableName) {
|
||
const p = `form${formNum}_`;
|
||
return `(() => {
|
||
const p = ${JSON.stringify(p)};
|
||
const target = ${JSON.stringify(tableName.toLowerCase().replace(/ё/g, 'е'))};
|
||
const norm = s => (s || '').replace(/ё/gi, 'е');
|
||
const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
|
||
if (!allGrids.length) return { error: 'no_grids', message: 'No grids found on form' };
|
||
const infos = allGrids.map((g, idx) => {
|
||
const gridId = g.id || '';
|
||
const gridName = gridId.replace(p, '');
|
||
const head = g.querySelector('.gridHead');
|
||
const columns = [];
|
||
if (head) {
|
||
const headLine = head.querySelector('.gridLine') || head;
|
||
[...headLine.children].forEach(box => {
|
||
if (box.offsetWidth === 0) return;
|
||
const textEl = box.querySelector('.gridBoxText');
|
||
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||
if (text) columns.push(text);
|
||
});
|
||
}
|
||
// Visual label from group title element
|
||
const titleEl = document.getElementById(p + gridName + '#title_div')
|
||
|| document.getElementById(p + 'Группа' + gridName + '#title_div');
|
||
const label = titleEl ? (titleEl.innerText?.trim().replace(/:\s*$/, '').replace(/ /g, ' ') || '') : '';
|
||
return { idx, gridId, gridName, label, columns, el: g };
|
||
});
|
||
// 1. Exact gridName match (case-insensitive)
|
||
let found = infos.find(i => norm(i.gridName).toLowerCase() === target);
|
||
// 2. Exact label match
|
||
if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase() === target);
|
||
// 3. gridName contains target
|
||
if (!found) found = infos.find(i => norm(i.gridName).toLowerCase().includes(target));
|
||
// 4. Label contains target
|
||
if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase().includes(target));
|
||
// 5. Any column contains target
|
||
if (!found) found = infos.find(i => i.columns.some(c => norm(c).toLowerCase().includes(target)));
|
||
if (found) {
|
||
return {
|
||
gridSelector: found.gridId ? '#' + CSS.escape(found.gridId) : null,
|
||
gridId: found.gridId,
|
||
gridName: found.gridName,
|
||
gridIndex: found.idx,
|
||
columns: found.columns
|
||
};
|
||
}
|
||
return {
|
||
error: 'not_found',
|
||
message: 'Table "' + ${JSON.stringify(tableName)} + '" not found',
|
||
available: infos.map(i => ({ name: i.gridName, ...(i.label ? { label: i.label } : {}), columns: i.columns }))
|
||
};
|
||
})()`;
|
||
}
|
||
|
||
/**
|
||
* Read table/grid data with pagination.
|
||
* Parses grid.innerText — \n separates rows, \t separates cells.
|
||
* First row = column headers.
|
||
* Returns { name, columns[], rows[{col:val}], total, offset, shown }.
|
||
*/
|
||
export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelector } = {}) {
|
||
const p = `form${formNum}_`;
|
||
return `(() => {
|
||
const p = ${JSON.stringify(p)};
|
||
const grid = ${gridSelector
|
||
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||
: `[...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||
.find(g => g.offsetWidth > 0 && g.offsetHeight > 0)`};
|
||
if (!grid) return { error: 'no_table', message: 'No table found on form ${formNum}' };
|
||
const name = grid.id ? grid.id.replace(p, '') : '';
|
||
|
||
// DOM-based parsing: gridHead → columns, gridBody → gridLine rows → gridBox cells
|
||
const head = grid.querySelector('.gridHead');
|
||
const body = grid.querySelector('.gridBody');
|
||
if (!head || !body) {
|
||
// Fallback: innerText-based (for non-standard grids)
|
||
const gText = grid.innerText?.trim() || '';
|
||
const lines = gText.split('\\n').filter(Boolean);
|
||
return { name, columns: [], rows: [], total: lines.length, offset: 0, shown: 0,
|
||
hint: 'Grid has no gridHead/gridBody structure' };
|
||
}
|
||
|
||
// Extract column headers with X-coordinates for alignment
|
||
const columns = [];
|
||
const headLine = head.querySelector('.gridLine') || head;
|
||
[...headLine.children].forEach(box => {
|
||
if (box.offsetWidth === 0) return;
|
||
const textEl = box.querySelector('.gridBoxText');
|
||
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||
if (!text) {
|
||
// Unnamed column — check if data cells contain checkboxes
|
||
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')) {
|
||
const r = box.getBoundingClientRect();
|
||
columns.push({ text: '(checkbox)', 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) 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');
|
||
const result = { name, columns: columns.map(c => c.text), rows, total, offset: ${offset}, shown: rows.length };
|
||
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]; })()`;
|
||
}
|
||
|
||
/**
|
||
* Count `.gridLine` rows in the body of the target grid.
|
||
* Returns the row count, or `0` when grid/body absent.
|
||
*/
|
||
export function countGridRowsScript(gridSelector) {
|
||
return `(() => {
|
||
const grid = ${gridResolver(gridSelector)};
|
||
const body = grid?.querySelector('.gridBody');
|
||
return body ? body.querySelectorAll('.gridLine').length : 0;
|
||
})()`;
|
||
}
|
||
|
||
/**
|
||
* Is the target grid a tree grid? (presence of `.gridBoxTree`)
|
||
* Returns boolean.
|
||
*/
|
||
export function isTreeGridScript(gridSelector) {
|
||
return `(() => {
|
||
const grid = ${gridResolver(gridSelector)};
|
||
return grid ? !!grid.querySelector('.gridBoxTree') : false;
|
||
})()`;
|
||
}
|
||
|
||
/**
|
||
* Return center coords of the grid's `.gridHead` element.
|
||
* Used as a click target to commit a pending cell edit (clicking the header
|
||
* defocuses the input without selecting another row).
|
||
*
|
||
* Returns `{ x, y } | null`.
|
||
*/
|
||
export function findGridHeadCenterCoordsScript(gridSelector) {
|
||
return `(() => {
|
||
const grid = ${gridResolver(gridSelector)};
|
||
if (!grid) return null;
|
||
const head = grid.querySelector('.gridHead');
|
||
if (!head) return null;
|
||
const r = head.getBoundingClientRect();
|
||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||
})()`;
|
||
}
|
||
|
||
/**
|
||
* Return the index of the currently selected row in the target grid, or
|
||
* fall back to the last row when nothing is selected.
|
||
*
|
||
* Returns row index, or `-1` when no rows.
|
||
*/
|
||
export function getSelectedOrLastRowIndexScript(gridSelector) {
|
||
return `(() => {
|
||
const grid = ${gridResolver(gridSelector)};
|
||
if (!grid) return -1;
|
||
const body = grid.querySelector('.gridBody');
|
||
if (!body) return -1;
|
||
const lines = [...body.querySelectorAll('.gridLine')];
|
||
const sel = lines.findIndex(l => l.classList.contains('selected'));
|
||
return sel >= 0 ? sel : lines.length - 1;
|
||
})()`;
|
||
}
|