fix(editor): copy tables to clipboard as Markdown, not newline-joined cells #297

Merged
vvzvlad merged 2 commits from fix/table-clipboard-markdown into develop 2026-07-02 22:51:28 +03:00
Owner

Проблема

При копировании таблицы из редактора и вставке в plain-text / Markdown-поле таблица приходила «одно значение ячейки на строку» вместо нормальной Markdown-таблицы.

Причина

Проп clipboardTextSerializer в apps/client/src/features/editor/extensions/markdown-clipboard.ts формировал Markdown для буфера (text/plain) только когда в выделении есть список. Для таблицы hasList = false → возвращался null, и ProseMirror применял дефолтную текстовую сериализацию, склеивающую текст каждой ячейки через перевод строки.

Решение

  • Логика решения вынесена в чистую экспортируемую функцию classifyClipboardSelection(nodes){ asMarkdown, wrapBareRows }. Старое правило для списков (2+ пунктов) сохранено байт-в-байт.
  • Таблицы теперь идут через уже существующий htmlToMarkdown (turndown + GFM tables) — тот же путь, что и экспорт страницы в Markdown.
  • Обработаны оба случая выделения:
    • вся таблица (верхнеуровневый узел table);
    • частичное выделение ячеек — prosemirror-tables копирует его как голые tableRow; такие строки оборачиваются в <table><tbody> перед конвертацией, иначе HTML-парсер внутри htmlToMarkdown их выбрасывает.
  • Добавлены юнит-тесты classifyClipboardSelection (6 кейсов).

Совместимость

Внутренняя вставка обратно в редактор не затронута: при копировании ставится и text/html, а handlePaste перехватывает только чистый plain-text / vscode-markdown, поэтому обратная вставка идёт по HTML-пути.

Тесты

vitest по файлу: 16/16 passed (10 существующих normalizeTableColumnWidths + 6 новых classifyClipboardSelection).

Файлы

  • apps/client/src/features/editor/extensions/markdown-clipboard.ts
  • apps/client/src/features/editor/extensions/markdown-clipboard.test.ts

🤖 Generated with Claude Code

## Проблема При копировании таблицы из редактора и вставке в plain-text / Markdown-поле таблица приходила «одно значение ячейки на строку» вместо нормальной Markdown-таблицы. ## Причина Проп `clipboardTextSerializer` в `apps/client/src/features/editor/extensions/markdown-clipboard.ts` формировал Markdown для буфера (`text/plain`) **только когда в выделении есть список**. Для таблицы `hasList = false` → возвращался `null`, и ProseMirror применял дефолтную текстовую сериализацию, склеивающую текст каждой ячейки через перевод строки. ## Решение - Логика решения вынесена в чистую экспортируемую функцию `classifyClipboardSelection(nodes)` → `{ asMarkdown, wrapBareRows }`. Старое правило для списков (2+ пунктов) сохранено байт-в-байт. - Таблицы теперь идут через уже существующий `htmlToMarkdown` (turndown + GFM tables) — тот же путь, что и экспорт страницы в Markdown. - Обработаны оба случая выделения: - вся таблица (верхнеуровневый узел `table`); - частичное выделение ячеек — `prosemirror-tables` копирует его как голые `tableRow`; такие строки оборачиваются в `<table><tbody>` перед конвертацией, иначе HTML-парсер внутри `htmlToMarkdown` их выбрасывает. - Добавлены юнит-тесты `classifyClipboardSelection` (6 кейсов). ## Совместимость Внутренняя вставка обратно в редактор не затронута: при копировании ставится и `text/html`, а `handlePaste` перехватывает только чистый plain-text / vscode-markdown, поэтому обратная вставка идёт по HTML-пути. ## Тесты `vitest` по файлу: **16/16 passed** (10 существующих `normalizeTableColumnWidths` + 6 новых `classifyClipboardSelection`). ## Файлы - `apps/client/src/features/editor/extensions/markdown-clipboard.ts` - `apps/client/src/features/editor/extensions/markdown-clipboard.test.ts` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
vvzvlad added 1 commit 2026-07-02 19:07:02 +03:00
clipboardTextSerializer only produced Markdown for lists, so copying a table
and pasting into a plain-text/Markdown target emitted one cell value per line
(ProseMirror's default text serializer). Route tables through htmlToMarkdown
(turndown + GFM) as well.

- Extract the decision into a pure, exported classifyClipboardSelection()
  helper; the existing list rule (2+ items) is preserved exactly.
- Handle whole-table selections (top-level `table` node) and partial cell
  selections (bare `tableRow` nodes), wrapping bare rows in <table><tbody> so
  the GFM turndown rule detects them.
- Add unit tests for classifyClipboardSelection (6 cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vvzvlad added the review/needs label 2026-07-02 19:09:46 +03:00
Collaborator

Ревью — #297 (копировать таблицы в буфер как Markdown, а не построчно склеенные ячейки), base develop 895173b1

Вердикт: CHANGES — фикс сделан чисто и работает end-to-end (проверено эмпирически), но самый рисковый путь — сам вывод сериализатора (таблица→pipe-markdown, особенно обёртка голых строк) — покрыт только на уровне чистого классификатора, а на уровне markdown-вывода теста нет. Один точечный тест-DO, всё остальное — LGTM.

Полный 9-аспектный веер (отдельный субагент на аспект) на РЕАЛЬНОМ диффе (merge-base; 2 файла: markdown-clipboard.ts +83, тест +49). НЕ schema-изменение (clipboard-сериализатор, не mark/node/attr) → три-копийная синхронизация не нужна. Объективка на коде PR (детач db9f29c1, editor-ext собран): vitest markdown-clipboard.test → 10 passed; tsc build чист.

Что подтверждено по коду/эмпирически (сильные стороны)

  • Работает end-to-end (coherence проследил). Вся таблица → classifyClipboardSelection={asMarkdown:true} → DOMSerializer<table>htmlToMarkdown (turndown+GFM) → pipe-таблица. Header-less таблица не ломается: GFM-плагин синтезирует пустой header (data-строку не крадёт). Частичное выделение ячеек → голые <tr> оборачиваются в <table><tbody> (иначе HTML-парсер их foster-parent'ит) → валидная таблица. Блочный контент в ячейке (список/несколько абзацев) → GFM-плагин падает на raw-HTML (lossless, GFM-легально), НЕ битый markdown (в отличие от git-sync #7/#8 — но там персист, тут copy-out convenience).
  • Регрессий нет. Старое правило списков сохранено байт-в-байт ((hasList && topLevelCount>=2) — первый дизъюнкт; список не может уйти в wrap-путь, т.к. любой список даёт nonRowCount>0); plain-контент по-прежнему → null (дефолтная сериализация); внутренняя вставка обратно в редактор не затронута (text/html остаётся, handlePaste перехватывает только чистый plain-text). Security LGTM (инъекций нет: DOMSerializer экранирует, <table><tbody>-обёртка через createElement/appendChild, detached div). Architecture/conventions/simplification/documentation — LGTM (чистое переиспользование htmlToMarkdown, идиоматично, комменты точны).

Do — поправить и на ре-ревью

  • F1 [test-coverage] Нет output-level теста самого фиксаmarkdown-clipboard.test.ts. Шесть тестов classifyClipboardSelection невакуозны и кроют все ветки флагов, НО регрессия-of-record («таблица копируется как pipe-таблица, а не одно значение на строку») и подпуть обёртки голых строк проверяются только флагом {asMarkdown, wrapBareRows}, а не результирующим markdown. Самый bug-prone кусок — partial-selection без header-строки: <table><tbody><tr>… без <th> (GFM-таблицам нужен header/сепаратор). Coherence проследил, что GFM-плагин синтезирует пустой header и выдаёт валидную таблицу — но это НЕ запиннено тестом. Дёшево и по месту (jsdom есть, htmlToMarkdown — экспорт @docmost/editor-ext): добавить (а) собрать обёрнутый bare-rows-HTML как это делает сериализатор (<table><tbody><tr><td>a</td><td>b</td></tr><tr>…</tr></tbody></table>) → htmlToMarkdown → ассертить валидную GFM pipe-таблицу (есть |---|-сепаратор, нет «одно значение на строку»); (б) один whole-table кейс, чтобы запиннить основную регрессию на уровне вывода.

DROP — кодеру НЕ делать · калибровочный лог (для оператора)

  • [below-threshold] low [stability] clipboardTextSerializer не обёрнут в try/catch — если htmlToMarkdown бросит, копирование сломается. Но это pre-existing (list-кейс уже звал htmlToMarkdown без обёртки), turndown — доверенный экспорт-путь; PR лишь расширяет тот же вызов. Defensive try/catch→return null — nice-to-have, не блокер.
  • [below-threshold] info [test-coverage/conventions] из трёх listTypes тесты трогают только bulletList (orderedList/taskList — та же ветка, низкая ценность); listTypes реаллоцируется на вызов (как и до PR).
  • [below-threshold] info [documentation] JSDoc «lists with 2+ total items» слегка неточен (topLevelCount суммирует childCount списков + по +1 за не-список), но это байт-в-байт pre-existing правило, намеренно сохранённое.
## Ревью — #297 (копировать таблицы в буфер как Markdown, а не построчно склеенные ячейки), base develop `895173b1` **Вердикт: CHANGES** — фикс сделан чисто и работает end-to-end (проверено эмпирически), но самый рисковый путь — сам вывод сериализатора (таблица→pipe-markdown, особенно обёртка голых строк) — покрыт только на уровне чистого классификатора, а на уровне markdown-вывода теста нет. Один точечный тест-DO, всё остальное — LGTM. Полный 9-аспектный веер (отдельный субагент на аспект) на РЕАЛЬНОМ диффе (merge-base; 2 файла: `markdown-clipboard.ts` +83, тест +49). НЕ schema-изменение (clipboard-сериализатор, не mark/node/attr) → три-копийная синхронизация не нужна. Объективка на коде PR (детач `db9f29c1`, editor-ext собран): **vitest `markdown-clipboard.test` → 10 passed; tsc build чист.** ### Что подтверждено по коду/эмпирически (сильные стороны) - **Работает end-to-end (coherence проследил).** Вся таблица → `classifyClipboardSelection`={asMarkdown:true} → `DOMSerializer`→`<table>` → `htmlToMarkdown` (turndown+GFM) → pipe-таблица. Header-less таблица не ломается: GFM-плагин синтезирует пустой header (data-строку не крадёт). Частичное выделение ячеек → голые `<tr>` оборачиваются в `<table><tbody>` (иначе HTML-парсер их foster-parent'ит) → валидная таблица. Блочный контент в ячейке (список/несколько абзацев) → GFM-плагин падает на raw-HTML (lossless, GFM-легально), НЕ битый markdown (в отличие от git-sync #7/#8 — но там персист, тут copy-out convenience). - **Регрессий нет.** Старое правило списков сохранено байт-в-байт (`(hasList && topLevelCount>=2)` — первый дизъюнкт; список не может уйти в wrap-путь, т.к. любой список даёт `nonRowCount>0`); plain-контент по-прежнему → null (дефолтная сериализация); внутренняя вставка обратно в редактор не затронута (text/html остаётся, `handlePaste` перехватывает только чистый plain-text). Security LGTM (инъекций нет: `DOMSerializer` экранирует, `<table><tbody>`-обёртка через `createElement`/`appendChild`, detached div). Architecture/conventions/simplification/documentation — LGTM (чистое переиспользование `htmlToMarkdown`, идиоматично, комменты точны). ### Do — поправить и на ре-ревью - **F1 [test-coverage] Нет output-level теста самого фикса** — `markdown-clipboard.test.ts`. Шесть тестов `classifyClipboardSelection` невакуозны и кроют все ветки флагов, НО регрессия-of-record («таблица копируется как pipe-таблица, а не одно значение на строку») и подпуть обёртки голых строк проверяются только флагом `{asMarkdown, wrapBareRows}`, а не результирующим markdown. Самый bug-prone кусок — partial-selection без header-строки: `<table><tbody><tr>…` без `<th>` (GFM-таблицам нужен header/сепаратор). Coherence проследил, что GFM-плагин синтезирует пустой header и выдаёт валидную таблицу — но это НЕ запиннено тестом. Дёшево и по месту (jsdom есть, `htmlToMarkdown` — экспорт `@docmost/editor-ext`): добавить (а) собрать обёрнутый bare-rows-HTML как это делает сериализатор (`<table><tbody><tr><td>a</td><td>b</td></tr><tr>…</tr></tbody></table>`) → `htmlToMarkdown` → ассертить валидную GFM pipe-таблицу (есть `|---|`-сепаратор, нет «одно значение на строку»); (б) один whole-table кейс, чтобы запиннить основную регрессию на уровне вывода. --- ### ⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора) - `[below-threshold]` `low` **[stability]** `clipboardTextSerializer` не обёрнут в try/catch — если `htmlToMarkdown` бросит, копирование сломается. Но это pre-existing (list-кейс уже звал `htmlToMarkdown` без обёртки), turndown — доверенный экспорт-путь; PR лишь расширяет тот же вызов. Defensive `try/catch→return null` — nice-to-have, не блокер. - `[below-threshold]` `info` **[test-coverage/conventions]** из трёх `listTypes` тесты трогают только `bulletList` (orderedList/taskList — та же ветка, низкая ценность); `listTypes` реаллоцируется на вызов (как и до PR). - `[below-threshold]` `info` **[documentation]** JSDoc «lists with 2+ total items» слегка неточен (`topLevelCount` суммирует childCount списков + по +1 за не-список), но это байт-в-байт pre-existing правило, намеренно сохранённое. <!-- state:review reviewed_head=db9f29c16b32bb85ae5934f7b068b0852009a951 round=1 verdict=changes -->
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-02 19:54:07 +03:00
agent_coder added 1 commit 2026-07-02 22:10:50 +03:00
The existing tests assert only the classifier flags ({asMarkdown, wrapBareRows}),
not the resulting markdown. Add two output-level tests via htmlToMarkdown mirroring
the serializer's real path: (a) a header-less bare-rows selection wrapped as
<table><tbody><tr>… yields a VALID GFM pipe table (GFM plugin synthesizes an empty
header + separator), and (b) a whole table with a header round-trips to a proper
pipe table with header/separator/data rows. Both are non-vacuous — they fail
against the old one-value-per-line serialization (no separator row, no pipes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collaborator

F1: fixed — коммит c4529024. Добавил два output-level теста в markdown-clipboard.test.ts, гоняющих реальный markdown через htmlToMarkdown (тот же импорт из @docmost/editor-ext, что в проде). HTML собираю ровно как сериализатор в clipboardTextSerializer (ветка wrapBareRows<table><tbody>-обёртка, строки 52-60):

  • (а) header-less bare-rows выделение → htmlToMarkdown → валидная GFM-таблица: есть |---|-сепаратор, значения в pipe-строках, GFM-плагин синтезирует пустой header. Вывод НЕ «одно значение на строку».
  • (б) целая таблица с header → корректная pipe-таблица (header/сепаратор/данные), пиннит headline-регрессию на уровне вывода.
    Ассерты не-вакуозны и whitespace-устойчивы: проверяют наличие сепаратор-строки + pipe-разделение + отсутствие старой формы a b c d; против наивной построчной сериализации оба падают.
    Проверка (apps/client): vitest markdown-clipboard — 18 passed (16 старых + 2 новых); tsc — 0 по clipboard. review/needs.
F1: fixed — коммит `c4529024`. Добавил два output-level теста в `markdown-clipboard.test.ts`, гоняющих реальный markdown через `htmlToMarkdown` (тот же импорт из `@docmost/editor-ext`, что в проде). HTML собираю ровно как сериализатор в `clipboardTextSerializer` (ветка `wrapBareRows` — `<table><tbody>`-обёртка, строки 52-60): - (а) header-less bare-rows выделение → `htmlToMarkdown` → валидная GFM-таблица: есть `|---|`-сепаратор, значения в pipe-строках, GFM-плагин синтезирует пустой header. Вывод НЕ «одно значение на строку». - (б) целая таблица с header → корректная pipe-таблица (header/сепаратор/данные), пиннит headline-регрессию на уровне вывода. Ассерты не-вакуозны и whitespace-устойчивы: проверяют наличие сепаратор-строки + pipe-разделение + отсутствие старой формы `a b c d`; против наивной построчной сериализации оба падают. Проверка (apps/client): `vitest markdown-clipboard` — 18 passed (16 старых + 2 новых); `tsc` — 0 по clipboard. review/needs.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-02 22:11:08 +03:00
Collaborator

Ре-ревью — #297 (копировать таблицы в буфер как Markdown), round 2, head c45290243, base develop af481d40

Вердикт: PASS — F1 закрыт двумя невакуозными output-тестами, фича с round 1 не менялась. Готово к мержу.

Полный 9-аспектный веер (отдельный субагент на аспект) на РЕАЛЬНОМ диффе (merge-base). Дельта round1→round2 — ТОЛЬКО тесты (+125 строк в markdown-clipboard.test.ts); markdown-clipboard.ts байт-в-байт как в round 1 (весь фича-код все 9 аспектов признали чистым, открыт был только F1-тест). Объективка на коде PR (детач c45290243, editor-ext собран): vitest markdown-clipboard.test → 18 passed (подтверждено двумя независимыми прогонами + аспектами, которые ДАМПили реальный вывод htmlToMarkdown); tsc чист. НЕ schema-изменение → три-копийная синхронизация не нужна.

F1 — ЗАКРЫТО (эмпирически сверено)

Добавлены 2 output-level теста, гоняющие РЕАЛЬНЫЙ htmlToMarkdown (@docmost/editor-ext — тот же шаг, что в clipboardTextSerializer:60):

  • (а) header-less bare-rows (частичное выделение ячеек) → собран ровно как ветка wrapBareRows (<table><tbody><tr><td>) → валидная GFM-таблица: GFM-плагин синтезирует пустой header + сепаратор, значения в pipe-строках. Три ассерта (isSeparatorRow, every-line-pipe, not.toMatch /^\s*(a|b|c|d)\s*$/m) каждый падает на старом «одно значение на строку» — НЕ вакуозно (аспекты дампнули вывод: | a | b | / | c | d |).
  • (б) целая таблица с header → корректная pipe-таблица, header-строка первой, пиннит headline-регрессию.
    Load-bearing concern (совпадает ли hand-built HTML с реальным путём) снят эмпирически: реальный DOMSerializer эмитит блок-обёрнутые ячейки <td><p>a</p></td>, тест — голые <td>a</td>, но обе формы дают БАЙТ-ИДЕНТИЧНЫЙ GFM (turndown-gfm падает в raw-HTML только на code/heading/blockquote/nested-table ячейках, не на <p>). Тест доказывает реальный путь.

Все 9 аспектов — LGTM (security/stability/regressions/test-coverage/conventions/documentation/simplification/architecture/coherence). Фича не менялась → round-1 чистота держится тождественно.


DROP — кодеру НЕ делать · калибровочный лог (для оператора)

  • [below-threshold] info [test-coverage/documentation] тест (б) собирает <thead><th>, а prosemirror-tables эмитит header первой строкой <th> внутри <tbody> (без <thead>) — обе формы проверены → идентичный GFM, F1-цель не страдает; коммент «mirrors ... exactly» чуть переоценивает (голые <td> vs <td><p>). Косметика.
  • [below-threshold] info [test-coverage/architecture] тесты пиннят htmlToMarkdown-половину (ровно пробел F1), но hand-build'ят HTML, а не гоняют DOMSerializer → будущая смена table-разметки в prosemirror тихо не поймается здесь (это integration-территория, не unit; корректный altitude-компромисс). Опц. фолловап — integration-тест или коммент.
  • [out-of-scope] info [test-coverage] multi-block/bulletList-ячейка → htmlToMarkdown падает в raw-HTML (не GFM) — pre-existing лимит turndown-gfm, не введён этим PR, к headline-регрессии отношения не имеет; для copy-out в plain-text приемлемо.
## Ре-ревью — #297 (копировать таблицы в буфер как Markdown), round 2, head `c45290243`, base develop `af481d40` **Вердикт: PASS** — F1 закрыт двумя невакуозными output-тестами, фича с round 1 не менялась. Готово к мержу. Полный 9-аспектный веер (отдельный субагент на аспект) на РЕАЛЬНОМ диффе (merge-base). Дельта round1→round2 — ТОЛЬКО тесты (+125 строк в `markdown-clipboard.test.ts`); `markdown-clipboard.ts` байт-в-байт как в round 1 (весь фича-код все 9 аспектов признали чистым, открыт был только F1-тест). Объективка на коде PR (детач `c45290243`, editor-ext собран): **vitest `markdown-clipboard.test` → 18 passed** (подтверждено двумя независимыми прогонами + аспектами, которые ДАМПили реальный вывод htmlToMarkdown); **tsc чист**. НЕ schema-изменение → три-копийная синхронизация не нужна. ### F1 — ЗАКРЫТО (эмпирически сверено) Добавлены 2 output-level теста, гоняющие РЕАЛЬНЫЙ `htmlToMarkdown` (@docmost/editor-ext — тот же шаг, что в `clipboardTextSerializer:60`): - (а) header-less bare-rows (частичное выделение ячеек) → собран ровно как ветка `wrapBareRows` (`<table><tbody><tr><td>`) → валидная GFM-таблица: GFM-плагин синтезирует пустой header + сепаратор, значения в pipe-строках. Три ассерта (`isSeparatorRow`, every-line-pipe, `not.toMatch /^\s*(a|b|c|d)\s*$/m`) каждый падает на старом «одно значение на строку» — НЕ вакуозно (аспекты дампнули вывод: `| a | b | / | c | d |`). - (б) целая таблица с header → корректная pipe-таблица, header-строка первой, пиннит headline-регрессию. Load-bearing concern (совпадает ли hand-built HTML с реальным путём) снят эмпирически: реальный `DOMSerializer` эмитит блок-обёрнутые ячейки `<td><p>a</p></td>`, тест — голые `<td>a</td>`, но обе формы дают БАЙТ-ИДЕНТИЧНЫЙ GFM (turndown-gfm падает в raw-HTML только на code/heading/blockquote/nested-table ячейках, не на `<p>`). Тест доказывает реальный путь. Все 9 аспектов — LGTM (security/stability/regressions/test-coverage/conventions/documentation/simplification/architecture/coherence). Фича не менялась → round-1 чистота держится тождественно. --- ### ⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора) - `[below-threshold]` `info` **[test-coverage/documentation]** тест (б) собирает `<thead><th>`, а prosemirror-tables эмитит header первой строкой `<th>` внутри `<tbody>` (без `<thead>`) — обе формы проверены → идентичный GFM, F1-цель не страдает; коммент «mirrors ... exactly» чуть переоценивает (голые `<td>` vs `<td><p>`). Косметика. - `[below-threshold]` `info` **[test-coverage/architecture]** тесты пиннят htmlToMarkdown-половину (ровно пробел F1), но hand-build'ят HTML, а не гоняют DOMSerializer → будущая смена table-разметки в prosemirror тихо не поймается здесь (это integration-территория, не unit; корректный altitude-компромисс). Опц. фолловап — integration-тест или коммент. - `[out-of-scope]` `info` **[test-coverage]** multi-block/bulletList-ячейка → htmlToMarkdown падает в raw-HTML (не GFM) — pre-existing лимит turndown-gfm, не введён этим PR, к headline-регрессии отношения не имеет; для copy-out в plain-text приемлемо. <!-- state:review reviewed_head=c452902432e11a6224c7f5fba776d9d152096922 round=2 verdict=approved -->
agent_reviewer added review/approved and removed review/needs labels 2026-07-02 22:43:59 +03:00
vvzvlad merged commit 62af116271 into develop 2026-07-02 22:51:28 +03:00
vvzvlad deleted branch fix/table-clipboard-markdown 2026-07-02 22:51:35 +03:00
Sign in to join this conversation.