243 lines
9.0 KiB
JavaScript
243 lines
9.0 KiB
JavaScript
export const createSelectionManager = (container, state) => {
|
|
let activeBounds = null; // { minRow, maxRow, minCol, maxCol }
|
|
let startCell = null; // { row, col }
|
|
let multiSelections = [];
|
|
|
|
const isCellSelected = (r, c) => {
|
|
if (activeBounds && r >= activeBounds.minRow && r <= activeBounds.maxRow && c >= activeBounds.minCol && c <= activeBounds.maxCol) return true;
|
|
return multiSelections.some(s => r >= s.minRow && r <= s.maxRow && c >= s.minCol && c <= s.maxCol);
|
|
};
|
|
|
|
let lastHadSelection = false;
|
|
|
|
const applySelectionUI = () => {
|
|
/* DEBUG: Performance Isolation Toggle */
|
|
if (window.__DT_FEATURES__ && !window.__DT_FEATURES__.selection) return;
|
|
|
|
let boundMinRow = Infinity, boundMaxRow = -Infinity;
|
|
if (activeBounds) {
|
|
boundMinRow = Math.min(boundMinRow, activeBounds.minRow);
|
|
boundMaxRow = Math.max(boundMaxRow, activeBounds.maxRow);
|
|
}
|
|
multiSelections.forEach(s => {
|
|
boundMinRow = Math.min(boundMinRow, s.minRow);
|
|
boundMaxRow = Math.max(boundMaxRow, s.maxRow);
|
|
});
|
|
|
|
const hasSelection = boundMinRow !== Infinity;
|
|
if (!hasSelection && !lastHadSelection) return;
|
|
lastHadSelection = hasSelection;
|
|
|
|
const body = container.querySelector('.dt-body');
|
|
if (!body) return;
|
|
|
|
const rowNodes = body.querySelectorAll('.dt-body-row');
|
|
rowNodes.forEach(rowNode => {
|
|
const absoluteRow = (rowNode._ref?.rIdx ?? -1) + state._listStartIndex;
|
|
const cells = rowNode.querySelectorAll('.dt-cell');
|
|
|
|
if (!hasSelection || absoluteRow < boundMinRow || absoluteRow > boundMaxRow) {
|
|
cells.forEach(cell => cell.classList.remove('dt-cell-selected'));
|
|
return;
|
|
}
|
|
|
|
cells.forEach((cell, cIdx) => {
|
|
if (isCellSelected(absoluteRow, cIdx)) {
|
|
cell.classList.add('dt-cell-selected');
|
|
} else {
|
|
cell.classList.remove('dt-cell-selected');
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
const updateStatus = () => {
|
|
let count = 0;
|
|
if (activeBounds) count += (activeBounds.maxRow - activeBounds.minRow + 1);
|
|
multiSelections.forEach(s => count += (s.maxRow - s.minRow + 1));
|
|
state.selectedRowCount = count;
|
|
};
|
|
|
|
const clearAllActive = (keepSelection = false) => {
|
|
if (!keepSelection) {
|
|
activeBounds = null;
|
|
startCell = null;
|
|
multiSelections = [];
|
|
applySelectionUI();
|
|
updateStatus();
|
|
}
|
|
};
|
|
|
|
const startSelect = (row, col, e) => {
|
|
const alreadySelected = isCellSelected(row, col);
|
|
const isRange = (activeBounds && (activeBounds.minRow !== activeBounds.maxRow || activeBounds.minCol !== activeBounds.maxCol)) || multiSelections.length > 0;
|
|
|
|
if (e.shiftKey && startCell) {
|
|
activeBounds = {
|
|
minRow: Math.min(startCell.row, row),
|
|
maxRow: Math.max(startCell.row, row),
|
|
minCol: Math.min(startCell.col, col),
|
|
maxCol: Math.max(startCell.col, col)
|
|
};
|
|
} else {
|
|
if (alreadySelected && !e.ctrlKey && !e.metaKey) {
|
|
// If clicking inside a range or a single selection, don't clear yet.
|
|
// Mark for potential single-cell toggle-off on mouseup if it was just a single cell.
|
|
if (!isRange) container._potentialCancel = { row, col };
|
|
// else: Keep current selection as is (sticky selection)
|
|
} else {
|
|
if (!e.ctrlKey && !e.metaKey) {
|
|
clearAllActive();
|
|
} else if (activeBounds && !alreadySelected) {
|
|
multiSelections.push(activeBounds);
|
|
}
|
|
startCell = { row, col };
|
|
activeBounds = { minRow: row, maxRow: row, minCol: col, maxCol: col };
|
|
}
|
|
state.isSelecting = true;
|
|
}
|
|
applySelectionUI();
|
|
updateStatus();
|
|
container.focus();
|
|
};
|
|
|
|
const updateSelect = (row, col) => {
|
|
if (state.isSelecting && startCell) {
|
|
activeBounds = {
|
|
minRow: Math.min(startCell.row, row),
|
|
maxRow: Math.max(startCell.row, row),
|
|
minCol: Math.min(startCell.col, col),
|
|
maxCol: Math.max(startCell.col, col)
|
|
};
|
|
container._potentialCancel = null; // Dragging means no cancel
|
|
applySelectionUI();
|
|
updateStatus();
|
|
}
|
|
};
|
|
|
|
const endSelect = () => {
|
|
if (container._potentialCancel) {
|
|
const { row, col } = container._potentialCancel;
|
|
if (isCellSelected(row, col)) {
|
|
clearAllActive();
|
|
}
|
|
container._potentialCancel = null;
|
|
}
|
|
state.isSelecting = false;
|
|
};
|
|
|
|
const getSelectionBounds = () => {
|
|
if (!activeBounds) return null;
|
|
let minRow = activeBounds.minRow, maxRow = activeBounds.maxRow;
|
|
let minCol = activeBounds.minCol, maxCol = activeBounds.maxCol;
|
|
multiSelections.forEach(s => {
|
|
minRow = Math.min(minRow, s.minRow);
|
|
maxRow = Math.max(maxRow, s.maxRow);
|
|
minCol = Math.min(minCol, s.minCol);
|
|
maxCol = Math.max(maxCol, s.maxCol);
|
|
});
|
|
return { minRow, maxRow, minCol, maxCol };
|
|
};
|
|
|
|
// Excel-style TSV Copy
|
|
const copy = async () => {
|
|
const bounds = getSelectionBounds();
|
|
if (!bounds) return;
|
|
|
|
const text = state.list.slice(bounds.minRow, bounds.maxRow + 1).map(row => {
|
|
return state.fields.slice(bounds.minCol, bounds.maxCol + 1).map(f => {
|
|
let val = String(row[f.id] ?? '');
|
|
if (val.includes('\t') || val.includes('\n') || val.includes('"')) {
|
|
val = '"' + val.replace(/"/g, '""') + '"';
|
|
}
|
|
return val;
|
|
}).join('\t');
|
|
}).join('\n');
|
|
|
|
await navigator.clipboard.writeText(text);
|
|
};
|
|
|
|
// Excel-style TSV Paste (Optimized for targeted refresh)
|
|
const paste = async () => {
|
|
try {
|
|
const text = await navigator.clipboard.readText();
|
|
if (!text) return;
|
|
|
|
const bounds = getSelectionBounds();
|
|
if (!bounds) return;
|
|
|
|
// Simple TSV parser
|
|
const rows = text.split(/\r?\n/).filter(line => line.length > 0).map(line => {
|
|
const cells = [];
|
|
let current = '', inQuotes = false;
|
|
for (let i = 0; i < line.length; i++) {
|
|
const char = line[i];
|
|
if (char === '"') {
|
|
if (inQuotes && line[i+1] === '"') { current += '"'; i++; }
|
|
else inQuotes = !inQuotes;
|
|
} else if (char === '\t' && !inQuotes) {
|
|
cells.push(current);
|
|
current = '';
|
|
} else {
|
|
current += char;
|
|
}
|
|
}
|
|
cells.push(current);
|
|
return cells;
|
|
});
|
|
|
|
const startRow = bounds.minRow;
|
|
const startCol = bounds.minCol;
|
|
const maxRow = bounds.maxRow;
|
|
const maxCol = bounds.maxCol;
|
|
const body = container.querySelector('.dt-body');
|
|
const rowNodes = body ? Array.from(body.childNodes).filter(n => n.classList?.contains('dt-body-row')) : [];
|
|
|
|
rows.forEach((rowData, rOffset) => {
|
|
const rIdx = startRow + rOffset;
|
|
if (rIdx > maxRow || rIdx >= state.list.length) return;
|
|
|
|
const rowItem = state.list[rIdx];
|
|
let rowChanged = false;
|
|
|
|
rowData.forEach((cellData, cOffset) => {
|
|
const cIdx = startCol + cOffset;
|
|
if (cIdx > maxCol || cIdx >= state.fields.length) return;
|
|
const field = state.fields[cIdx];
|
|
rowItem[field.id] = cellData;
|
|
rowChanged = true;
|
|
});
|
|
|
|
// Targeted DOM refresh to avoid full list scan lag
|
|
if (rowChanged && container.refreshNode) {
|
|
const domNode = rowNodes.find(n => (n._ref?.rIdx ?? -1) + state._listStartIndex === rIdx);
|
|
if (domNode) container.refreshNode(domNode);
|
|
}
|
|
});
|
|
} catch (err) {
|
|
console.error('Paste Error:', err);
|
|
}
|
|
};
|
|
|
|
const deleteSelected = () => {
|
|
const bounds = getSelectionBounds();
|
|
if (!bounds) return;
|
|
const rMin = bounds.minRow, rMax = bounds.maxRow;
|
|
const newList = state.list.filter((_, i) => !(i >= rMin && i <= rMax));
|
|
state.list = newList;
|
|
clearAllActive();
|
|
};
|
|
|
|
return {
|
|
applySelectionUI,
|
|
clearAllActive,
|
|
startSelect,
|
|
updateSelect,
|
|
endSelect,
|
|
deleteSelected,
|
|
getSelectionBounds,
|
|
copy,
|
|
paste
|
|
};
|
|
};
|