[feature][editor] Кнопка «Ударение» в bubble-меню: знак ударения (U+0301) на выделенную букву #270

Open
opened 2026-07-01 00:42:12 +03:00 by vvzvlad · 0 comments
Owner

Summary

Добавить в всплывающее меню редактора (bubble menu) кнопку «Ударение»: пользователь выделяет гласную букву и одним кликом ставит над ней знак ударения. Повторный клик — снимает ударение (toggle).

Реализуется как вставка комбинируемого диакритического знака U+0301 (COMBINING ACUTE ACCENT) сразу после выделенной буквы. Это «честный» Unicode-символ, а НЕ кастомная TipTap-метка: он остаётся в тексте документа, в экспортируемых HTML/Markdown, в индексе полнотекстового поиска и в публичном шаре — без каких-либо изменений на сервере и в конвертерах.

Поиск по stress|ударени|u0301|accent|combining в apps/ и packages/ пуст — функции сейчас нет.

Где: apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx — это тот самый toolbar со скриншота (Text → выравнивание → B/I/U/S → код → спойлер → очистка → цвет → ссылка → комментарий).

Принятые решения

  • Без новой марки/узла и без серверных изменений. В отличие от спойлера (#246), ударение — это просто символ в тексте. НЕ нужны: пакет editor-ext, регистрация в collaboration.util.ts, turndown-правила экспорта, CSS. Markdown/HTML round-trip «бесплатный» — это обычный текст.
  • Символ: U+0301 (combining acute accent). Ставится после буквы — так работает комбинирование: знак рисуется над предыдущим символом. Для русского это канонический способ обозначения ударения (в Unicode нет прекомпозированных «а́/и́/о́…», поэтому строка остаётся в разложенном виде и стабильна при сохранении/загрузке — NFC её не «склеит»).
  • Поведение — переключатель. Если сразу после выделения уже стоит U+0301 — клик его удаляет; иначе вставляет. Одна транзакция → корректный Ctrl+Z.
  • Наследование форматирования. Знак вставляется с марками позиции вставки (tr.insertText применяет $from.marks()), поэтому остаётся в одном текстовом узле с буквой и корректно рисуется поверх неё, даже если буква bold/italic/цветная.
  • Иконка — кастомный мини-SVG (акут). В Tabler нет глифа акута: IconGrave — это надгробие, а не диакритика. Делаем маленький компонент с тем же API, что у Tabler-иконок ({ style, stroke }).
  • Только ред��ктируемое bubble-меню. В readonly-bubble-menu.tsx кнопку не добавляем (это действие правки).

Архитектура / реализация

Все изменения — клиентские: один файл редактора + переводы. Сервер и конвертеры не трогаем.

1. bubble-menu.tsx — иконка, состояние, пункт меню

Кастомная иконка (локальный мини-компонент):

// Custom acute-accent icon — Tabler has no acute glyph (IconGrave is a tombstone).
function IconStress({ style, stroke = 2 }: { style?: React.CSSProperties; stroke?: number }) {
  return (
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
      stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round" style={style}>
      <path d="M5 19l5 -12l5 12" /> {/* letter A */}
      <path d="M7.5 14h5" />        {/* A crossbar */}
      <path d="M13 5l4 -3" />       {/* acute accent over the apex */}
    </svg>
  );
}

Расширить useEditorState selector — флаг наличия ударения у выделенной буквы (для подсветки isActive):

const { to } = ctx.editor.state.selection;
const docSize = ctx.editor.state.doc.content.size;
const afterChar = ctx.editor.state.doc.textBetween(to, Math.min(to + 1, docSize));
// ... в возвращаемом объекте:
isStress: afterChar === "́",

Новый пункт в массив items (логично между «Spoiler» и «Clear formatting»):

{
  name: "Stress",
  isActive: () => editorState?.isStress,
  command: () => {
    const STRESS = "́"; // combining acute accent (U+0301)
    props.editor.chain().focus().command(({ tr, state, dispatch }) => {
      const { to, empty } = state.selection;
      if (empty) return false;                 // bubble menu shows only on selection; guard anyway
      const max = state.doc.content.size;
      const after = state.doc.textBetween(to, Math.min(to + 1, max));
      if (!dispatch) return true;
      if (after === STRESS) {
        tr.delete(to, to + 1);                 // toggle off: remove existing accent
      } else {
        tr.insertText(STRESS, to);             // insert after last selected char; inherits marks at `to`
      }
      return true;
    }).run();
  },
  icon: IconStress,
},

2. i18n

Ключ "Stress": en-US → "Stress", ru-RU → "Ударение" (apps/client/public/locales/*/translation.json, рядом с Bold/Italic/Spoiler). Остальные локали — по желанию, иначе фолбэк на английский ключ.

Тонкости и edge-cases

  • Выделение из нескольких букв. Знак ставится после последней выделенной буквы (ударение на неё). Ожидаемый сценарий — выделить одну гласную; стоит проговорить это в тултипе/доке.
  • Позиции ProseMirror. U+0301 — один UTF-16-юнит (BMP), занимает +1 позицию; смещений не ломает.
  • Граница узла / конец документа. Math.min(to+1, content.size) + textBetween через границу блока вернёт "" → совпадения нет → просто вставка (безвредно).
  • NFC-нормализация. Для русских гласных прекомпозированных форм с акутом нет → строка остаётся разложенной и переживает round-trip через БД/Yjs.
  • Undo. Одна транзакция + .focus() → Ctrl+Z снимает за один шаг (ср. с багом поте��и фокуса #269 — здесь фокус не теряем).
  • Наследование марок. Проверить на bold/italic/цветной букве: знак должен оставаться в том же runs и рисоваться над буквой. Фолбэк, если insertText не подхватит марки: tr.replaceWith(to, to, state.schema.text(STRESS, state.doc.resolve(to).marks())).
  • Не-буквенное выделение (пробел/пунктуация) — знак прилипнет к символу и нарисуется странно, но вреда нет; не блокирующее.
  • readonly-bubble-menu — кнопки там быть не должно.

Definition of Done

  • В редактируемом bubble-меню есть кнопка «Stress» (в readonly — нет).
  • Клик при выделенной гласной добавляет U+0301 после неё; визуально появляется ударение.
  • Повторный клик (ударение уже стоит) снимает его (toggle); isActive подсвечивает состояние.
  • Форматирование буквы (bold/italic/цвет) сохраняется, знак остаётся в том же текстовом узле.
  • Ctrl+Z отменяет за один шаг.
  • Markdown/HTML-экспорт и полнотекстовый поиск сохраняют символ (обычный текст; серверных правок нет).
  • Ключ i18n «Stress» добавлен (en + ru).

Out of scope

  • Другие диакритики (гравис, умляут и т.п.) и общий «диакритик-пикер».
  • Автоматическая простановка ударений (словарь/эвристика).
  • Кастомная TipTap-метка для ударения (намеренно отвергнута — обычный Unicode портативнее).
  • Кнопка в fixed-toolbar (inline-marks-group.tsx) — опциональный follow-up для консистентности.
## Summary Добавить в всплывающее меню редактора (bubble menu) кнопку **«Ударение»**: пользователь выделяет гласную букву и одним кликом ставит над ней знак ударения. Повторный клик — снимает ударение (toggle). Реализуется как вставка комбинируемого диакритического знака **U+0301 (COMBINING ACUTE ACCENT)** сразу после выделенной буквы. Это «честный» Unicode-символ, а НЕ кастомная TipTap-метка: он остаётся в тексте документа, в экспортируемых HTML/Markdown, в индексе полнотекстового поиска и в публичном шаре — без каких-либо изменений на сервере и в конвертерах. Поиск по `stress|ударени|u0301|accent|combining` в `apps/` и `packages/` пуст — функции сейчас нет. **Где:** `apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx` — это тот самый toolbar со скриншота (Text → выравнивание → B/I/U/S → код → спойлер → очистка → цвет → ссылка → комментарий). ## Принятые решения - **Без новой марки/узла и без серверных изменений.** В отличие от спойлера (#246), ударение — это просто символ в тексте. НЕ нужны: пакет `editor-ext`, регистрация в `collaboration.util.ts`, turndown-правила экспорта, CSS. Markdown/HTML round-trip «бесплатный» — это обычный текст. - **Символ:** U+0301 (combining acute accent). Ставится **после** буквы — так работает комбинирование: знак рисуется над предыдущим символом. Для русского это канонический способ обозначения ударения (в Unicode нет прекомпозированных «а́/и́/о́…», поэтому строка остаётся в разложенном виде и стабильна при сохранении/загрузке — NFC её не «склеит»). - **Поведение — переключатель.** Если сразу после выделения уже стоит U+0301 — клик его удаляет; иначе вставляет. Одна транзакция → корректный Ctrl+Z. - **Наследование форматирования.** Знак вставляется с марками позиции вставки (`tr.insertText` применяет `$from.marks()`), поэтому остаётся в одном текстовом узле с буквой и корректно рисуется поверх неё, даже если буква bold/italic/цветная. - **Иконка — кастомный мини-SVG** (акут). В Tabler нет глифа акута: `IconGrave` — это надгробие, а не диакритика. Делаем маленький компонент с тем же API, что у Tabler-иконок (`{ style, stroke }`). - **Только ред��ктируемое bubble-меню.** В `readonly-bubble-menu.tsx` кнопку не добавляем (это действие правки). ## Архитектура / реализация Все изменения — клиентские: один файл редактора + переводы. Сервер и конвертеры не трогаем. ### 1. `bubble-menu.tsx` — иконка, состояние, пункт меню Кастомная иконка (локальный мини-компонент): ```tsx // Custom acute-accent icon — Tabler has no acute glyph (IconGrave is a tombstone). function IconStress({ style, stroke = 2 }: { style?: React.CSSProperties; stroke?: number }) { return ( <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round" style={style}> <path d="M5 19l5 -12l5 12" /> {/* letter A */} <path d="M7.5 14h5" /> {/* A crossbar */} <path d="M13 5l4 -3" /> {/* acute accent over the apex */} </svg> ); } ``` Расширить `useEditorState` selector — флаг наличия ударения у выделенной буквы (для подсветки `isActive`): ```ts const { to } = ctx.editor.state.selection; const docSize = ctx.editor.state.doc.content.size; const afterChar = ctx.editor.state.doc.textBetween(to, Math.min(to + 1, docSize)); // ... в возвращаемом объекте: isStress: afterChar === "́", ``` Новый пункт в массив `items` (логично между «Spoiler» и «Clear formatting»): ```ts { name: "Stress", isActive: () => editorState?.isStress, command: () => { const STRESS = "́"; // combining acute accent (U+0301) props.editor.chain().focus().command(({ tr, state, dispatch }) => { const { to, empty } = state.selection; if (empty) return false; // bubble menu shows only on selection; guard anyway const max = state.doc.content.size; const after = state.doc.textBetween(to, Math.min(to + 1, max)); if (!dispatch) return true; if (after === STRESS) { tr.delete(to, to + 1); // toggle off: remove existing accent } else { tr.insertText(STRESS, to); // insert after last selected char; inherits marks at `to` } return true; }).run(); }, icon: IconStress, }, ``` ### 2. i18n Ключ `"Stress"`: en-US → `"Stress"`, ru-RU → `"Ударение"` (`apps/client/public/locales/*/translation.json`, рядом с `Bold/Italic/Spoiler`). Остальные локали — по желанию, иначе фолбэк на английский ключ. ## Тонкости и edge-cases - **Выделение из нескольких букв.** Знак ставится после **последней** выделенной буквы (ударение на неё). Ожидаемый сценарий — выделить одну гласную; стоит проговорить это в тултипе/доке. - **Позиции ProseMirror.** U+0301 — один UTF-16-юнит (BMP), занимает +1 позицию; смещений не ломает. - **Граница узла / конец документа.** `Math.min(to+1, content.size)` + `textBetween` через границу блока вернёт `""` → совпадения нет → просто вставка (безвредно). - **NFC-нормализация.** Для русских гласных прекомпозированных форм с акутом нет → строка остаётся разложенной и переживает round-trip через БД/Yjs. - **Undo.** Одна транзакция + `.focus()` → Ctrl+Z снимает за один шаг (ср. с багом поте��и фокуса #269 — здесь фокус не теряем). - **Наследование марок.** Проверить на bold/italic/цветной букве: знак должен оставаться в том же runs и рисоваться над буквой. Фолбэк, если `insertText` не подхватит марки: `tr.replaceWith(to, to, state.schema.text(STRESS, state.doc.resolve(to).marks()))`. - **Не-буквенное выделение** (пробел/пунктуация) — знак прилипнет к символу и нарисуется странно, но вреда нет; не блокирующее. - **readonly-bubble-menu** — кнопки там быть не должно. ## Definition of Done - [ ] В редактируемом bubble-меню есть кнопка «Stress» (в readonly — нет). - [ ] Клик при выделенной гласной добавляет U+0301 после неё; визуально появляется ударение. - [ ] Повторный клик (ударение уже стоит) снимает его (toggle); `isActive` подсвечивает состояние. - [ ] Форматирование буквы (bold/italic/цвет) сохраняется, знак остаётся в том же текстовом узле. - [ ] Ctrl+Z отменяет за один шаг. - [ ] Markdown/HTML-экспорт и полнотекстовый поиск сохраняют символ (обычный текст; серверных правок нет). - [ ] Ключ i18n «Stress» добавлен (en + ru). ## Out of scope - Другие диакритики (гравис, умляут и т.п.) и общий «диакритик-пикер». - Автоматическая простановка ударений (словарь/эвристика). - Кастомная TipTap-метка для ударения (намеренно отвергнута — обычный Unicode портативнее). - Кнопка в fixed-toolbar (`inline-marks-group.tsx`) — опциональный follow-up для консистентности.
vvzvlad added the feature label 2026-07-01 00:42:12 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#270