fix(muya): restore cross-cell table selection (Phase G) · marktext/marktext@27a8c16 · GitHub
Skip to content

Commit 27a8c16

Browse files
Jocsclaude
andcommitted
fix(muya): restore cross-cell table selection (Phase G)
Port of legacy `tableSelectCellsCtrl`: dragging a rectangle of table cells now selects that contiguous range (visual highlight) and copy/cut operate on just those cells rather than the whole table. - `TableCellSelection` (editor module, attached in `editor.init`): mousedown records the anchor cell; once the pointer leaves it the drag arms, the native text range is collapsed to a caret in the anchor (keeping the editor focused so clipboard events still fire) and the anchor->focus rectangle gets the `.mu-table-cell-selected` class via each cell's `::before` overlay. A new mousedown clears the previous selection. Listeners go through `attachDOMEvent`, so `detachAllDomEvents` cleans them up. - `Table.cellAt(row, column)` / `Table.getSubTableState(...)` extract a normalised, dimension-clamped rectangle as an `ITableState`. - `Clipboard` consults the frozen selection first: a single selected cell copies its plain text (no html), a larger rectangle serialises to GFM table markdown via `StateToMarkdown`; cut empties just those cells in place (json-op'd through the content `text` setter). Covered by unit specs (cellAt / getSubTableState, drag highlight + copy/cut sub-range) and a Chromium e2e exercising a real pointer drag and OS clipboard round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9f216dd commit 27a8c16

8 files changed

Lines changed: 806 additions & 1 deletion

File tree

Lines changed: 113 additions & 0 deletions

‎packages/muya/src/assets/styles/blockSyntax.css‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,17 @@ li.mu-task-list-item > span.mu-checkbox-checked::before {
560560
margin-bottom: 0;
561561
}
562562

563+
/* Cross-cell drag selection (port of marktext's `.ag-cell-selected`): the
564+
rectangle of cells the user dragged across is tinted via the per-cell
565+
`::before` overlay so it reads as one contiguous selection. Every cell
566+
(header included) renders as a `td`, and the overlay already paints the cell
567+
border, so a translucent accent fill is enough. */
568+
.mu-table-inner tr td.mu-table-cell-selected::before {
569+
z-index: 1;
570+
571+
background: var(--selection-color);
572+
}
573+
563574
.mu-table-inner td[data-align='left'] {
564575
text-align: left;
565576
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// @vitest-environment happy-dom
2+
3+
import type Table from '..';
4+
import type { ITableState } from '../../../../state/types';
5+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6+
import { Muya } from '../../../../muya';
7+
8+
// Unit coverage for the cross-cell selection building blocks added for the
9+
// Phase G table-selection restoration: `Table.cellAt` (resolve a body cell by
10+
// row/column offsets) and `Table.getSubTableState` (extract a rectangular block
11+
// of cells as an `ITableState` so the clipboard can serialise just that range).
12+
13+
const bootedHosts: HTMLElement[] = [];
14+
let hadVersion = false;
15+
let originalVersion: string | undefined;
16+
17+
beforeEach(() => {
18+
hadVersion = 'MUYA_VERSION' in window;
19+
originalVersion = window.MUYA_VERSION;
20+
window.MUYA_VERSION = 'test';
21+
});
22+
23+
afterEach(() => {
24+
while (bootedHosts.length) {
25+
const host = bootedHosts.pop()!;
26+
host.remove();
27+
}
28+
if (hadVersion)
29+
window.MUYA_VERSION = originalVersion as string;
30+
else
31+
delete (window as Partial<Window>).MUYA_VERSION;
32+
});
33+
34+
// A 3-row × 3-column table: header (a1/b1/c1) plus two body rows.
35+
const TABLE_MD = [
36+
'| a1 | b1 | c1 |',
37+
'| --- | --- | --- |',
38+
'| a2 | b2 | c2 |',
39+
'| a3 | b3 | c3 |',
40+
'',
41+
].join('\n');
42+
43+
function bootMuya(markdown: string): Muya {
44+
const host = document.createElement('div');
45+
document.body.appendChild(host);
46+
const muya = new Muya(host, { markdown } as ConstructorParameters<typeof Muya>[1]);
47+
muya.init();
48+
bootedHosts.push(muya.domNode);
49+
return muya;
50+
}
51+
52+
function firstTable(muya: Muya): Table {
53+
const block = muya.editor.scrollPage!.firstContentInDescendant()!.closestBlock('table');
54+
return block as Table;
55+
}
56+
57+
function cellTexts(state: ITableState): string[][] {
58+
return state.children.map(row => row.children.map(cell => cell.text));
59+
}
60+
61+
describe('table.cellAt', () => {
62+
it('resolves a body cell by (row, column) offsets', () => {
63+
const table = firstTable(bootMuya(TABLE_MD));
64+
const cell = table.cellAt(2, 1); // 3rd row, 2nd column => "b3"
65+
expect(cell).not.toBeNull();
66+
expect(cell!.getState().text).toBe('b3');
67+
});
68+
69+
it('returns null for an out-of-range row or column', () => {
70+
const table = firstTable(bootMuya(TABLE_MD));
71+
expect(table.cellAt(9, 0)).toBeNull();
72+
expect(table.cellAt(0, 9)).toBeNull();
73+
});
74+
});
75+
76+
describe('table.getSubTableState', () => {
77+
it('extracts the rectangle between two corners (header becomes the sub-table header)', () => {
78+
const table = firstTable(bootMuya(TABLE_MD));
79+
// Rows 0..1, columns 0..1 => a1/b1 + a2/b2.
80+
const state = table.getSubTableState(0, 0, 1, 1);
81+
expect(cellTexts(state)).toEqual([
82+
['a1', 'b1'],
83+
['a2', 'b2'],
84+
]);
85+
});
86+
87+
it('normalises reversed bounds (focus before anchor)', () => {
88+
const table = firstTable(bootMuya(TABLE_MD));
89+
// Same rectangle, corners passed bottom-right -> top-left.
90+
const state = table.getSubTableState(2, 2, 1, 1);
91+
expect(cellTexts(state)).toEqual([
92+
['b2', 'c2'],
93+
['b3', 'c3'],
94+
]);
95+
});
96+
97+
it('clamps bounds that exceed the table dimensions', () => {
98+
const table = firstTable(bootMuya(TABLE_MD));
99+
const state = table.getSubTableState(0, 0, 99, 99);
100+
// The full 3x3 table is returned, not a ragged/oversized one.
101+
expect(cellTexts(state)).toEqual([
102+
['a1', 'b1', 'c1'],
103+
['a2', 'b2', 'c2'],
104+
['a3', 'b3', 'c3'],
105+
]);
106+
});
107+
108+
it('preserves per-column alignment in the extracted cells', () => {
109+
const aligned = [
110+
'| h1 | h2 |',
111+
'| :--- | ---: |',
112+
'| x | y |',
113+
'',
114+
].join('\n');
115+
const table = firstTable(bootMuya(aligned));
116+
const state = table.getSubTableState(0, 0, 1, 1);
117+
expect(state.children[0].children[0].meta.align).toBe('left');
118+
expect(state.children[0].children[1].meta.align).toBe('right');
119+
});
120+
});

‎packages/muya/src/block/gfm/table/index.ts‎

Lines changed: 49 additions & 1 deletion

0 commit comments

Comments
 (0)