Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
12a1bb6
feat(table): clipboard semantics for cells and rows
sumbad Oct 22, 2026
b74c656
test(debug): CDP verification scripts for the table clipboard task
sumbad Oct 22, 2026
ad97b73
fix(table): review fixes — rowspan-aware row insert, tiling cursor, t…
sumbad Oct 22, 2026
8d9c463
test(debug): harden the table clipboard verification scripts
sumbad Oct 22, 2026
0dacc64
docs(changelog): unreleased — table cell and row clipboard
sumbad Oct 22, 2026
5fac1db
fix(table): survive non-string cell color attrs from clipboard HTML
sumbad Oct 22, 2026
9be7d1b
fix(table): column paste at a caret, single-cell selection
sumbad Oct 22, 2026
a38e0da
feat(table): Copy/Cut/Paste buttons in the table bar
sumbad Oct 23, 2026
cfd62dc
merge: table-clipboard-buttons into the table-ux iteration
sumbad Oct 23, 2026
83c204f
feat(table): duplicate row and column commands + table-bar buttons
sumbad Oct 23, 2026
388d3f6
merge: table-duplicate-row-column into the table-ux iteration
sumbad Oct 23, 2026
81af71f
feat(table): native cell selection on desktop, circle stays mobile-only
sumbad Oct 23, 2026
e97b0c7
merge: table-selection-native-drag into the table-ux iteration
sumbad Oct 23, 2026
36f2271
test(table): cover the toolbar clipboard buttons (caret 1×1 scope)
sumbad Oct 23, 2026
d6a6165
feat(table): drop the selection circle everywhere, desktop long-press
sumbad Oct 23, 2026
14b206b
docs(changelog): unreleased — table clipboard features in English, Ad…
sumbad Oct 23, 2026
327981e
test(e2e): table bar button count after the clipboard and duplicate b…
sumbad Oct 23, 2026
19ac63b
fix(table): greptile review round — cut/copy ordering, merged-cell fi…
sumbad Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Table cells get Copy, Cut and Paste buttons in the table toolbar — the cell clipboard finally works on mobile too, where there is no Cmd+C. The buttons also accept a bare caret in a cell (that cell counts as the selection).
- Duplicate Row and Duplicate Column actions: an exact copy appears right next to the original, header row included.
- Select a table cell by holding the mouse button on it for a moment (desktop). The old selection circle is gone — dragging across cells selects them like in Notion.

### Fixed

- Pasting copied cells into another table replaces the selected cells' content instead of nesting a whole table inside a cell and breaking the layout. Pasting copied rows adds them as new rows aligned to the target table's columns, and Cut removes fully selected rows outright, Excel-style.

## [0.12.1] - 2026-10-22

### Changed
Expand Down
108 changes: 108 additions & 0 deletions scripts/debug/verify-table-clipboard-android.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// tmp-verify-android.mjs — mobile verification of the table clipboard task
// on the Android emulator (CDP :9224), obsidian-plugin vault only.
import { chromium } from 'playwright-core';

const browser = await chromium.connectOverCDP('http://127.0.0.1:9224', { timeout: 10000 });
const page = browser.contexts().flatMap((c) => c.pages()).find((p) => p.url().startsWith('http://localhost/'));
if (page == null) throw new Error('Android Obsidian page not found');

const R = {};
R.vault = await page.evaluate(() => window.app?.vault?.getName?.() ?? null);
R.plugin = await page.evaluate(() => !!window.app?.plugins?.getPlugin?.('inscriptum'));

// write the test note (two 3-col tables) and open it; unique name per run
const notePath = `local/table-clipboard-verify-${Date.now()}.note`;
R.notePath = notePath;
R.setup = await page.evaluate(async (notePath) => {
const mk = (name) => ({
type: 'table', attrs: { data: { rowsCount: 3, colsCount: 3 } },
content: [
{ type: 'tableRow', content: [0, 1, 2].map((i) => ({ type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: name + 'h' + i }] }] })) },
{ type: 'tableRow', content: [0, 1, 2].map((i) => ({ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: name + '0' + i }] }] })) },
{ type: 'tableRow', content: [0, 1, 2].map((i) => ({ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: name + '1' + i }] }] })) },
],
});
const doc = {
type: 'noteDoc',
content: [
{ type: 'noteTitle', content: [{ type: 'text', text: 'table-clipboard-verify' }] },
mk('A'),
{ type: 'paragraph', content: [{ type: 'text', text: 'between' }] },
mk('B'),
],
};
await app.vault.adapter.write(notePath, JSON.stringify(doc));
const file = app.vault.getAbstractFileByPath(notePath);
const leaf = app.workspace.getLeaf('tab');
await leaf.openFile(file);
app.workspace.revealLeaf(leaf);
await new Promise((r) => setTimeout(r, 800));
const ed = leaf.view?.editor;
if (!ed) return { err: 'no editor' };
window.__ed = ed;
window.__cellPos = (text) => {
let p = null;
ed.state.doc.descendants((node, pos) => {
if (!p && (node.type.name === 'tableCell' || node.type.name === 'tableHeader') && node.textContent === text) p = pos;
return !p;
});
return p;
};
window.__tableTexts = (idx) => {
const out = [];
let seen = -1;
ed.state.doc.descendants((node) => {
if (node.type.name === 'table') { seen += 1; return seen === idx; }
if (seen === idx && (node.type.name === 'tableCell' || node.type.name === 'tableHeader')) out.push(node.textContent);
return true;
});
return out;
};
return { ok: true, platform: { isMobile: /is-mobile/.test(document.body.className) || Boolean(window.Capacitor), ua: navigator.userAgent.slice(0, 60) } };
}, notePath);

// rect paste: CellSelection over B00..B11 + synthetic paste with a copied 2x2
R.rectPaste = await page.evaluate(() => {
const ed = window.__ed;
ed.commands.setCellSelection({ anchorCell: window.__cellPos('B00'), headCell: window.__cellPos('B11') });
const html = '<table data-pm-slice="1 1 -2 []"><tbody><tr><td colspan="1" rowspan="1" data-colwidth="50"><p>A00</p></td><td colspan="1" rowspan="1" data-colwidth="50"><p>A01</p></td></tr><tr><td colspan="1" rowspan="1" data-colwidth="50"><p>A10</p></td><td colspan="1" rowspan="1" data-colwidth="50"><p>A11</p></td></tr></tbody></table>';
const dt = new DataTransfer();
dt.setData('text/html', html);
dt.setData('text/plain', 'A00\n\nA01\n\nA10\n\nA11');
ed.view.dom.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
return window.__tableTexts(1);
});

// undo
R.afterUndo = await page.evaluate(() => {
window.__ed.commands.undo();
return window.__tableTexts(1);
});

// rows insert at caret
R.rowsAtCaret = await page.evaluate(() => {
const ed = window.__ed;
const pos = window.__cellPos('B00');
ed.commands.focus(pos + 1);
const html = '<table data-pm-slice="1 1 -2 []"><tbody><tr><td colspan="1" rowspan="1" data-colwidth="50"><p>A00</p></td><td colspan="1" rowspan="1" data-colwidth="50"><p>A01</p></td></tr><tr><td colspan="1" rowspan="1" data-colwidth="50"><p>A10</p></td><td colspan="1" rowspan="1" data-colwidth="50"><p>A11</p></td></tr></tbody></table>';
const dt = new DataTransfer();
dt.setData('text/html', html);
ed.view.dom.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
return window.__tableTexts(1);
});

// cleanup: close the leaf, delete this run's note (file closed first)
R.cleanedUp = await page.evaluate(async (notePath) => {
const leaf = app.workspace.getLeavesOfType('note-view').find((l) => l.view?.file?.path === notePath);
if (leaf) {
try { leaf.detach(); } catch {}
await new Promise((r) => setTimeout(r, 300));
}
const file = app.vault.getAbstractFileByPath(notePath);
if (file) {
try { await app.vault.delete(file); } catch { return false; }
}
return true;
}, notePath);
await browser.close().catch(() => {});
console.log(JSON.stringify(R, null, 2));
224 changes: 224 additions & 0 deletions scripts/debug/verify-table-clipboard.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
// tmp-verify-desktop.mjs — desktop verification of the table clipboard task
// (issue table-clipboard) in the live obsidian-plugin dev vault, CDP :9223.
// Trusted keyboard/mouse via playwright Input; state reads via evaluate.
import { chromium } from 'playwright-core';

const browser = await chromium.connectOverCDP('http://127.0.0.1:9223', { timeout: 8000 });

const R = {};
let page = null;
try {
// 0. find the dev-vault window (rule-verify-both-platforms: only the
// obsidian-plugin vault may be touched)
const appPages = browser.contexts().flatMap((c) => c.pages()).filter((x) => x.url().startsWith('app://obsidian.md'));
for (const p of appPages) {
const vault = await p.evaluate(() => window.app?.vault?.getName?.() ?? null);
if (vault === 'obsidian-plugin') {
page = p;
break;
}
}
R.windows = await Promise.all(appPages.map((p) => p.evaluate(() => window.app?.vault?.getName?.() ?? null)));
if (page == null) throw new Error('obsidian-plugin vault window not found');

// The window must be OS-focused: trusted copy/cut keyboard commands are
// dropped by Chromium when the web contents have no focus.
R.windowFocus = await page.evaluate(() => {
const win = window.electron?.remote?.getCurrentWindow?.();
if (!win) return 'no electron.remote';
win.show();
win.focus();
return win.getTitle();
});
await page.waitForTimeout(400);

// 1. the app was reloaded (app:reload) AFTER the build, so the fresh
// bundle is already live; just confirm the plugin is enabled
R.pluginEnabled = await page.evaluate(() => !!app.plugins.getPlugin('inscriptum'));

// 2. write the test note (two 3-col tables: header + 2 body rows each).
// Unique name per run: a restored workspace tab may hold a stale doc
// for a reused path (the view does not necessarily reload from disk).
const notePath = `local/table-clipboard-verify-${Date.now()}.note`;
R.notePath = notePath;
await page.evaluate(async (notePath) => {
const mk = (name) => ({
type: 'table', attrs: { data: { rowsCount: 3, colsCount: 3 } },
content: [
{ type: 'tableRow', content: [0, 1, 2].map((i) => ({ type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: name + 'h' + i }] }] })) },
{ type: 'tableRow', content: [0, 1, 2].map((i) => ({ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: name + '0' + i }] }] })) },
{ type: 'tableRow', content: [0, 1, 2].map((i) => ({ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: name + '1' + i }] }] })) },
],
});
const doc = {
type: 'noteDoc',
content: [
{ type: 'noteTitle', content: [{ type: 'text', text: 'table-clipboard-verify' }] },
mk('A'),
{ type: 'paragraph', content: [{ type: 'text', text: 'between' }] },
mk('B'),
],
};
await app.vault.adapter.write(notePath, JSON.stringify(doc));
const file = app.vault.getAbstractFileByPath(notePath);
// close the tabs my earlier runs left behind, keep exactly one leaf
const existing = app.workspace.getLeavesOfType('note-view')
.filter((l) => (l.view?.file?.path ?? '').startsWith('local/table-clipboard-verify'));
const leaf = app.workspace.getLeaf('tab');
await leaf.openFile(file);
for (const l of existing) {
if (l !== leaf) {
try { l.detach(); } catch {}
}
}
app.workspace.revealLeaf(leaf);
window.__ed = leaf.view.editor ?? null;
}, notePath);
await page.waitForTimeout(1200);
R.editorUp = await page.evaluate(() => !!window.__ed);

// helpers evaluated in-page (re-resolve the editor: it is created
// asynchronously after the leaf mounts)
await page.evaluate((notePath) => {
const leaf = app.workspace.getLeavesOfType('note-view').find((l) => l.view?.file?.path === notePath);
window.__ed = leaf.view.editor;
window.__cellPos = (text) => {
let p = null;
window.__ed.state.doc.descendants((node, pos) => {
if (!p && (node.type.name === 'tableCell' || node.type.name === 'tableHeader') && node.textContent === text) p = pos;
return !p;
});
return p;
};
window.__tableTexts = (idx) => {
const out = [];
let seen = -1;
window.__ed.state.doc.descendants((node) => {
if (node.type.name === 'table') { seen += 1; return seen === idx; }
if (seen === idx && (node.type.name === 'tableCell' || node.type.name === 'tableHeader')) out.push(node.textContent);
return true;
});
return out;
};
window.__selRect = (a, b) => {
window.__ed.commands.setCellSelection({ anchorCell: window.__cellPos(a), headCell: window.__cellPos(b) });
window.__ed.view.focus();
};
window.__caret = (text) => {
const pos = window.__cellPos(text);
window.__ed.commands.focus(pos + 1);
window.__ed.view.focus();
};
}, notePath);

// focus the web contents with a trusted click into the editor, then keep
// PM focus via the API (native copy/cut need OS-focused web contents)
const edRect = await page.evaluate(() => {
const r = window.__ed.view.dom.getBoundingClientRect();
return { x: r.x + Math.min(80, r.width / 2), y: r.y + 8 };
});
if (edRect.width === 0) throw new Error('editor has no layout');
await page.mouse.click(edRect.x, edRect.y);
await page.waitForTimeout(200);
R.focusAfterClick = await page.evaluate(() => window.__ed.view.hasFocus());

// 3. rect copy from A00..A11 -> rect paste onto B00..B11
await page.evaluate(() => window.__selRect('A00', 'A11'));
await page.keyboard.press('Meta+c');
await page.waitForTimeout(150);
await page.evaluate(() => window.__selRect('B00', 'B11'));
await page.keyboard.press('Meta+v');
await page.waitForTimeout(250);
R.rectPaste = await page.evaluate(() => window.__tableTexts(1));

// 4. undo once — the paste must roll back in a single step
await page.keyboard.press('Meta+z');
await page.waitForTimeout(200);
R.afterUndo = await page.evaluate(() => window.__tableTexts(1));

// 5. rows copy (full rows A0+A1) -> caret in B00 -> paste inserts rows
await page.evaluate(() => window.__selRect('A00', 'A12'));
await page.keyboard.press('Meta+c');
await page.waitForTimeout(150);
await page.evaluate(() => window.__caret('B00'));
R.caretType = await page.evaluate(() => window.__ed.state.selection.constructor.name);
await page.keyboard.press('Meta+v');
await page.waitForTimeout(250);
R.rowsAtCaret = await page.evaluate(() => window.__tableTexts(1));
await page.keyboard.press('Meta+z');
await page.waitForTimeout(200);

// 6. Cmd+X on a full body row removes the row
await page.evaluate(() => window.__selRect('A00', 'A02'));
await page.keyboard.press('Meta+x');
await page.waitForTimeout(250);
R.cutRow = await page.evaluate(() => window.__tableTexts(0));
await page.keyboard.press('Meta+z');
await page.waitForTimeout(200);

// 7. Cmd+X on a partial rect clears contents
await page.evaluate(() => window.__selRect('A00', 'A11'));
await page.keyboard.press('Meta+x');
await page.waitForTimeout(250);
R.cutPartial = await page.evaluate(() => window.__tableTexts(0));
await page.keyboard.press('Meta+z');
await page.waitForTimeout(200);

// 8. focus after a real circle drag (trusted mouse): caret in A11 -> drag
// the selection circle to B11... wait, drag stays in one table: to B02.
await page.evaluate(() => window.__caret('A11'));
await page.waitForTimeout(300);
const circle = await page.evaluate(() => {
const el = window.__ed.view.dom.querySelector('.texto-table__overlay_overlay-circle');
if (!el) return null;
const r = el.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2, cellPos: el.dataset.cellPos };
});
R.circle = circle;
if (circle) {
const target = await page.evaluate(() => {
const td = [...window.__ed.view.dom.querySelectorAll('td,th')].find((el) => el.textContent === 'A02');
const r = td.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
});
await page.mouse.move(circle.x, circle.y);
await page.mouse.down();
await page.mouse.move(target.x, target.y, { steps: 8 });
await page.mouse.up();
await page.waitForTimeout(300);
R.afterDrag = await page.evaluate(() => {
const s = window.__ed.state.selection;
return {
type: s.constructor.name,
anchorCell: s.$anchorCell ? s.$anchorCell.pos : null,
headCell: s.$headCell ? s.$headCell.pos : null,
hasFocus: window.__ed.view.hasFocus(),
};
});
// 9. trusted Cmd+C right after the drag — the focus fix makes it work
await page.keyboard.press('Meta+c');
await page.waitForTimeout(150);
await page.evaluate(() => window.__selRect('B00', 'B12'));
await page.keyboard.press('Meta+v');
await page.waitForTimeout(250);
R.pasteAfterDragCopy = await page.evaluate(() => window.__tableTexts(1));
}
// cleanup: close the leaf (file not open anymore), then delete this run's
// note and any leftovers of earlier runs (explicit user-style deletes —
// never while the file is open in a view, see rule-open-file-safety)
await page.evaluate(async (notePath) => {
const files = app.vault.getFiles().filter((f) => f.path.startsWith('local/table-clipboard-verify'));
for (const f of files) {
const leaves = app.workspace.getLeavesOfType('note-view').filter((l) => l.view?.file?.path === f.path);
for (const l of leaves) {
try { l.detach(); } catch {}
}
await new Promise((r) => setTimeout(r, 150));
try { await app.vault.delete(f); } catch {}
}
}, notePath);
R.cleanedUp = true;
} finally {
await browser.close().catch(() => {});
}
console.log(JSON.stringify(R, null, 2));
Loading
Loading