diff --git a/CHANGELOG.md b/CHANGELOG.md index 96d028a..254a747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/scripts/debug/verify-table-clipboard-android.mjs b/scripts/debug/verify-table-clipboard-android.mjs new file mode 100644 index 0000000..e09487f --- /dev/null +++ b/scripts/debug/verify-table-clipboard-android.mjs @@ -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 = '

A00

A01

A10

A11

'; + 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 = '

A00

A01

A10

A11

'; + 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)); diff --git a/scripts/debug/verify-table-clipboard.mjs b/scripts/debug/verify-table-clipboard.mjs new file mode 100644 index 0000000..f13e480 --- /dev/null +++ b/scripts/debug/verify-table-clipboard.mjs @@ -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)); diff --git a/src/components/bubble-menu-bar/table-bubble-menu-bar.element.tsx b/src/components/bubble-menu-bar/table-bubble-menu-bar.element.tsx index fff0e31..77d2592 100644 --- a/src/components/bubble-menu-bar/table-bubble-menu-bar.element.tsx +++ b/src/components/bubble-menu-bar/table-bubble-menu-bar.element.tsx @@ -20,6 +20,11 @@ import type { BorderPen, BorderSideName, } from "../../texto/extensions/table/helpers/borders"; +import { + copyTableSelection, + cutTableSelection, + pasteFromSystemClipboard, +} from "../../texto/extensions/table/helpers/cellClipboard"; import type { BubbleIconName } from "../icons/iconSprite"; import { bubbleIconNodes } from "./icons.svgnode"; @@ -236,6 +241,12 @@ export const TableBubbleMenuElement = litView.element({ case "colRight": e.chain().focus().addColumnAfter().run(); break; + case "dupRow": + e.chain().focus().duplicateRow().run(); + break; + case "dupCol": + e.chain().focus().duplicateColumn().run(); + break; case "merge": e.chain().focus().mergeCells().run(); break; @@ -268,6 +279,22 @@ export const TableBubbleMenuElement = litView.element({ .run(); }; + /* ── Clipboard actions (issue table-clipboard-buttons): a cell selection + or the caret cell (1×1). Paste reads the system clipboard inside the + click gesture and feeds it through the editor's paste pipeline. ── */ + const copyCells = () => { + copyTableSelection(props.editor.view); + }; + const cutCells = () => { + void cutTableSelection(props.editor.view).catch(() => {}); + }; + const pasteCells = () => { + void pasteFromSystemClipboard(props.editor.view).catch(() => {}); + }; + const canReadClipboard = + typeof navigator !== "undefined" && + !!(navigator.clipboard?.read || navigator.clipboard?.readText); + const syncCaret = () => { const editor = props.editor; if (editor.isDestroyed || !editor.view) return; @@ -425,6 +452,49 @@ export const TableBubbleMenuElement = litView.element({ > {bubbleIconNodes.colRight()} + + + + + + {canReadClipboard && ( + + )}