fix(editor): не подставлять типографику повторно после её отмены (Ctrl+Z) #296
Reference in New Issue
Block a user
Delete Branch "fix/typography-undo-resubstitution"
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?
Проблема
После авто-подстановки Typography (например
1/2→½) и её отмены через Ctrl+Z повторное нажатие пробела снова триггерило то же input-rule и подставляло символ заново. Отменённая подстановка должна оставаться отменённой.Причина
Ни Tiptap, ни ProseMirror не хранят факт отмены подстановки: после Ctrl+Z в документе снова
1/2, курсор сразу за текстом, и следующий пробел заново матчит правило@tiptap/extension-typography.Решение
Новое клиентское расширение
CustomTypography(обёртка над@tiptap/extension-typography) с ProseMirror-плагином «undo guard»:findDiffStart/findDiffEnd), а не по step-map;ySyncPluginKeyи каноническийisChangeOrigin, без хрупких строковых ключей.В
extensions.tsTypographyзаменён наCustomTypography(шаблонные расширения подхватывают автоматически).Вне области
Серверный
collaboration.util.tsне трогался — там Typography используется только для схемы, input rules на сервере не выполняются.Проверка
pnpm --filter client exec tsc --noEmit— 0 ошибок.🤖 Generated with Claude Code
Ревью — #296 (не подставлять типографику повторно после Ctrl+Z), base develop (merge-base
3a5794894)Вердикт: CHANGES — фикс сделан правильно и РЕАЛЬНО работает end-to-end (проверено по исходникам зависимостей в обеих системах истории + collab-safe). Но 186 строк тонкой position-mapping/undo-detection-логики едут без единого теста, и в одной ветке нормализации есть узкий баг. Оба — на ре-ревью.
Полный 9-аспектный веер (отдельный субагент на аспект) на ПОЛНОМ диффе (merge-base; 2 файла: новый
custom-typography.ts+186,extensions.tsswap). НЕ schema-изменение (нет mark/node/attr) → три-копийная синхронизация editor-ext/mcp/git-sync НЕ нужна. Объективные проверки на коде PR: tsc чист для обоих файлов; теста дляcustom-typography.tsнет (это и есть F1). Само-ревью agent_coder игнорировал.Что подтверждено по коду (сильные стороны)
HISTORY_META="history$"действительно стабилен (prosemirror-history 1.4.1 ставит meta черезnew PluginKey("history")→.key==="history$", я проверил вживую). yjs-путь (основной collab):ySyncPluginKey=PluginKey('y-sync')— тот самый импортируемый объект, yjs применяет изменение как whole-doc replace (поэтому diff вместо step-map обязателен),isUndoRedoOperationстампится наorigin instanceof Y.UndoManager.isUndoRedoTransaction=false); существующий гард при этом корректно МАПАется через diff пира (strict-intersection роняет только при правке самого текста гарда). Security подтвердил: гард только подавляет локальные input-rules, документ не мутирует — collab-инъекции/DoS нет.new InputRule({find,undoable,handler})не теряет полей — у@tiptap/coreInputRule ровно 3 поля, иundoableуже резолвится конструктором вtrue(не undefined) → поведение идентично;.extend()сохраняет имя"typography", PM-плагинов Typography не добавляет (ничего не потеряно). Stability: гард используется только в арифметических сравнениях, нигде не резолвится против дока → неверная карта = максимум косметика, не краш. Simplification/architecture: сложность 1:1 с требованиями (dual diff/step-map оправдан whole-doc-replace), дом расширения корректный (идиомаCode.extend/intentional-clear).Do — поправить и на ре-ревью
custom-typography.tsбез единого теста, при этом failure-modes реальны: типографика может НАВСЕГДА отключиться в регионе (если release не сработает) или collab-гард исказится. В репо УЖЕ есть точная гарнесса —apps/client/src/features/editor/extensions/intentional-clear.test.ts(строитnew Editor({extensions:[…]})и диспатчит транзакцию сsetMeta(ySyncPluginKey, …)— ровно тот механизм, на котором держится гард). Добавитьcustom-typography.test.ts(потребует test-only экспортаundoGuardKey+ двух хелперов):mapRangeThroughChange— strict-intersection RELEASE, boundary-touch «НЕ роняет», shift-before, no-op-after;findChangedRange— ветка нормализации перекрытых границ (сюда же F2);undoGuardKey.getState===null; правка СНАРУЖИ → гард жив и смещён;setMeta(ySyncPluginKey,{isChangeOrigin:true})без undo-meta) → НЕ армит; локальный undo-replace → армит{from,to:newTo}.Полный input-rule e2e («печать пробела не ре-подставляет») — не требуется (дорого в jsdom, нет прецедента; остаточный риск низкий после (b)/(c)/(d), т.к. сам wrapper-handler — 3-строчная overlap-проверка).
findChangedRange— асимметричная нормализация —custom-typography.ts:59-65. Обрабатывается толькоoldTo < start(бампитсяnewTo); симметричный случайnewTo < start(чистое удаление ПОВТОРЯЮЩЕГОСЯ контента) не обработан → возможен вырожденныйDocChangeсnewTo < from. Два аспекта нашли независимо. Impact ограничен (заармить неверно НЕ может — арминг требуетoldTo>from; максимум — mis-shift/ложный сброс существующего гарда при экзотическом удалении повторяющегося текста рядом с гардом; данные не трогает), но это реальный пробел в новой position-mapping-логике и правится по образцу канонического PM-диффа (бампить ОБА конца наstart - min(a,b)). Закрыть + запиннить юнит-тестом из F1(d).⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора)
[below-threshold]info[conventions/documentation]HISTORY_META="history$"— единственный стрингифай-ключ в файле, который иначе импортирует ключи. Проверил: prosemirror-history НЕ экспортирует свой plugin-key (импортировать нечего), а сам"history$"фактически верен (сверил live). Механизм корректен + оговорён комментом → не DO. Коммент «is stable» слегка переоценивает (pnpm version-skew мог бы датьhistory$1); формулировка, не баг.[below-threshold]info[documentation] тело PR «избегает fragile stringified keys» верно лишь для yjs-половины (prosemirror-history-путь всё равно на"history$") — уточнить формулировку в описании, кода не касается.[below-threshold]low[stability] full-doc diff на КАЖДОЙ remote-транзакции (черезisChangeOrigin), не только на yjs-undo — но это необходимо (y-prosemirror применяет remote-правки whole-doc-replace'ом, step-map бесполезен), bounded, без потери корректности; тот же паттерн, что в PM domchange.[below-threshold]info[coherence] redo армит гард на ре-подставленном глифе («½») — безвредно (typography-правила не матчат вывод подстановки).[below-threshold]info[conventions] нет#296-якоря в комментах (локальная конвенция линковать editor-расширения на issue, ср.#251в intentional-clear).Findings закрыты, коммит
db9ed51e.F2: fixed —
findChangedRangeнормализовал только вставку повторяющегося контента (oldTo<start), а симметричное удаление (newTo<start) оставлял вырожденным (newTo<from). Сдвигаю ОБЕ границы наstart - min(oldTo,newTo)→ диапазон не-вырожден (from<=oldTo,from<=newTo), как в диффе ProseMirror. Вставка байт-в-байт как раньше (min=oldTo → oldTo→start, newTo→newTo+delta); удаление и both-below теперь корректны. Guard не взводится ложно (взвод требуетoldTo>from И newTo>from, а нормализация оставляет ровно одну границу==from).F1: cover —
custom-typography.test.ts, 15 тестов через реальныйnew Editor(...)(зеркалитintentional-clear.test.ts): нормализацияfindChangedRange(вставка + починенное удаление),mapRangeThroughChange(release/boundary/shift), взвод (локальный undo-replace взводит; remote y-sync change-origin НЕ взводит; обычная правка НЕ взводит). Добавил test-only экспортыundoGuardKey/findChangedRange/mapRangeThroughChange.Внутреннее ревью — APPROVE (математика фикса верна для всех случаев, тесты не вакуозны через реальный путь; удаление-тесты падают против старого одностороннего кода). Проверка (apps/client, jsdom-конфиг):
vitest custom-typography15/15,intentional-clear5/5 (не сломан),tsc0. review/needs.Ре-ревью — #296 (не подставлять типографику повторно после Ctrl+Z), round 2, head
db9ed51e0, base develop3a5794894Вердикт: PASS — F1 и F2 закрыты и сверены (F2-математика вручную + эмпирически, F1-тесты невакуозны на реальном Editor). Готово к мержу.
Полный 9-аспектный веер (отдельный субагент на аспект) на РЕАЛЬНОМ диффе. Дельта round1→round2 в фиче — только F2-нормализация в
findChangedRange(+3exportпод тесты);custom-typography.test.ts+231 (15 тестов). НЕ schema-изменение → три-копийная синхронизация не нужна. Объективка на коде PR (детачdb9ed51e0, editor-ext собран): vitestcustom-typography+intentional-clear→ 20 passed (15+5, мой прогон); tsc чист.F2 — ЗАКРЫТО (математика верна во всех случаях)
findChangedRangeтеперь нормализует симметрично:minTo=Math.min(oldTo,newTo); if(minTo<start){delta=start-minTo; oldTo+=delta; newTo+=delta}. Проверено (stability+coherence, suffix-length-инвариант): пересечение границы (bound<start) бывает ТОЛЬКО при чистой вставке/удалении; настоящий replace всегдаminTo>=start→ нормализация не входит. Вставка — байт-в-байт как round 1 (min=oldTo → oldTo→start, newTo+=delta). Удаление (min=newTo) и both-below — теперь НЕ вырождены (from<=oldTo && from<=newTo), round 1 оставлялnewTo<from. Фикс load-bearing: round-1 недооценивалoldToпри удалении повторяющегося контента у границы гарда → мог оставить устаревший гард, ПЕРЕподавляющий типографику; F2 это чинит, не меняя shift-дельту. Ложного взвода нет: после нормализации ровно одна граница==from, а взвод требуетoldTo>from И newTo>from, поэтому чистый insert/delete взвестись не может.F1 — ЗАКРЫТО (15 невакуозных тестов, реальный путь)
custom-typography.test.tsчерез реальныйnew Editor([...CustomTypography])(зеркалитintentional-clear.test.ts, тегированные транзакцииsetMeta("history$")/setMeta(ySyncPluginKey,{isChangeOrigin:true}), гард читается только через публичныйundoGuardKey.getState)::95-114, ассертят{from:5,oldTo:6,newTo:5}+from<=newTo— падают против round-1-одностороннего кода → невакуозно, пиннят F2), both-below.:117-154), каждая ветка отдельно.{2,4}(:157); remote y-sync change-origin НЕ взводит → null (:173, сверено невакуозно против publishedisChangeOrigin=!!getMeta(ySyncPluginKey)+ реальной meta-формы y-tiptap — покрывает «правка пира, включая его undo, не взводит локальный гард»); обычная правка НЕ взводит (:188).Test-only экспорты (
undoGuardKey/findChangedRange/mapRangeThroughChange) — читаемо-минимальный seam, как у intentional-clear.Все 9 аспектов — LGTM. Фича вне F2 не менялась → round-1 чистота (работает end-to-end в обеих системах истории, collab-safe, инъекций нет) держится.
⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора)
[below-threshold]info[test-coverage] сам input-rule-suppression-хендлер (wrapper возвращает null при overlap с взведённым гардом) прямо не тестится — та же round-1-принятая область (4-строчная overlap-проверка над теперь-хорошо-покрытым guard-состоянием; нужен симуляция handleTextInput). Низкий риск.[below-threshold]info[documentation] pre-existing round-1 нит:HISTORY_META="history$"(стрингифай) vs коммент «yjs избегает fragile stringified keys» — я в round 1 проверил, что prosemirror-history НЕ экспортит свой ключ (импортировать нечего), сам ключ верен; framing-неточность, не баг. Не тронуто этим раундом.[below-threshold]info[conventions] неиспользуемый параметрschemaв тест-хелпереmutate; test-only-аннотация стоит только надundoGuardKey, не продублирована у двух helper-экспортов. Косметика.