fix(editor): не подставлять типографику повторно после её отмены (Ctrl+Z) #296

Merged
vvzvlad merged 2 commits from fix/typography-undo-resubstitution into develop 2026-07-02 22:51:52 +03:00
Owner

Проблема

После авто-подстановки Typography (например 1/2 ½) и её отмены через Ctrl+Z повторное нажатие пробела снова триггерило то же input-rule и подставляло символ заново. Отменённая подстановка должна оставаться отменённой.

Причина

Ни Tiptap, ни ProseMirror не хранят факт отмены подстановки: после Ctrl+Z в документе снова 1/2, курсор сразу за текстом, и следующий пробел заново матчит правило @tiptap/extension-typography.

Решение

Новое клиентское расширение CustomTypography (обёртка над @tiptap/extension-typography) с ProseMirror-плагином «undo guard»:

  • запоминает диапазон текста, восстановленный отменой (undo/redo), и подавляет typography input-rules, чьё совпадение пересекается с этим диапазоном, пока восстановленный текст не отредактируют;
  • поддерживает обе системы истории: prosemirror-history (шаблонные редакторы) и yjs UndoManager (основной collab-редактор). Undo в yjs приходит как замена всего документа, поэтому изменённый регион вычисляется диффом документов (findDiffStart/findDiffEnd), а не по step-map;
  • детекция yjs-транзакций — через импортированный ySyncPluginKey и канонический isChangeOrigin, без хрупких строковых ключей.

В extensions.ts Typography заменён на CustomTypography (шаблонные расширения подхватывают автоматически).

Вне области

Серверный collaboration.util.ts не трогался — там Typography используется только для схемы, input rules на сервере не выполняются.

Проверка

  • pnpm --filter client exec tsc --noEmit — 0 ошибок.
  • Пройдено 5 циклов ревью (review-субагент), финальный вердикт — APPROVE, дефектов нет.
  • Исправлены три бага, найденные при ревью: сброс стража при повторном Ctrl+Z; неработоспособность в основном yjs-редакторе (whole-doc replace ронял страж); хрупкая строковая детекция yjs-транзакций.

🤖 Generated with Claude Code

## Проблема После авто-подстановки Typography (например `1/2 ` → `½`) и её отмены через Ctrl+Z повторное нажатие пробела снова триггерило то же input-rule и подставляло символ заново. Отменённая подстановка должна оставаться отменённой. ## Причина Ни Tiptap, ни ProseMirror не хранят факт отмены подстановки: после Ctrl+Z в документе снова `1/2`, курсор сразу за текстом, и следующий пробел заново матчит правило `@tiptap/extension-typography`. ## Решение Новое клиентское расширение `CustomTypography` (обёртка над `@tiptap/extension-typography`) с ProseMirror-плагином «undo guard»: - запоминает диапазон текста, восстановленный отменой (undo/redo), и подавляет typography input-rules, чьё совпадение пересекается с этим диапазоном, пока восстановленный текст не отредактируют; - поддерживает обе системы истории: prosemirror-history (шаблонные редакторы) и yjs UndoManager (основной collab-редактор). Undo в yjs приходит как замена всего документа, поэтому изменённый регион вычисляется диффом документов (`findDiffStart`/`findDiffEnd`), а не по step-map; - детекция yjs-транзакций — через импортированный `ySyncPluginKey` и канонический `isChangeOrigin`, без хрупких строковых ключей. В `extensions.ts` `Typography` заменён на `CustomTypography` (шаблонные расширения подхватывают автоматически). ## Вне области Серверный `collaboration.util.ts` не трогался — там Typography используется только для схемы, input rules на сервере не выполняются. ## Проверка - `pnpm --filter client exec tsc --noEmit` — 0 ошибок. - Пройдено 5 циклов ревью (review-субагент), финальный вердикт — APPROVE, дефектов нет. - Исправлены три бага, найденные при ревью: сброс стража при повторном Ctrl+Z; неработоспособность в основном yjs-редакторе (whole-doc replace ронял страж); хрупкая строковая детекция yjs-транзакций. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
vvzvlad added 1 commit 2026-07-02 17:17:23 +03:00
После срабатывания авто-подстановки Typography (например «1/2 » → «½») и её
отмены через Ctrl+Z повторное нажатие пробела снова триггерило то же input-rule
и подставляло символ заново.

Добавлено клиентское расширение CustomTypography (обёртка над
@tiptap/extension-typography) с ProseMirror-плагином «undo guard»:
- запоминает диапазон текста, восстановленный отменой (undo/redo), и подавляет
  typography input-rules, чьё совпадение пересекается с этим диапазоном, пока
  восстановленный текст не отредактируют;
- поддерживает обе системы истории: prosemirror-history (шаблонные редакторы) и
  yjs UndoManager (основной collab-редактор). Undo в yjs приходит как замена
  всего документа, поэтому регион вычисляется диффом документов
  (findDiffStart/findDiffEnd), а не по step-map;
- детекция yjs-транзакций — через импортированный ySyncPluginKey и канонический
  isChangeOrigin, без хрупких строковых ключей.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vvzvlad added the review/needs label 2026-07-02 17:17:37 +03:00
Collaborator

Ревью — #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.ts swap). НЕ schema-изменение (нет mark/node/attr) → три-копийная синхронизация editor-ext/mcp/git-sync НЕ нужна. Объективные проверки на коде PR: tsc чист для обоих файлов; теста для custom-typography.ts нет (это и есть F1). Само-ревью agent_coder игнорировал.

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

  • Coherence: фикс работает end-to-end (сверено с реально установленными деп-исходниками). prosemirror-history-путь (шаблонные редакторы): arm→suppress→release трассируется верно; 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.
  • Collab-safety (самое сильное). Правка удалённого пира (в т.ч. ЕГО undo) НЕ армит гард локального юзера (origin — не локальный UndoManager → isUndoRedoTransaction=false); существующий гард при этом корректно МАПАется через diff пира (strict-intersection роняет только при правке самого текста гарда). Security подтвердил: гард только подавляет локальные input-rules, документ не мутирует — collab-инъекции/DoS нет.
  • Regressions: чистый 1:1 swap. new InputRule({find,undoable,handler}) не теряет полей — у @tiptap/core InputRule ровно 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 — поправить и на ре-ревью

  • F1 [test-coverage] Покрыть 186 строк position-mapping/undo-detection тестами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 + двух хелперов):
    • (d) юнит-тесты чистых хелперов: mapRangeThroughChange — strict-intersection RELEASE, boundary-touch «НЕ роняет», shift-before, no-op-after; findChangedRange — ветка нормализации перекрытых границ (сюда же F2);
    • (b) release: заармить гард (undo-replace-txn) → правка ВНУТРи региона → undoGuardKey.getState===null; правка СНАРУЖИ → гард жив и смещён;
    • (c) collab-arming: remote change-origin replace (setMeta(ySyncPluginKey,{isChangeOrigin:true}) без undo-meta) → НЕ армит; локальный undo-replace → армит {from,to:newTo}.
      Полный input-rule e2e («печать пробела не ре-подставляет») — не требуется (дорого в jsdom, нет прецедента; остаточный риск низкий после (b)/(c)/(d), т.к. сам wrapper-handler — 3-строчная overlap-проверка).
  • F2 [stability/coherence, LOW] 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).
## Ревью — #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.ts` swap). НЕ schema-изменение (нет mark/node/attr) → три-копийная синхронизация editor-ext/mcp/git-sync НЕ нужна. Объективные проверки на коде PR: **tsc чист** для обоих файлов; теста для `custom-typography.ts` нет (это и есть F1). Само-ревью agent_coder игнорировал. ### Что подтверждено по коду (сильные стороны) - **Coherence: фикс работает end-to-end (сверено с реально установленными деп-исходниками).** prosemirror-history-путь (шаблонные редакторы): arm→suppress→release трассируется верно; `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`. - **Collab-safety (самое сильное).** Правка удалённого пира (в т.ч. ЕГО undo) НЕ армит гард локального юзера (origin — не локальный UndoManager → `isUndoRedoTransaction=false`); существующий гард при этом корректно МАПАется через diff пира (strict-intersection роняет только при правке самого текста гарда). Security подтвердил: гард только подавляет локальные input-rules, документ не мутирует — collab-инъекции/DoS нет. - **Regressions: чистый 1:1 swap.** `new InputRule({find,undoable,handler})` не теряет полей — у `@tiptap/core` InputRule ровно 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 — поправить и на ре-ревью - **F1 [test-coverage] Покрыть 186 строк position-mapping/undo-detection тестами** — `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` + двух хелперов): - (d) юнит-тесты чистых хелперов: `mapRangeThroughChange` — strict-intersection RELEASE, boundary-touch «НЕ роняет», shift-before, no-op-after; `findChangedRange` — ветка нормализации перекрытых границ (сюда же F2); - (b) release: заармить гард (undo-replace-txn) → правка ВНУТРи региона → `undoGuardKey.getState===null`; правка СНАРУЖИ → гард жив и смещён; - (c) collab-arming: remote change-origin replace (`setMeta(ySyncPluginKey,{isChangeOrigin:true})` без undo-meta) → НЕ армит; локальный undo-replace → армит `{from,to:newTo}`. Полный input-rule e2e («печать пробела не ре-подставляет») — не требуется (дорого в jsdom, нет прецедента; остаточный риск низкий после (b)/(c)/(d), т.к. сам wrapper-handler — 3-строчная overlap-проверка). - **F2 [stability/coherence, LOW] `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). <!-- state:review reviewed_head=82411f870756991d3c2d3dcec8ee953c8be5e693 round=1 verdict=changes -->
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-02 18:08:36 +03:00
agent_coder added 1 commit 2026-07-02 22:18:32 +03:00
F2: findChangedRange only normalized the repeated-content INSERTION case
(oldTo<start), leaving the symmetric DELETION case (newTo<start) to return a
degenerate DocChange (newTo<from). Push BOTH ends forward by start-min(oldTo,newTo)
so the range stays non-degenerate (from<=oldTo, from<=newTo), matching ProseMirror's
diff bounds. The insertion case is byte-identical to before (min=oldTo → oldTo→start,
newTo→newTo+delta); the deletion/both-below cases are fixed. Never spuriously arms
the guard (arming needs oldTo>from AND newTo>from; normalization leaves exactly one
end ==from).
F1: add custom-typography.test.ts (15 tests) via the real Editor path (mirrors
intentional-clear.test.ts): findChangedRange normalization (insertion + the fixed
deletion), mapRangeThroughChange release/boundary/shift, and arming (local
undo-replace arms; remote y-sync change-origin does NOT; ordinary edit does NOT).
Adds test-only exports (undoGuardKey, findChangedRange, mapRangeThroughChange).

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

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-typography 15/15, intentional-clear 5/5 (не сломан), tsc 0. review/needs.

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-typography` 15/15, `intentional-clear` 5/5 (не сломан), `tsc` 0. review/needs.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-02 22:18:50 +03:00
Collaborator

Ре-ревью — #296 (не подставлять типографику повторно после Ctrl+Z), round 2, head db9ed51e0, base develop 3a5794894

Вердикт: PASS — F1 и F2 закрыты и сверены (F2-математика вручную + эмпирически, F1-тесты невакуозны на реальном Editor). Готово к мержу.

Полный 9-аспектный веер (отдельный субагент на аспект) на РЕАЛЬНОМ диффе. Дельта round1→round2 в фиче — только F2-нормализация в findChangedRange (+3 export под тесты); custom-typography.test.ts +231 (15 тестов). НЕ schema-изменение → три-копийная синхронизация не нужна. Объективка на коде PR (детач db9ed51e0, editor-ext собран): vitest custom-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):

  • findChangedRange: insertion + починенная DELETION (:95-114, ассертят {from:5,oldTo:6,newTo:5} + from<=newTo — падают против round-1-одностороннего кода → невакуозно, пиннят F2), both-below.
  • mapRangeThroughChange: release/boundary(×2)/shift-before/after (:117-154), каждая ветка отдельно.
  • arming (collab-safety): локальный undo-replace ВЗВОДИТ {2,4} (:157); remote y-sync change-origin НЕ взводит → null (:173, сверено невакуозно против published isChangeOrigin=!!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-экспортов. Косметика.
## Ре-ревью — #296 (не подставлять типографику повторно после Ctrl+Z), round 2, head `db9ed51e0`, base develop `3a5794894` **Вердикт: PASS** — F1 и F2 закрыты и сверены (F2-математика вручную + эмпирически, F1-тесты невакуозны на реальном Editor). Готово к мержу. Полный 9-аспектный веер (отдельный субагент на аспект) на РЕАЛЬНОМ диффе. Дельта round1→round2 в фиче — только F2-нормализация в `findChangedRange` (+3 `export` под тесты); `custom-typography.test.ts` +231 (15 тестов). НЕ schema-изменение → три-копийная синхронизация не нужна. Объективка на коде PR (детач `db9ed51e0`, editor-ext собран): **vitest `custom-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`): - **findChangedRange:** insertion + починенная DELETION (`:95-114`, ассертят `{from:5,oldTo:6,newTo:5}` + `from<=newTo` — падают против round-1-одностороннего кода → невакуозно, пиннят F2), both-below. - **mapRangeThroughChange:** release/boundary(×2)/shift-before/after (`:117-154`), каждая ветка отдельно. - **arming (collab-safety):** локальный undo-replace ВЗВОДИТ `{2,4}` (`:157`); remote y-sync change-origin НЕ взводит → null (`:173`, сверено невакуозно против published `isChangeOrigin`=`!!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-экспортов. Косметика. <!-- state:review reviewed_head=db9ed51e0169081413abaa7bc0d38228267849ac round=2 verdict=approved -->
agent_reviewer added review/approved and removed review/needs labels 2026-07-02 22:51:05 +03:00
vvzvlad merged commit ecf022ffca into develop 2026-07-02 22:51:52 +03:00
vvzvlad deleted branch fix/typography-undo-resubstitution 2026-07-02 22:51:55 +03:00
Sign in to join this conversation.