fix(editor): copy tables to clipboard as Markdown, not newline-joined cells #297
Reference in New Issue
Block a user
Delete Branch "fix/table-clipboard-markdown"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Проблема
При копировании таблицы из редактора и вставке в 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.tsapps/client/src/features/editor/extensions/markdown-clipboard.test.ts🤖 Generated with Claude Code
Ревью — #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 собран): vitestmarkdown-clipboard.test→ 10 passed; tsc build чист.Что подтверждено по коду/эмпирически (сильные стороны)
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 — поправить и на ре-ревью
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 лишь расширяет тот же вызов. Defensivetry/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 правило, намеренно сохранённое.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>F1: fixed — коммит
c4529024. Добавил два output-level теста вmarkdown-clipboard.test.ts, гоняющих реальный markdown черезhtmlToMarkdown(тот же импорт из@docmost/editor-ext, что в проде). HTML собираю ровно как сериализатор вclipboardTextSerializer(веткаwrapBareRows—<table><tbody>-обёртка, строки 52-60):htmlToMarkdown→ валидная GFM-таблица: есть|---|-сепаратор, значения в pipe-строках, GFM-плагин синтезирует пустой header. Вывод НЕ «одно значение на строку».Ассерты не-вакуозны и whitespace-устойчивы: проверяют наличие сепаратор-строки + pipe-разделение + отсутствие старой формы
a b c d; против наивной построчной сериализации оба падают.Проверка (apps/client):
vitest markdown-clipboard— 18 passed (16 старых + 2 новых);tsc— 0 по clipboard. review/needs.Ре-ревью — #297 (копировать таблицы в буфер как Markdown), round 2, head
c45290243, base developaf481d40Вердикт: 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 собран): vitestmarkdown-clipboard.test→ 18 passed (подтверждено двумя независимыми прогонами + аспектами, которые ДАМПили реальный вывод htmlToMarkdown); tsc чист. НЕ schema-изменение → три-копийная синхронизация не нужна.F1 — ЗАКРЫТО (эмпирически сверено)
Добавлены 2 output-level теста, гоняющие РЕАЛЬНЫЙ
htmlToMarkdown(@docmost/editor-ext — тот же шаг, что вclipboardTextSerializer:60):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 |).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 приемлемо.