374 lines
14 KiB
JavaScript
374 lines
14 KiB
JavaScript
window.EditorTables = (function () {
|
||
|
||
function repeat(ch, n) {
|
||
var s = '';
|
||
for (var i = 0; i < n; i++) s += ch;
|
||
return s;
|
||
}
|
||
|
||
var segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
||
|
||
// Graphemes rendered two columns wide: emoji-presentation characters, anything
|
||
// forced to emoji presentation via VS16, and East Asian wide/fullwidth ranges.
|
||
var WIDE = /\p{Emoji_Presentation}|️|[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-가-힣豈-︰-﹏-⦆¢-₩]/u;
|
||
var ZERO = /^[\p{Mn}\p{Me}\p{Cf}]+$/u;
|
||
|
||
function displayWidth(s) {
|
||
var w = 0;
|
||
Array.from(segmenter.segment(s), function (seg) {
|
||
var g = seg.segment;
|
||
if (!ZERO.test(g)) w += WIDE.test(g) ? 2 : 1;
|
||
});
|
||
return w;
|
||
}
|
||
|
||
function padLeft(s, w) {
|
||
return repeat(' ', w - displayWidth(s)) + s;
|
||
}
|
||
|
||
function padRight(s, w) {
|
||
return s + repeat(' ', w - displayWidth(s));
|
||
}
|
||
|
||
function padCenter(s, w) {
|
||
var total = w - displayWidth(s);
|
||
var left = Math.floor(total / 2);
|
||
return repeat(' ', left) + s + repeat(' ', total - left);
|
||
}
|
||
|
||
function parseTableRow(line) {
|
||
var trimmed = line.trim();
|
||
if (trimmed.charAt(0) === '|') trimmed = trimmed.slice(1);
|
||
if (trimmed.charAt(trimmed.length - 1) === '|') trimmed = trimmed.slice(0, -1);
|
||
return trimmed.split('|').map(function (c) { return c.trim(); });
|
||
}
|
||
|
||
function isSeparatorRow(cells) {
|
||
return cells.every(function (c) { return /^:?-+:?$/.test(c); });
|
||
}
|
||
|
||
function parseAlignment(cell) {
|
||
var left = cell.charAt(0) === ':';
|
||
var right = cell.charAt(cell.length - 1) === ':';
|
||
if (left && right) return 'center';
|
||
if (left) return 'left';
|
||
if (right) return 'right';
|
||
return null;
|
||
}
|
||
|
||
function makeSepCell(width, align) {
|
||
if (align === 'left') return ':' + repeat('-', width - 1);
|
||
if (align === 'center') return ':' + repeat('-', width - 2) + ':';
|
||
if (align === 'right') return repeat('-', width - 1) + ':';
|
||
return repeat('-', width);
|
||
}
|
||
|
||
function findTableRange(text, cursorPos) {
|
||
var lines = text.split('\n');
|
||
var charCount = 0;
|
||
var cursorLine = 0;
|
||
for (var i = 0; i < lines.length; i++) {
|
||
if (charCount + lines[i].length >= cursorPos && charCount <= cursorPos) {
|
||
cursorLine = i;
|
||
}
|
||
charCount += lines[i].length + 1;
|
||
}
|
||
if (!/^\|.*\|$/.test(lines[cursorLine].trim())) return null;
|
||
var start = cursorLine;
|
||
while (start > 0 && /^\|.*\|$/.test(lines[start - 1].trim())) start--;
|
||
var end = cursorLine;
|
||
while (end < lines.length - 1 && /^\|.*\|$/.test(lines[end + 1].trim())) end++;
|
||
if (end - start < 1) return null;
|
||
return { start: start, end: end, lines: lines, cursorLine: cursorLine };
|
||
}
|
||
|
||
function formatTableText(text, cursorPos) {
|
||
var range = findTableRange(text, cursorPos);
|
||
if (!range) return null;
|
||
|
||
var rows = [];
|
||
var sepIndex = -1;
|
||
var alignments = [];
|
||
for (var i = range.start; i <= range.end; i++) {
|
||
var cells = parseTableRow(range.lines[i]);
|
||
if (sepIndex === -1 && isSeparatorRow(cells)) {
|
||
sepIndex = rows.length;
|
||
alignments = cells.map(function (c) { return parseAlignment(c); });
|
||
}
|
||
rows.push(cells);
|
||
}
|
||
if (sepIndex === -1) return null;
|
||
|
||
var colCount = 0;
|
||
rows.forEach(function (r) { if (r.length > colCount) colCount = r.length; });
|
||
while (alignments.length < colCount) alignments.push(null);
|
||
|
||
rows = rows.map(function (r) {
|
||
while (r.length < colCount) r.push('');
|
||
return r;
|
||
});
|
||
|
||
var widths = [];
|
||
for (var c = 0; c < colCount; c++) {
|
||
var max = 3;
|
||
for (var r = 0; r < rows.length; r++) {
|
||
if (r === sepIndex) continue;
|
||
var len = displayWidth(rows[r][c]);
|
||
if (len > max) max = len;
|
||
}
|
||
widths.push(max);
|
||
}
|
||
|
||
var formatted = rows.map(function (row, ri) {
|
||
var cells = row.map(function (cell, ci) {
|
||
var w = widths[ci];
|
||
if (ri === sepIndex) return makeSepCell(w, alignments[ci]);
|
||
var align = alignments[ci];
|
||
if (align === 'right') return padLeft(cell, w);
|
||
if (align === 'center') return padCenter(cell, w);
|
||
return padRight(cell, w);
|
||
});
|
||
return '| ' + cells.join(' | ') + ' |';
|
||
});
|
||
|
||
var beforeTable = range.lines.slice(0, range.start).join('\n');
|
||
var afterTable = range.lines.slice(range.end + 1).join('\n');
|
||
var parts = [];
|
||
if (beforeTable) parts.push(beforeTable);
|
||
parts.push(formatted.join('\n'));
|
||
if (afterTable) parts.push(afterTable);
|
||
var newText = parts.join('\n');
|
||
|
||
var oldBeforeLen = 0;
|
||
for (var i = 0; i < range.start; i++) oldBeforeLen += range.lines[i].length + 1;
|
||
var cursorInTable = cursorPos - oldBeforeLen;
|
||
var newTableText = formatted.join('\n');
|
||
var newCursor = (beforeTable ? beforeTable.length + 1 : 0) + Math.min(cursorInTable, newTableText.length);
|
||
|
||
return { text: newText, cursor: newCursor };
|
||
}
|
||
|
||
function getCursorColumn(text, cursorPos) {
|
||
var range = findTableRange(text, cursorPos);
|
||
if (!range) return null;
|
||
var lines = text.split('\n');
|
||
var charCount = 0;
|
||
for (var i = 0; i < range.cursorLine; i++) charCount += lines[i].length + 1;
|
||
var lineOffset = cursorPos - charCount;
|
||
var line = lines[range.cursorLine];
|
||
var col = -1;
|
||
for (var c = 0; c < line.length; c++) {
|
||
if (line.charAt(c) === '|') {
|
||
col++;
|
||
if (c >= lineOffset) return Math.max(col - 1, 0);
|
||
}
|
||
}
|
||
return Math.max(col, 0);
|
||
}
|
||
|
||
function setColumnAlignment(text, cursorPos, align) {
|
||
var range = findTableRange(text, cursorPos);
|
||
if (!range) return null;
|
||
var colIdx = getCursorColumn(text, cursorPos);
|
||
if (colIdx === null) return null;
|
||
|
||
var rows = [];
|
||
var sepIndex = -1;
|
||
for (var i = range.start; i <= range.end; i++) {
|
||
var cells = parseTableRow(range.lines[i]);
|
||
if (sepIndex === -1 && isSeparatorRow(cells)) sepIndex = rows.length;
|
||
rows.push(cells);
|
||
}
|
||
if (sepIndex === -1 || colIdx >= rows[sepIndex].length) return null;
|
||
|
||
var cell = rows[sepIndex][colIdx].replace(/:/g, '-');
|
||
if (align === 'left') cell = ':' + cell.slice(1);
|
||
else if (align === 'center') cell = ':' + cell.slice(1, -1) + ':';
|
||
else if (align === 'right') cell = cell.slice(0, -1) + ':';
|
||
rows[sepIndex][colIdx] = cell;
|
||
|
||
var newLines = range.lines.slice();
|
||
for (var i = 0; i < rows.length; i++) {
|
||
newLines[range.start + i] = '| ' + rows[i].join(' | ') + ' |';
|
||
}
|
||
return formatTableText(newLines.join('\n'), cursorPos);
|
||
}
|
||
|
||
function insertColumn(text, cursorPos) {
|
||
var range = findTableRange(text, cursorPos);
|
||
if (!range) return null;
|
||
var colIdx = getCursorColumn(text, cursorPos);
|
||
if (colIdx === null) return null;
|
||
|
||
var newLines = range.lines.slice();
|
||
var sepIndex = -1;
|
||
for (var i = range.start; i <= range.end; i++) {
|
||
var cells = parseTableRow(range.lines[i]);
|
||
var isSep = sepIndex === -1 && isSeparatorRow(cells);
|
||
if (isSep) sepIndex = i;
|
||
cells.splice(colIdx, 0, isSep ? '---' : '');
|
||
newLines[i] = '| ' + cells.join(' | ') + ' |';
|
||
}
|
||
return formatTableText(newLines.join('\n'), cursorPos);
|
||
}
|
||
|
||
function deleteColumn(text, cursorPos) {
|
||
var range = findTableRange(text, cursorPos);
|
||
if (!range) return null;
|
||
var colIdx = getCursorColumn(text, cursorPos);
|
||
if (colIdx === null) return null;
|
||
|
||
var newLines = range.lines.slice();
|
||
for (var i = range.start; i <= range.end; i++) {
|
||
var cells = parseTableRow(range.lines[i]);
|
||
if (cells.length <= 1) return null;
|
||
cells.splice(colIdx, 1);
|
||
newLines[i] = '| ' + cells.join(' | ') + ' |';
|
||
}
|
||
return formatTableText(newLines.join('\n'), cursorPos);
|
||
}
|
||
|
||
function insertRow(text, cursorPos) {
|
||
var range = findTableRange(text, cursorPos);
|
||
if (!range) return null;
|
||
var colCount = 0;
|
||
for (var i = range.start; i <= range.end; i++) {
|
||
var cells = parseTableRow(range.lines[i]);
|
||
if (cells.length > colCount) colCount = cells.length;
|
||
}
|
||
var emptyCells = [];
|
||
for (var c = 0; c < colCount; c++) emptyCells.push('');
|
||
var newLines = range.lines.slice();
|
||
newLines.splice(range.cursorLine + 1, 0, '| ' + emptyCells.join(' | ') + ' |');
|
||
return formatTableText(newLines.join('\n'), cursorPos);
|
||
}
|
||
|
||
function insertRowBelow(text, cursorPos) {
|
||
var range = findTableRange(text, cursorPos);
|
||
if (!range) return null;
|
||
var result = insertRow(text, cursorPos);
|
||
if (!result) return null;
|
||
var lines = result.text.split('\n');
|
||
var cursor = 0;
|
||
for (var i = 0; i <= range.cursorLine; i++) cursor += lines[i].length + 1;
|
||
cursor += 2; // skip leading '| '
|
||
return { text: result.text, cursor: cursor };
|
||
}
|
||
|
||
function deleteRow(text, cursorPos) {
|
||
var range = findTableRange(text, cursorPos);
|
||
if (!range) return null;
|
||
var sepIndex = -1;
|
||
for (var i = range.start; i <= range.end; i++) {
|
||
if (isSeparatorRow(parseTableRow(range.lines[i]))) { sepIndex = i; break; }
|
||
}
|
||
if (range.cursorLine === range.start || range.cursorLine === sepIndex) return null;
|
||
|
||
var newLines = range.lines.slice();
|
||
newLines.splice(range.cursorLine, 1);
|
||
var newCursor = cursorPos;
|
||
if (range.cursorLine < newLines.length) {
|
||
var charCount = 0;
|
||
for (var i = 0; i < range.cursorLine; i++) charCount += newLines[i].length + 1;
|
||
newCursor = charCount;
|
||
}
|
||
return formatTableText(newLines.join('\n'), Math.min(newCursor, newLines.join('\n').length));
|
||
}
|
||
|
||
// Absolute cursor offset at the start of the `col`-th cell's content on
|
||
// `lineIdx` of a *formatted* table (i.e. just past the "| " / " | " that
|
||
// opens the cell). Clamps to the last cell if col overflows the row.
|
||
function cellCursor(text, lineIdx, col) {
|
||
var lines = text.split('\n');
|
||
var offset = 0;
|
||
for (var i = 0; i < lineIdx; i++) offset += lines[i].length + 1;
|
||
var line = lines[lineIdx] || '';
|
||
var pipes = [];
|
||
for (var c = 0; c < line.length; c++) if (line.charAt(c) === '|') pipes.push(c);
|
||
if (pipes.length === 0) return Math.min(offset, text.length);
|
||
var p = pipes[Math.min(col, pipes.length - 1)];
|
||
return Math.min(offset + p + 2, text.length);
|
||
}
|
||
|
||
// Shared engine for the Enter / Tab / Shift-Tab cell navigation. Reformats
|
||
// the table and drops the cursor into the target cell, appending an empty
|
||
// row when navigation runs past the last row. Returns null when the cursor
|
||
// is not in a table (so the caller can fall back to the editor default).
|
||
function moveInTable(text, cursorPos, mode) {
|
||
var range = findTableRange(text, cursorPos);
|
||
if (!range) return null;
|
||
var colIdx = getCursorColumn(text, cursorPos);
|
||
if (colIdx === null) return null;
|
||
|
||
var sepRel = -1, colCount = 0;
|
||
for (var i = range.start; i <= range.end; i++) {
|
||
var cells = parseTableRow(range.lines[i]);
|
||
if (cells.length > colCount) colCount = cells.length;
|
||
if (sepRel === -1 && isSeparatorRow(cells)) sepRel = i - range.start;
|
||
}
|
||
if (sepRel === -1) return null;
|
||
|
||
var lastRel = range.end - range.start;
|
||
var rowRel = range.cursorLine - range.start;
|
||
var targetRow = rowRel, targetCol = colIdx;
|
||
|
||
if (mode === 'enter') {
|
||
targetRow = rowRel + 1;
|
||
if (targetRow === sepRel) targetRow++;
|
||
} else if (mode === 'tab') {
|
||
targetCol = colIdx + 1;
|
||
if (targetCol >= colCount) {
|
||
targetCol = 0;
|
||
targetRow = rowRel + 1;
|
||
if (targetRow === sepRel) targetRow++;
|
||
}
|
||
} else if (mode === 'shifttab') {
|
||
targetCol = colIdx - 1;
|
||
if (targetCol < 0) {
|
||
targetCol = colCount - 1;
|
||
targetRow = rowRel - 1;
|
||
if (targetRow === sepRel) targetRow--;
|
||
}
|
||
if (targetRow < 0) return null;
|
||
}
|
||
|
||
var lines = range.lines.slice();
|
||
if (targetRow > lastRel) {
|
||
var emptyCells = [];
|
||
for (var c = 0; c < colCount; c++) emptyCells.push('');
|
||
var emptyLine = '| ' + emptyCells.join(' | ') + ' |';
|
||
while (lastRel < targetRow) {
|
||
lines.splice(range.start + lastRel + 1, 0, emptyLine);
|
||
lastRel++;
|
||
}
|
||
}
|
||
|
||
var tableStartOffset = 0;
|
||
for (var i = 0; i < range.start; i++) tableStartOffset += lines[i].length + 1;
|
||
var formatted = formatTableText(lines.join('\n'), tableStartOffset);
|
||
if (!formatted) return null;
|
||
|
||
return {
|
||
text: formatted.text,
|
||
cursor: cellCursor(formatted.text, range.start + targetRow, targetCol),
|
||
};
|
||
}
|
||
|
||
function nextRowSameColumn(text, cursorPos) { return moveInTable(text, cursorPos, 'enter'); }
|
||
function nextCell(text, cursorPos) { return moveInTable(text, cursorPos, 'tab'); }
|
||
function prevCell(text, cursorPos) { return moveInTable(text, cursorPos, 'shifttab'); }
|
||
|
||
return {
|
||
formatTableText: formatTableText,
|
||
nextRowSameColumn: nextRowSameColumn,
|
||
nextCell: nextCell,
|
||
prevCell: prevCell,
|
||
setColumnAlignment: setColumnAlignment,
|
||
insertColumn: insertColumn,
|
||
deleteColumn: deleteColumn,
|
||
insertRow: insertRow,
|
||
insertRowBelow: insertRowBelow,
|
||
deleteRow: deleteRow,
|
||
};
|
||
})();
|