Files
2026-07-13 12:30:20 +08:00

530 lines
24 KiB
JavaScript
Vendored
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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.
// HyperMD, copyright (c) by laobubu
// Distributed under an MIT license: http://laobubu.net/HyperMD/LICENSE
//
// DESCRIPTION: Align Table Columns
//
(function (mod){ //[HyperMD] UMD patched!
/*commonjs*/ ("object"==typeof exports&&"undefined"!=typeof module) ? mod(null, exports, require("codemirror"), require("../core")) :
/*amd*/ ("function"==typeof define&&define.amd) ? define(["require","exports","codemirror","../core"], mod) :
/*plain env*/ mod(null, (this.HyperMD.TableAlign = this.HyperMD.TableAlign || {}), CodeMirror, HyperMD);
})(function (require, exports, CodeMirror, core_1) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.defaultOption = {
enabled: false,
};
exports.suggestedOption = {
enabled: true,
};
core_1.suggestedEditorConfig.hmdTableAlign = exports.suggestedOption;
core_1.normalVisualConfig.hmdTableAlign = false;
CodeMirror.defineOption("hmdTableAlign", exports.defaultOption, function (cm, newVal) {
var enabled = !!newVal;
///// convert newVal's type to `Partial<Options>`, if it is not.
if (!enabled || typeof newVal === "boolean") {
newVal = { enabled: enabled };
}
///// apply config and write new values into cm
var inst = exports.getAddon(cm);
for (var k in exports.defaultOption) {
inst[k] = (k in newVal) ? newVal[k] : exports.defaultOption[k];
}
});
//#endregion
/********************************************************************************** */
//#region Addon Class
var TableAlign = /** @class */ (function () {
function TableAlign(cm) {
// options will be initialized to defaultOption (if exists)
// add your code here
var _this = this;
this.cm = cm;
this.styleEl = document.createElement("style");
/**
* Remeasure visible columns, update CSS style to make columns aligned
*
* (This is a debounced function)
*/
// PATCHED: Request-Animation-Frame-throttle instead of timed debounce. tableID is
// line-number-based (hypermd.js: "T" + line), so any line shift
// above a table changes the CSS string and the table line is
// re-rendered with a new id. With a 100ms debounce the new column
// DOM lived for ~100ms without matching CSS — visible flicker.
// requestAnimationFrame batches bursty updates (many `update`
// events collapse into one) AND runs before the browser paints,
// so the new <style> is applied in the same frame as the render.
var rafScheduled = false;
this.updateStyle = function () {
if (rafScheduled) return;
rafScheduled = true;
requestAnimationFrame(function () {
rafScheduled = false;
if (!_this.enabled) return;
var measures = _this.measure();
var css = _this.makeCSS(measures);
if (css === _this._lastCSS) return;
_this.styleEl.textContent = _this._lastCSS = css;
_this.refreshHeights();
});
};
/** CodeMirror renderLine event handler */
this._procLine = function (cm, line, el) {
if (!el.querySelector('.cm-hmd-table-sep'))
return;
var lineSpan = el.firstElementChild;
var lineSpanChildren = Array.prototype.slice.call(lineSpan.childNodes, 0);
var eolState = cm.getStateAfter(line.lineNo());
var columnStyles = eolState.hmdTableColumns;
var tableID = eolState.hmdTableID;
var columnIdx = eolState.hmdTable === 2 /* NORMAL */ ? -1 : 0;
var columnSpan = _this.makeColumn(columnIdx, columnStyles[columnIdx] || "dummy", tableID);
var columnContentSpan = columnSpan.firstElementChild;
for (var _i = 0, lineSpanChildren_1 = lineSpanChildren; _i < lineSpanChildren_1.length; _i++) {
var el_1 = lineSpanChildren_1[_i];
var elClass = el_1.nodeType === Node.ELEMENT_NODE && el_1.className || "";
if (/cm-hmd-table-sep/.test(elClass)) {
// found a "|", and a column is finished
columnIdx++;
columnSpan.appendChild(columnContentSpan);
lineSpan.appendChild(columnSpan);
lineSpan.appendChild(el_1);
columnSpan = _this.makeColumn(columnIdx, columnStyles[columnIdx] || "dummy", tableID);
columnContentSpan = columnSpan.firstElementChild;
}
else {
columnContentSpan.appendChild(el_1);
}
}
columnSpan.appendChild(columnContentSpan);
lineSpan.appendChild(columnSpan);
};
new core_1.FlipFlop(
/* ON */ function () {
cm.on("renderLine", _this._procLine);
cm.on("update", _this.updateStyle);
cm.refresh();
document.head.appendChild(_this.styleEl);
},
/* OFF */ function () {
cm.off("renderLine", _this._procLine);
cm.off("update", _this.updateStyle);
document.head.removeChild(_this.styleEl);
}).bind(this, "enabled", true);
}
/**
* create a <span> container as column,
* note that put content into column.firstElementChild
*/
TableAlign.prototype.makeColumn = function (index, style, tableID) {
var span = document.createElement("span");
span.className = "hmd-table-column hmd-table-column-" + index + " hmd-table-column-" + style;
span.setAttribute("data-column", "" + index);
span.setAttribute("data-table-id", tableID);
var span2 = document.createElement("span");
span2.className = "hmd-table-column-content";
span2.setAttribute("data-column", "" + index);
span.appendChild(span2);
return span;
};
/** Measure all visible tables and columns */
TableAlign.prototype.measure = function () {
var cm = this.cm;
var lineDiv = cm.display.lineDiv; // contains every <pre> line
// PATCHED: cells word-wrap under the generated max-width caps, so a
// plain offsetWidth read would return the capped width and the
// natural width would be lost. The hmd-table-measure class lifts
// the caps (max-width: none, white-space: pre) for the duration of
// the read. We run inside requestAnimationFrame, before paint, so
// the temporary layout is never visible.
lineDiv.classList.add("hmd-table-measure");
// The editor centers content via large side paddings on the
// scroller, so derive the width available to a table from a
// rendered row's own line box rather than from the scroller.
this._availWidth = 0;
var rowPre = lineDiv.querySelector("pre.HyperMD-table-row");
if (rowPre) {
var preStyle = window.getComputedStyle(rowPre);
this._availWidth = rowPre.clientWidth
- (parseFloat(preStyle.paddingLeft) || 0)
- (parseFloat(preStyle.paddingRight) || 0);
}
var contentSpans = lineDiv.querySelectorAll(".hmd-table-column-content");
/** every table's every column's width in px */
var ans = {};
for (var i = 0; i < contentSpans.length; i++) {
var contentSpan = contentSpans[i];
var column = contentSpan.parentElement;
var tableID = column.getAttribute("data-table-id");
var columnIdx = ~~column.getAttribute("data-column");
var width = contentSpan.offsetWidth + 1; // +1 because browsers turn 311.3 into 312
if (!(tableID in ans))
ans[tableID] = [];
var columnWidths = ans[tableID];
while (columnWidths.length <= columnIdx)
columnWidths.push(0);
if (columnWidths[columnIdx] < width)
columnWidths[columnIdx] = width;
}
lineDiv.classList.remove("hmd-table-measure");
return ans;
};
/**
* PATCHED: cap column widths so a table fits the editor width and
* its cells word-wrap instead of growing horizontally (issue #43).
* Columns narrower than their fair share keep their natural width;
* the remaining space is split evenly among the wider ones.
*/
TableAlign.prototype.capWidths = function (widths, available) {
var minCap = 48;
var total = 0;
for (var i = 0; i < widths.length; i++)
total += widths[i];
if (available <= 0 || total <= available)
return widths;
var sorted = widths.slice().sort(function (a, b) { return a - b; });
var remaining = available;
var cap = minCap;
for (var i = 0; i < sorted.length; i++) {
cap = remaining / (sorted.length - i);
if (sorted[i] > cap)
break;
remaining -= sorted[i];
}
cap = Math.max(Math.floor(cap), minCap);
return widths.map(function (w) { return Math.min(w, cap); });
};
/** Generate CSS */
TableAlign.prototype.makeCSS = function (measures) {
var rules = [];
for (var tableID in measures) {
// Column borders and rounding eat a few px per column.
var overhead = measures[tableID].length * 2 + 8;
var columnWidths = this.capWidths(measures[tableID], this._availWidth - overhead);
var rulePrefix = "pre.HyperMD-table-row.HyperMD-table_" + tableID + " .hmd-table-column-";
for (var columnIdx = 0; columnIdx < columnWidths.length; columnIdx++) {
var width = columnWidths[columnIdx] + .5;
rules.push("" + rulePrefix + columnIdx + " { min-width: " + width + "px }");
rules.push("" + rulePrefix + columnIdx + " .hmd-table-column-content { max-width: " + width + "px }");
}
}
return rules.join("\n");
};
/**
* PATCHED: a width change can now change a table row's height (cells
* word-wrap), but CodeMirror re-measures line heights only during its
* own update cycle, which has already run by the time our CSS lands.
* Stale heights break cursor placement and scrolling for everything
* below the table, so detect the mismatch and refresh.
*/
TableAlign.prototype.refreshHeights = function () {
var cm = this.cm;
var view = cm.display.view;
for (var i = 0; i < view.length; i++) {
var lineView = view[i];
if (lineView.hidden || lineView.rest || !lineView.node)
continue;
if (lineView.line.widgets && lineView.line.widgets.length)
continue;
if (!lineView.node.querySelector(".hmd-table-column"))
continue;
var rect = lineView.node.getBoundingClientRect();
if (Math.abs((rect.bottom - rect.top) - lineView.line.height) > 1) {
cm.refresh();
return;
}
}
};
return TableAlign;
}());
exports.TableAlign = TableAlign;
//#endregion
/** ADDON GETTER (Singleton Pattern): a editor can have only one TableAlign instance */
exports.getAddon = core_1.Addon.Getter("TableAlign", TableAlign, exports.defaultOption);
});
// App-level table UX helpers (not part of the HyperMD table-align addon).
// Hovering a table's right edge shows a thin
// vertical strip with a plus that adds a column, the bottom edge shows a
// horizontal one that adds a row. With Cmd/Ctrl held the planks flip to
// minus and remove the last column / row instead.
function initTablePlus(cm) {
const wrapper = cm.getWrapperElement();
const colBtn = document.createElement('div');
colBtn.className = 'table-plus table-plus-col';
colBtn.textContent = '+';
const rowBtn = document.createElement('div');
rowBtn.className = 'table-plus table-plus-row';
rowBtn.textContent = '+';
wrapper.appendChild(colBtn);
wrapper.appendChild(rowBtn);
const THICK = 16; // plank thickness, also its hover band, px
function hide() {
colBtn.classList.remove('show');
rowBtn.classList.remove('show');
}
function setMinusMode(on) {
colBtn.textContent = rowBtn.textContent = on ? '' : '+';
}
// Cmd/Ctrl pressed while the mouse rests over a plank; mousemove below
// keeps the glyph in sync when the mouse enters with the key held.
document.addEventListener('keydown', function (e) {
if (e.key === 'Meta' || e.key === 'Control') setMinusMode(true);
});
document.addEventListener('keyup', function (e) {
if (e.key === 'Meta' || e.key === 'Control') setMinusMode(false);
});
// Visible tables as client-rect blocks, keyed by table id
function tableBlocks() {
const blocks = [];
for (const pre of cm.display.lineDiv.querySelectorAll('pre.HyperMD-table-row')) {
const id = (pre.className.match(/HyperMD-table_(T\d+)/) || [])[1];
if (!id) continue;
let last = blocks[blocks.length - 1];
if (!last || last.id !== id) {
last = {id, top: Infinity, bottom: 0, left: Infinity, right: 0};
blocks.push(last);
}
// The bordered cells, not the pre, define the table box - the
// pre includes line padding above and below the borders. The
// |---| separator row is collapsed (no padding, no borders), its
// cells sit outside the visible table box, so skip it.
if (/HyperMD-table-row-1\b/.test(pre.className)) continue;
for (const col of pre.querySelectorAll('.hmd-table-column:not(.hmd-table-column-dummy)')) {
const r = col.getBoundingClientRect();
if (r.width === 0) continue;
last.left = Math.min(last.left, r.left);
last.right = Math.max(last.right, r.right);
last.top = Math.min(last.top, r.top);
last.bottom = Math.max(last.bottom, r.bottom);
}
}
return blocks.filter(b => b.right > b.left);
}
function update(mx, my) {
const wrapRect = wrapper.getBoundingClientRect();
let col = null, row = null;
// The hover bands match the planks' footprints exactly (planks sit
// 1px over the table border), so a shown plank is always clickable.
for (const b of tableBlocks()) {
if (my >= b.top && my <= b.bottom && mx >= b.right - 1 && mx <= b.right - 1 + THICK) col = b;
if (mx >= b.left && mx <= b.right && my >= b.bottom - 1 && my <= b.bottom - 1 + THICK) row = b;
}
if (col) {
colBtn.classList.add('show');
colBtn.style.left = (col.right - wrapRect.left - 1) + 'px';
colBtn.style.top = (col.top - wrapRect.top) + 'px';
colBtn.style.height = (col.bottom - col.top) + 'px';
colBtn._table = col;
} else {
colBtn.classList.remove('show');
}
if (row) {
rowBtn.classList.add('show');
rowBtn.style.left = (row.left - wrapRect.left) + 'px';
rowBtn.style.top = (row.bottom - wrapRect.top - 1) + 'px';
rowBtn.style.width = (row.right - row.left) + 'px';
rowBtn._table = row;
} else {
rowBtn.classList.remove('show');
}
}
let raf = 0;
wrapper.addEventListener('mousemove', function (e) {
if (raf) return;
raf = requestAnimationFrame(function () {
raf = 0;
setMinusMode(e.metaKey || e.ctrlKey);
update(e.clientX, e.clientY);
});
});
wrapper.addEventListener('mouseleave', hide);
cm.on('scroll', hide);
cm.on('change', hide);
hide();
colBtn.addEventListener('mousedown', function (e) {
e.preventDefault();
e.stopPropagation();
const range = tableLineRange(cm, cm.lineAtHeight(colBtn._table.top + 1, 'window'));
if (!range) return;
if (e.metaKey || e.ctrlKey) tableRemoveColumn(cm, range);
else tableAddColumn(cm, range);
});
rowBtn.addEventListener('mousedown', function (e) {
e.preventDefault();
e.stopPropagation();
const range = tableLineRange(cm, cm.lineAtHeight(rowBtn._table.top + 1, 'window'));
if (!range) return;
if (e.metaKey || e.ctrlKey) tableRemoveRow(cm, range);
else tableAddRow(cm, range);
});
}
// Cmd/Ctrl+A inside a table cell selects the cell's content first;
// an empty cell consumes the key without selecting anything, and when the
// cell is already selected it returns false so the handler falls through
// to the usual select-all.
function tableSelectCell(cm) {
const cursor = cm.getCursor();
if (!cm.getStateAfter(cursor.line).hmdTable) return false;
const text = cm.getLine(cursor.line);
let start = text.lastIndexOf('|', Math.max(cursor.ch - 1, 0)) + 1;
let end = text.indexOf('|', cursor.ch);
if (end < 0) end = text.length;
while (start < end && text[start] === ' ') start++;
while (end > start && text[end - 1] === ' ') end--;
if (start === end) return true;
const from = {line: cursor.line, ch: start};
const to = {line: cursor.line, ch: end};
const sel = cm.listSelections()[0];
if (sel && CodeMirror.cmpPos(sel.from(), from) === 0 && CodeMirror.cmpPos(sel.to(), to) === 0) {
return false;
}
cm.setSelection(from, to);
return true;
}
// Insert an empty 2x2 table at the cursor and focus its first cell
function tableInsert(cm) {
const cursor = cm.getCursor();
const line = cm.getLine(cursor.line);
const table = '| | |\n| --- | --- |\n| | |';
cm.operation(function () {
if (line.trim() === '') {
cm.replaceRange(table, {line: cursor.line, ch: 0}, {line: cursor.line, ch: line.length});
cm.setCursor({line: cursor.line, ch: 2});
} else {
cm.replaceRange('\n' + table, {line: cursor.line, ch: line.length});
cm.setCursor({line: cursor.line + 1, ch: 2});
}
});
}
// Enter inside a table cell moves focus to the same column's cell one row
// below (skipping the |---| separator), selecting its content if there is
// any. Returns false on the last row so the default Enter applies.
function tableEnterCell(cm) {
const cursor = cm.getCursor();
if (!cm.getStateAfter(cursor.line).hmdTable) return false;
const range = tableLineRange(cm, cursor.line);
if (!range) return false;
let target = cursor.line + 1;
if (target === range.from + 1) target++;
if (target > range.to) return false;
const pipes = (cm.getLine(cursor.line).slice(0, cursor.ch).match(/\|/g) || []).length;
const text = cm.getLine(target);
let start = 0;
for (let i = 0, seen = 0; i < text.length && seen < pipes; i++) {
if (text[i] === '|') {
seen++;
start = i + 1;
}
}
const cellStart = start; // right after the opening pipe
let cellEnd = text.indexOf('|', start);
if (cellEnd < 0) cellEnd = text.length;
let from = cellStart, to = cellEnd;
while (from < to && text[from] === ' ') from++;
while (to > from && text[to - 1] === ' ') to--;
if (from === to) {
// Empty cell: land the cursor between the padding spaces, not glued
// to the pipe. Normalise to " cursor " (add spaces if missing).
if (text.slice(cellStart, cellEnd) !== ' ') {
cm.replaceRange(' ', {line: target, ch: cellStart}, {line: target, ch: cellEnd});
}
cm.setCursor({line: target, ch: cellStart + 1});
} else {
cm.setSelection({line: target, ch: from}, {line: target, ch: to});
}
return true;
}
// Expand a line known to be inside a table to the table's full line range
function tableLineRange(cm, line) {
const state = cm.getStateAfter(line);
if (!state.hmdTable) return null;
const id = state.hmdTableID;
const inTable = (l) => {
const st = cm.getStateAfter(l);
return st.hmdTable && st.hmdTableID === id;
};
let from = line, to = line;
while (from > cm.firstLine() && inTable(from - 1)) from--;
while (to < cm.lastLine() && inTable(to + 1)) to++;
return {from, to};
}
// Append an empty column to every row of the table
function tableAddColumn(cm, range) {
cm.operation(function () {
for (let l = range.from; l <= range.to; l++) {
const text = cm.getLine(l);
const isSep = l === range.from + 1; // the |---|---| row
if (/\|\s*$/.test(text)) {
cm.replaceRange(isSep ? ' --- |' : ' |', {line: l, ch: text.lastIndexOf('|') + 1});
} else {
cm.replaceRange(isSep ? ' | ---' : ' | ', {line: l, ch: text.length});
}
}
const header = cm.getLine(range.from);
const inNewCell = /\|\s*$/.test(header) ? header.lastIndexOf('|') - 1 : header.length;
cm.setCursor({line: range.from, ch: inNewCell});
});
cm.focus();
}
// Append an empty row after the table's last row
function tableAddRow(cm, range) {
const state = cm.getStateAfter(range.from);
const cols = (state.hmdTableColumns || []).length || 1;
const normal = state.hmdTable === 2; // table with outer pipes
const row = normal ? '|' + ' |'.repeat(cols) : Array(cols).fill(' ').join('|');
cm.operation(function () {
cm.replaceRange('\n' + row, {line: range.to, ch: cm.getLine(range.to).length});
cm.setCursor({line: range.to + 1, ch: normal ? 2 : 1});
});
cm.focus();
}
// Remove the last column from every row of the table. Cursors
// inside the removed cells get clipped to the line end by CodeMirror.
function tableRemoveColumn(cm, range) {
const state = cm.getStateAfter(range.from);
if ((state.hmdTableColumns || []).length <= 1) return;
cm.operation(function () {
for (let l = range.from; l <= range.to; l++) {
const text = cm.getLine(l);
const lastPipe = text.lastIndexOf('|');
if (lastPipe < 0) continue;
if (/\|\s*$/.test(text)) {
const prevPipe = text.lastIndexOf('|', lastPipe - 1);
if (prevPipe < 0) continue;
cm.replaceRange('|', {line: l, ch: prevPipe}, {line: l, ch: text.length});
} else {
const cut = text.slice(0, lastPipe).replace(/\s+$/, '').length;
cm.replaceRange('', {line: l, ch: cut}, {line: l, ch: text.length});
}
}
});
cm.focus();
}
// Remove the table's last row, never the header or the separator
function tableRemoveRow(cm, range) {
if (range.to <= range.from + 1) return;
cm.operation(function () {
cm.replaceRange('',
{line: range.to - 1, ch: cm.getLine(range.to - 1).length},
{line: range.to, ch: cm.getLine(range.to).length});
});
cm.focus();
}