feat: implement sticky table header

This commit is contained in:
Vadim Melnicuk
2026-09-10 22:45:30 +01:00
parent 342a300cbc
commit c0d107b8a2
5 changed files with 179 additions and 45 deletions
+3 -31
View File
@@ -5,6 +5,7 @@ import { markdown, markdownKeymap, markdownLanguage } from '@codemirror/lang-mar
import { indentUnit, syntaxHighlighting, syntaxTree, forceParsing, codeFolding } from '@codemirror/language';
import { vim, Vim } from '@replit/codemirror-vim';
import { highlightStyle } from './theme';
import { findSyncChange } from './helpers/textChange';
import { shikiCodeHighlight } from './helpers/shikiDecorations';
import { liveModeExtensions } from './liveMode';
import { readOnlyExtensions, activeLineHighlightExtensions, externalSyncAnnotation, copyReadOnlySelection } from './helpers/readOnly';
@@ -520,7 +521,7 @@ export function createEditor({
if (!(hit instanceof Element)) {
continue;
}
const wrap = hit.closest('.meo-md-html-table-wrap');
const wrap = hit.closest('.meo-md-html-table-shell');
if (!(wrap instanceof HTMLElement) || !editorView.dom.contains(wrap)) {
continue;
}
@@ -637,7 +638,7 @@ export function createEditor({
if (!view.dom.contains(active)) {
return null;
}
return active.closest('.meo-md-html-table-wrap') ? active : null;
return active.closest('.meo-md-html-table-shell') ? active : null;
};
const getTableInputSourceRange = (input: HTMLTextAreaElement): { from: number; to: number } | null => {
@@ -2572,35 +2573,6 @@ function isInsideTableCell(state, position) {
return false;
}
function findSyncChange(previousText, nextText) {
if (previousText === nextText) {
return null;
}
let from = 0;
const maxStart = Math.min(previousText.length, nextText.length);
while (from < maxStart && previousText.charCodeAt(from) === nextText.charCodeAt(from)) {
from += 1;
}
let previousTo = previousText.length;
let nextTo = nextText.length;
while (
previousTo > from &&
nextTo > from &&
previousText.charCodeAt(previousTo - 1) === nextText.charCodeAt(nextTo - 1)
) {
previousTo -= 1;
nextTo -= 1;
}
return {
from,
to: previousTo,
insert: nextText.slice(from, nextTo)
};
}
// Map a position through a single replace change so external syncs keep the cursor nearby.
function mapPositionThroughChange(position, change) {
const insertLength = change.insert.length;
+40
View File
@@ -0,0 +1,40 @@
/** Put the editable header outside the body's horizontal scroller so CSS can pin it. */
export function createStickyTableHeader(
shell: HTMLElement,
wrap: HTMLElement,
header: HTMLTableSectionElement,
columnCount: number
) {
const band = document.createElement('div');
band.className = 'meo-md-html-table-sticky-header';
const viewport = document.createElement('div');
viewport.className = 'meo-md-html-table-header-wrap';
const table = document.createElement('table');
table.className = 'meo-md-html-table';
const columns = Array.from({ length: columnCount }, () => document.createElement('col'));
const colgroup = document.createElement('colgroup');
colgroup.append(...columns);
table.append(colgroup, header);
viewport.append(table);
band.append(viewport);
const syncFromBody = (): void => { viewport.scrollLeft = wrap.scrollLeft; };
const syncFromHeader = (): void => { wrap.scrollLeft = viewport.scrollLeft; };
wrap.addEventListener('scroll', syncFromBody, { passive: true });
viewport.addEventListener('scroll', syncFromHeader, { passive: true });
const observer = new ResizeObserver(() => {
shell.style.setProperty('--meo-table-sticky-height', `${band.getBoundingClientRect().height}px`);
syncFromBody();
});
observer.observe(band);
return {
band,
table,
columns,
destroy(): void {
observer.disconnect();
wrap.removeEventListener('scroll', syncFromBody);
viewport.removeEventListener('scroll', syncFromHeader);
}
};
}
+69 -12
View File
@@ -10,6 +10,8 @@ import { isPrimaryModifierPointerClick } from './linkNavigation';
import { wikiLinkScheme } from './wikiLinks';
import { normalizeSourceHref } from './rawUrls';
import type { EditorDiagnostic } from './diagnostics';
import { createStickyTableHeader } from './tableStickyHeader';
import { findSyncChange } from './textChange';
declare global {
interface HTMLDivElement {
@@ -25,6 +27,7 @@ interface TableData {
endLine?: number;
indent?: string;
signature?: string;
contentSignature?: string;
from?: number;
to?: number;
headerCells?: string[];
@@ -42,10 +45,12 @@ interface DomRefs {
rowInputs: HTMLTextAreaElement[][];
allRowInputs: HTMLTextAreaElement[][];
table: HTMLTableElement;
headerTable: HTMLTableElement;
headerColEls: HTMLTableColElement[];
tbody: HTMLTableSectionElement;
container: HTMLElement;
shell: HTMLElement;
wrap: HTMLElement;
wrap: HTMLDivElement;
toolbar: HTMLElement;
cellGrid: HTMLTableCellElement[][];
rowEntries: RowEntry[];
@@ -1356,6 +1361,8 @@ function buildTableDataForLineRange(state, startLineNo, endLineNo) {
};
}
const tableWidgetOwners = new WeakMap<HTMLElement, HtmlTableWidget>();
class HtmlTableWidget extends WidgetType {
tableData: TableData;
view: EditorView | null;
@@ -1402,6 +1409,32 @@ class HtmlTableWidget extends WidgetType {
);
}
updateDOM(dom: HTMLElement): boolean {
const owner = tableWidgetOwners.get(dom);
if (!owner?.domRefs || owner.tableData.contentSignature !== this.tableData.contentSignature) return false;
// Diagnostic updates must retain the inputs, pending edits, focus, and scroll position.
// Event listeners still belong to the instance that created this DOM.
owner.tableData = this.tableData;
const inputs = [owner.domRefs.headerInputs, ...owner.domRefs.sourceBodyRowInputs];
for (const [row, rowInputs] of inputs.entries()) {
for (const [col, input] of rowInputs.entries()) {
const range = owner.cellSourceRange(row, col);
if (range) {
input.dataset.tableCellFrom = String(range.from);
input.dataset.tableCellTo = String(range.to);
}
owner.renderCellPreview(
input.parentElement?.querySelector('.meo-md-html-table-cell-preview'),
tableCellEditorTextToMarkdown(input.value),
owner.cellDiagnostics(row, col),
range
);
}
}
return true;
}
getEditorView(dom?: HTMLElement): EditorView | null {
if (this.view) return this.view;
if (!dom) return null;
@@ -1668,7 +1701,7 @@ class HtmlTableWidget extends WidgetType {
findCellElement(node) {
if (!this.domRefs || !(node instanceof Element)) return null;
const cell = node.closest(tableCellSelector);
if (!cell || !this.domRefs.table.contains(cell)) return null;
if (!cell || !this.domRefs.shell.contains(cell)) return null;
return cell;
}
@@ -1684,7 +1717,7 @@ class HtmlTableWidget extends WidgetType {
const nextCaret = Math.min(Math.max(caret ?? input.value.length, 0), input.value.length);
input.setSelectionRange(nextCaret, nextCaret);
input.closest(tableCellSelector)?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
const container = input.closest('.meo-md-html-table-wrap');
const container = input.closest('.meo-md-html-table-shell');
if (container instanceof HTMLElement) {
this.emitTableSelectionChange(container);
}
@@ -1826,7 +1859,7 @@ class HtmlTableWidget extends WidgetType {
const active = document.activeElement;
if (!(active instanceof Element)) return false;
if (!view.dom.contains(active)) return false;
return active.closest('.meo-md-html-table-wrap') !== null;
return active.closest('.meo-md-html-table-shell') !== null;
}
selectedCellCount() {
@@ -2095,7 +2128,15 @@ class HtmlTableWidget extends WidgetType {
return;
}
view.dispatch({ changes: { from: range.from, to: range.to, insert: markdown } });
const change = findSyncChange(current, markdown);
if (!change) return;
const changes = view.state.changes({
from: range.from + change.from,
to: range.from + change.to,
insert: change.insert
});
// Keep the table in the viewport while CodeMirror measures its replacement widget.
view.dispatch({ changes, effects: view.scrollSnapshot().map(changes) });
this.hasPendingCellEdits = false;
if (focusTarget) {
this.scheduleFocusCellAfterCommit(view, tableStartLine, focusTarget);
@@ -2327,7 +2368,7 @@ class HtmlTableWidget extends WidgetType {
fitColumnWidths() {
if (!this.domRefs) return false;
const { shell, wrap, table, colEls, headerInputs, rowInputs } = this.domRefs;
const { shell, wrap, table, headerTable, colEls, headerColEls, headerInputs, rowInputs } = this.domRefs;
const chPx = this.measureChPx(wrap);
const cellChromePx = (cellHorizontalPaddingCh * chPx) + cellBorderPx;
const minPx = (minColumnWidthCh * chPx) + cellChromePx;
@@ -2346,11 +2387,13 @@ class HtmlTableWidget extends WidgetType {
colEls[i].style.width = `${widthPx}px`;
colEls[i].style.minWidth = `${widthPx}px`;
colEls[i].style.maxWidth = 'none';
headerColEls[i].style.cssText = colEls[i].style.cssText;
}
this.lastAppliedWidths = nextAppliedWidths;
table.style.width = `${targetTotal}px`;
table.style.minWidth = `${targetTotal}px`;
table.style.maxWidth = 'none';
headerTable.style.cssText = table.style.cssText;
this.updateApplySortAnchor(shell, wrap, targetTotal);
return changed;
}
@@ -2566,6 +2609,7 @@ class HtmlTableWidget extends WidgetType {
}
const shell = document.createElement('div');
shell.className = 'meo-md-html-table-shell';
tableWidgetOwners.set(shell, this);
const wrap = document.createElement('div');
wrap.className = 'meo-md-html-table-wrap';
if (Number.isFinite(this.tableData.startLine)) {
@@ -2619,7 +2663,7 @@ class HtmlTableWidget extends WidgetType {
const th = document.createElement('th');
th.dataset.tableRow = '0';
th.dataset.tableCol = String(col);
const { content, input } = this.createCellEditor(this.tableData.headerCells[col] ?? '', headerRow, headerInputs, wrap, 0, col);
const { content, input } = this.createCellEditor(this.tableData.headerCells[col] ?? '', headerRow, headerInputs, shell, 0, col);
headerInputs.push(input);
headerCells.push(th);
th.appendChild(content);
@@ -2649,7 +2693,7 @@ class HtmlTableWidget extends WidgetType {
cellGrid.push(headerCells);
allRowInputs.push(headerInputs);
thead.appendChild(headerRow);
table.appendChild(thead);
const tbody = document.createElement('tbody');
const bodyRowInputs = [];
@@ -2665,7 +2709,7 @@ class HtmlTableWidget extends WidgetType {
const td = document.createElement('td');
td.dataset.tableRow = String(tableRowIndex);
td.dataset.tableCol = String(col);
const { content, input } = this.createCellEditor(this.tableData.rows[rowIdx][col] ?? '', tr, inputs, wrap, tableRowIndex, col);
const { content, input } = this.createCellEditor(this.tableData.rows[rowIdx][col] ?? '', tr, inputs, shell, tableRowIndex, col);
inputs.push(input);
bodyCells.push(td);
td.appendChild(content);
@@ -2682,11 +2726,16 @@ class HtmlTableWidget extends WidgetType {
table.appendChild(tbody);
wrap.append(table);
shell.append(toolbar, wrap, applySortButton);
const stickyHeader = createStickyTableHeader(shell, wrap, thead, this.tableData.colCount);
stickyHeader.band.prepend(toolbar, applySortButton);
shell.append(stickyHeader.band, wrap);
this.cleanupFns.push(stickyHeader.destroy);
this.domRefs = {
shell,
wrap,
table,
headerTable: stickyHeader.table,
headerColEls: stickyHeader.columns,
tbody,
container: shell,
toolbar,
@@ -2704,7 +2753,7 @@ class HtmlTableWidget extends WidgetType {
toolbarButtons
};
this.updateActionTargetStyles();
this.wireTableSelection(table);
this.wireTableSelection(shell);
this.pendingResizeRows = true;
this.scheduleLayout({ resizeRows: true });
@@ -2742,9 +2791,15 @@ class HtmlTableWidget extends WidgetType {
}
destroy(dom) {
const owner = tableWidgetOwners.get(dom);
tableWidgetOwners.delete(dom);
if (owner && owner !== this) {
owner.destroy(dom);
return;
}
for (const cleanup of this.cleanupFns) cleanup();
this.cleanupFns = [];
dom?._meoTableResizeObserver?.disconnect();
this.domRefs?.wrap._meoTableResizeObserver?.disconnect();
if (this.layoutFrame) {
cancelAnimationFrame(this.layoutFrame);
this.layoutFrame = 0;
@@ -2877,6 +2932,7 @@ function addTableWidgetDecoration(builder, data, diagnostics: EditorDiagnostic[]
const normalizedAlignments = normalizeRow(alignments, colCount).map((value) => value ?? null);
const headerCells = normalizeRow(headerLine.cells, colCount);
const rows = dataLines.map((line) => normalizeRow(line.cells, colCount));
const contentSignature = JSON.stringify({ colCount, headerCells, rows, normalizedAlignments, indent });
const signature = JSON.stringify({
colCount,
headerCells,
@@ -2898,6 +2954,7 @@ function addTableWidgetDecoration(builder, data, diagnostics: EditorDiagnostic[]
headerCells,
rows,
signature,
contentSignature,
startLine,
endLine,
diagnostics: collectTableDiagnostics(data, diagnostics),
+29
View File
@@ -0,0 +1,29 @@
export function findSyncChange(previousText: string, nextText: string): { from: number; to: number; insert: string } | null {
if (previousText === nextText) {
return null;
}
let from = 0;
const maxStart = Math.min(previousText.length, nextText.length);
while (from < maxStart && previousText.charCodeAt(from) === nextText.charCodeAt(from)) {
from += 1;
}
let previousTo = previousText.length;
let nextTo = nextText.length;
while (
previousTo > from &&
nextTo > from &&
previousText.charCodeAt(previousTo - 1) === nextText.charCodeAt(nextTo - 1)
) {
previousTo -= 1;
nextTo -= 1;
}
return {
from,
to: previousTo,
insert: nextText.slice(from, nextTo)
};
}
+38 -2
View File
@@ -2405,12 +2405,37 @@ body {
.cm-editor .meo-md-html-table-shell {
position: relative;
padding: 30px 0;
padding: 0 0 30px;
width: 100%;
max-width: 100%;
min-width: 0;
}
.cm-editor .meo-md-html-table-sticky-header {
position: sticky;
top: 0;
padding-top: 30px;
background: var(--meo-background);
z-index: 210;
}
.cm-editor .meo-md-html-table-header-wrap {
overflow-x: auto;
scrollbar-width: none;
}
.cm-editor .meo-md-html-table-header-wrap::-webkit-scrollbar {
display: none;
}
.cm-editor .meo-md-html-table thead {
background: var(--meo-background);
}
.cm-editor .meo-md-html-table td {
scroll-margin-top: var(--meo-table-sticky-height, 30px);
}
.cm-editor .meo-md-html-table-wrap {
width: 100%;
max-width: 100%;
@@ -2474,7 +2499,9 @@ body {
}
.cm-editor .meo-md-html-table {
border-collapse: collapse;
/* Separate borders move with the sticky header. */
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
}
@@ -2483,6 +2510,15 @@ body {
position: relative;
padding: 0;
border: 1px solid var(--meo-color-base03);
border-width: 0 1px 1px 0;
}
.cm-editor .meo-md-html-table tr > :first-child {
border-left-width: 1px;
}
.cm-editor .meo-md-html-table thead th {
border-top-width: 1px;
}
.cm-editor .meo-md-html-table th.meo-md-html-table-cell-selected,