У спільному редакторі Content Blocks у ICanUp з’явився дуже дивний баг: після перетягування блоків усередині Rich Text раптом опинявся текст, якого я туди ніколи не вводила.
Це були не випадкові символи. У збереженому контенті можна було побачити службові підписи самого інтерфейсу: назву редактора, locale, позицію блока, ID якоря, підказку до поля та тип параграфа.
Тобто drag-and-drop змінював не тільки порядок блоків. У певному сценарії він міг пошкодити сам вміст Rich Text.
Для редактора контенту це особливо небезпечний клас помилок, бо UI-текст може тихо потрапити в модель, а потім зберегтися в базі як справжній контент.

Vue Rich Text Editor після drag-and-drop містив текст інтерфейсу
Спостережуваний результат виглядав приблизно так: до нормального тексту могли домішатися службові фрагменти блока.
Текст (редактор) #5EN Позиція: 5 ID якоря Введіть без #.Посилайтеся як #service-inquiry. ПараграфКожен із цих фрагментів має право існувати в адміністративному інтерфейсі.
Але жоден із них не повинен опинитися всередині значення Rich Text, яке потім рендериться на публічній сторінці.
Чому цей баг небезпечніший за звичайну помилку drag-and-drop
Якщо після перетягування блок опинився не на тій позиції, це помітно одразу.
Пошкодження текстового контенту може бути значно менш очевидним.
- службовий текст може з’явитися далеко всередині великого матеріалу;
- автор може не помітити його перед Save;
- після збереження він уже стає частиною даних;
- помилка може повторитися після наступного reorder;
- однаковий shared editor використовується для різних типів контенту.
Тому тут недостатньо просто зробити так, щоб drag-and-drop знову "виглядав нормально". Потрібно захистити саму цілісність контенту.
contenteditable не знає, який текст для нас є службовим
Для браузера contenteditable працює з DOM і selection.
Він не знає, що напис Позиція: 5 є службовим елементом Vue-компонента, а абзац поруч є контентом автора.
Цю межу має визначити наша структура компонента та інтеграція редактора.
<div class="content-block"> <header class="block-ui"> <span>EN</span> <span>Position: 5</span> <button>Move</button> </header> <div class="editor-shell"> <div contenteditable="true"> Real article content </div> </div> </div>Візуально все це може бути однією карткою.
Але для редактора правильна межа значно вужча: його контентом має бути тільки конкретний editor root.
Візуальна близькість елементів не означає, що вони повинні належати до однієї моделі редагованого контенту.
Перша перевірка: чи Rich Text читає тільки власний editor root
Один із небезпечних підходів для складного редактора - серіалізувати занадто великий DOM-контейнер.
// Too broad for a Rich Text content boundary.const html = blockElement.innerHTML;Якщо blockElement містить і Rich Text, і службовий UI, то сама структура вже дозволяє забрати більше даних, ніж потрібно.
Надійніший контракт полягає в тому, що контент береться з API редактора або з його конкретного root element.
const html = editor.getHTML();Stable Vue keys критичні, коли блоки змінюють порядок
Наступна важлива перевірка - ідентичність Vue-компонента після reorder.
Якщо ключ залежить від позиції елемента в масиві, після перестановки Vue може повторно використати екземпляр компонента не так, як очікує складний Rich Text editor.
<ContentBlockEditor v-for="(block, index) in blocks" :key="index" :block="block"/>Проблема тут не в тому, що index завжди заборонений.
Проблема в тому, що після reorder позиція вже не є ідентичністю конкретного ContentBlock.
<ContentBlockEditor v-for="block in blocks" :key="block.clientKey" :block="block"/>Для вже збереженого блока це може бути постійний ID. Для нового блока до збереження може знадобитися стабільний клієнтський ключ.
Важливо, щоб reorder змінював позицію, але не особистість компонента.
Чому повторне використання неправильного editor instance може пошкодити state
Rich Text editor часто має власний внутрішній стан, selection, listeners і посилання на DOM.
Тому його не можна розглядати як простий статичний textarea.
Before reorder: Block A -> Editor instance ABlock B -> Editor instance BBlock C -> Editor instance C After reorder should still be: Block C -> Editor instance CBlock A -> Editor instance ABlock B -> Editor instance BЯкщо після перестановки екземпляр редактора починає обслуговувати інший блок, можна отримати набагато складніші симптоми, ніж просто неправильний порядок.
Саме тому stable keys і життєвий цикл редактора входять до перших речей, які я перевіряю.
Drag handle повинен бути окремим від області редагування
Ще одна важлива межа - що саме можна схопити мишею для переміщення блока.
Якщо draggable-зона охоплює всю картку разом із contenteditable, браузеру потрібно одночасно розрізняти дві складні взаємодії:
- користувач виділяє або редагує текст;
- користувач починає перетягувати цілий блок.
Ці сценарії краще розділити явним drag handle.
<div class="content-block"> <button class="drag-handle" type="button" > Move </button> <RichTextEditor /> </div>Редактор залишається областю для selection і введення тексту, а службова кнопка відповідає за переміщення блока.
Selection і drag events можуть взаємодіяти неочевидно
У браузері перетягування тексту вже має власну поведінку.
Виділений текст можна drag-and-drop перемістити в іншу editable-зону. Тому баг у block reorder не можна досліджувати так, ніби selection взагалі не існує.
User selection | vdragstart | +--> block reorder? | +--> native text drag? | +--> DataTransfer content? | vdrop targetОсобливо важливо перевірити сценарій, коли курсор або selection активні всередині редактора безпосередньо перед переміщенням блока.
Reorder не повинен змінювати Rich Text model
Для мене головний контракт цього компонента можна сформулювати дуже просто.
Before reorder: Block A:position = 1content = "<p>Alpha</p>" Block B:position = 2content = "<p>Beta</p>" After reorder: Block B:position = 1content = "<p>Beta</p>"Якщо разом із position змінюється content, це вже порушення меж між двома незалежними операціями.
Синхронізація editor state і ContentBlock model має бути однозначною
У складному редакторі існує щонайменше два рівні стану:
Рівень | Що зберігає |
|---|---|
Rich Text editor | Поточний документ, selection, internal state |
Vue ContentBlock model | Тип блока, position, locale, data та інші поля |
Потрібно чітко визначити, коли редактор передає новий HTML у модель і що відбувається під час reorder.
Переміщення блока не повинно запускати випадкову повторну серіалізацію стороннього DOM.
Locale isolation потрібно перевіряти окремо
У ICanUp Content Blocks локалізовані.
Тому reorder не повинен не тільки псувати Rich Text, а й змішувати стан UK та EN блоків.
UK block:locale = ukcontent = український текст EN block:locale = encontent = English text After repeated reorder: UK stays UKEN stays ENcontent stays attached to its own blockЧому fix тільки для News був би неправильним
Проблему можна вперше помітити в одному конкретному редакторі.
Але якщо Post, Page та News використовують спільний ContentBlocks editor, виправлення в одному екрані лише приховає архітектурну проблему.
Post ----+ |Page ----+--> Shared ContentBlocks Editor |News ----+Контракт reorder + Rich Text повинен бути правильним у спільному компоненті.
DOM блока і модель ContentBlock не повинні бути одним і тим самим
Цей баг добре нагадує про одну фундаментальну річ.
DOM адміністративного редактора містить набагато більше інформації, ніж модель публічного контенту.
Admin block DOM: - block number- locale badge- position- action buttons- drag handle- anchor field- help text- editor toolbar- Rich Text content Persisted Rich Text: - user-authored content onlyHTML адміністративного компонента не повинен ставати неявним форматом збереження контенту.
Як я б будувала безпечний контракт Rich Text + drag-and-drop
- Кожен ContentBlock має стабільну ідентичність, яка не залежить від position.
- Rich Text editor має власний чіткий root.
- Контент читається з editor model/API, а не з DOM усього блока.
- Drag починається через окремий handle.
- Editable area не використовується як drag handle.
- Reorder змінює порядок, але не Rich Text data.
- Editor instance залишається прив’язаним до того самого ContentBlock.
- Destroy/remount виконуються тільки тоді, коли цього справді вимагає lifecycle.
- Locale входить до контракту ідентичності блока.
- Після Save + reload відновлюється тільки введений користувачем контент.
Vue key має означати ідентичність, а не позицію
Для stateful-компонентів це один із найважливіших принципів.
const block = { id: null, clientKey: crypto.randomUUID(), position: 5, type: 'rich_text',};position може змінитися десять разів. clientKey не повинен змінюватися разом із ним.
Не можна виправляти пошкодження просто очищенням від відомих фраз
Після такого бага може виникнути спокуса перед збереженням видаляти відомі службові слова.
// Wrong direction.html = html .replace('Position:', '') .replace('Anchor ID', '') .replace('Paragraph', '');Такий підхід небезпечний.
По-перше, ці слова можуть бути справжнім текстом статті. По-друге, завтра з’явиться новий label. По-третє, джерело пошкодження залишиться.
Потрібно не очищати забруднений HTML, а не допустити потрапляння UI в editor model.
Регресійний тест має перевіряти content integrity після reorder
Тест на те, що масив блоків поміняв порядок, тут недостатній.
Потрібно перевірити і сам контент.
const before = { first: '<p>Alpha</p>', second: '<p>Beta</p>',}; // Reorder blocks. expect(firstBlock.content).toBe( '<p>Beta</p>'); expect(secondBlock.content).toBe( '<p>Alpha</p>');Які сценарії потрібно перевірити після виправлення
Сценарій | Очікування |
|---|---|
Один reorder | Змінюється тільки position |
Кілька reorder поспіль | Rich Text не пошкоджується |
Незбережений текст | Не губиться під час переміщення |
UK та EN | Контент локалей не змішується |
Rich Text + інший block type | Стан редактора залишається прив’язаним до свого блока |
Save + reload | Повертається тільки реальний введений контент |
Post | Спільний редактор працює правильно |
Page | Спільний редактор працює правильно |
News | Спільний редактор працює правильно |
Manual test важливий через browser selection і drag behavior
Частину таких сценаріїв складно повністю відтворити лише unit-тестами, тому ручна перевірка в браузері залишається важливою.
- Ввести у два Rich Text blocks різний помітний текст.
- Перемістити нижній блок угору.
- Повторити reorder кілька разів.
- Спробувати reorder після активного курсора в editor.
- Спробувати reorder після виділення частини тексту.
- Перевірити UK та EN.
- Зберегти матеріал.
- Перезавантажити сторінку.
- Перевірити, що службових labels у content немає.
- Повторити сценарій для Post, Page та News.
Що я не роблю після такого бага
- Не використовую position як єдину ідентичність stateful Vue editor.
- Не серіалізую весь DOM ContentBlock як Rich Text content.
- Не роблю всю editable-картку одночасно drag handle.
- Не припускаю, що reorder не може впливати на editor lifecycle.
- Не очищаю збережений HTML від відомих UI-фраз як основне виправлення.
- Не виправляю shared ContentBlocks bug тільки для однієї сутності.
- Не тестую лише новий порядок блоків без перевірки самого Rich Text.
- Не забуваю перевіряти locale isolation.
Checklist для Vue Rich Text Editor з drag-and-drop
- Визначити точний editor root.
- Переконатися, що службовий DOM знаходиться поза content model.
- Перевірити стабільні Vue keys.
- Не прив’язувати ідентичність компонента до index або position.
- Перевірити lifecycle редактора після reorder.
- Перевірити destroy/remount і повторне використання instance.
- Мати окремий drag handle.
- Перевірити native text selection і drag events.
- Оновлювати ContentBlock model тільки з editor state.
- Гарантувати, що reorder не змінює content.
- Перевірити UK/EN isolation.
- Запустити repeated reorder + Save + reload regression.
- Перевірити всі сутності, які використовують shared editor.
У редакторі блоків порядок, службовий UI і користувацький контент можуть жити в одній Vue-компоненті, але вони не повинні мати одну межу даних.
Висновок: drag-and-drop має переміщувати ContentBlock, а не його Rich Text
Баг із Rich Text у ICanUp був нетиповим саме тому, що після drag-and-drop пошкоджувався не лише порядок елементів, а міг змінитися сам контент.
Службові підписи блока існували поруч із редактором і в певному сценарії опинялися всередині Rich Text. Це змусило дивитися на проблему ширше: не тільки на draggable, а на ідентичність Vue-компонентів, lifecycle редактора, DOM boundaries, selection, drag events та синхронізацію editor state з ContentBlock model.
Для мене ключовий контракт тепер дуже чіткий: reorder має змінювати тільки position. Текст, locale та зв’язок Rich Text editor із конкретним ContentBlock повинні залишатися незмінними.
Головний урок: якщо складний editor знаходиться всередині draggable UI, потрібно окремо захищати межу між DOM інтерфейсу і даними, які вважаються справжнім контентом.



