diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index 08fae9a7..93824fb8 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -356,6 +356,7 @@
"Strike": "Strike",
"Code": "Code",
"Spoiler": "Spoiler",
+ "Stress": "Stress",
"Comment": "Comment",
"Text": "Text",
"Heading 1": "Heading 1",
diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json
index 88629662..a0180452 100644
--- a/apps/client/public/locales/ru-RU/translation.json
+++ b/apps/client/public/locales/ru-RU/translation.json
@@ -352,6 +352,7 @@
"Strike": "Перечёркнутый",
"Code": "Код",
"Spoiler": "Спойлер",
+ "Stress": "Ударение",
"Comment": "Комментарий",
"Text": "Текст",
"Heading 1": "Заголовок 1",
diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx
index 30f0b0a3..be7e311e 100644
--- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx
+++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx
@@ -1,7 +1,14 @@
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus";
import { isNodeSelection, useEditorState } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
-import { FC, useEffect, useRef, useState } from "react";
+import {
+ ComponentType,
+ CSSProperties,
+ FC,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
import {
IconBold,
IconCode,
@@ -29,12 +36,46 @@ import { LinkSelector } from "@/features/editor/components/bubble-menu/link-sele
import { useTranslation } from "react-i18next";
import { showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { userAtom } from "@/features/user/atoms/current-user-atom";
+import {
+ hasStressAfterSelection,
+ toggleStressAccent,
+} from "./stress-accent";
+
+// Tabler has no acute-accent glyph (IconGrave is a tombstone), so we ship a
+// tiny local icon that mirrors the Tabler icon API ({ style, stroke }).
+function IconStress({
+ style,
+ stroke = 2,
+}: {
+ style?: React.CSSProperties;
+ stroke?: string | number;
+}) {
+ return (
+
+ );
+}
export interface BubbleMenuItem {
name: string;
isActive: () => boolean;
command: () => void;
- icon: typeof IconBold;
+ // Rendered as , so the real contract is
+ // just { style?, stroke? }. stroke is string|number to match Tabler's own prop
+ // type; Tabler icons and the local IconStress both satisfy it (no cast needed).
+ icon: ComponentType<{ style?: CSSProperties; stroke?: string | number }>;
}
type EditorBubbleMenuProps = Omit & {
@@ -77,6 +118,8 @@ export const EditorBubbleMenu: FC = (props) => {
isCode: ctx.editor.isActive("code"),
isComment: ctx.editor.isActive("comment"),
isSpoiler: ctx.editor.isActive("spoiler"),
+ // A stress accent already sits right after the selection end.
+ isStress: hasStressAfterSelection(ctx.editor.state),
};
},
});
@@ -118,6 +161,18 @@ export const EditorBubbleMenu: FC = (props) => {
command: () => props.editor.chain().focus().toggleSpoiler().run(),
icon: IconEyeOff,
},
+ {
+ name: "Stress",
+ isActive: () => editorState?.isStress,
+ // Toggle the U+0301 combining accent right after the selected letter.
+ // The whole toggle is a single transaction, so one Ctrl+Z reverts it.
+ command: () => {
+ const editor = props.editor;
+ editor.view.dispatch(toggleStressAccent(editor.state));
+ editor.view.focus();
+ },
+ icon: IconStress,
+ },
{
name: "Clear formatting",
// Action, not a toggle — never show an active/highlighted state.
diff --git a/apps/client/src/features/editor/components/bubble-menu/stress-accent.test.ts b/apps/client/src/features/editor/components/bubble-menu/stress-accent.test.ts
new file mode 100644
index 00000000..db6203a0
--- /dev/null
+++ b/apps/client/src/features/editor/components/bubble-menu/stress-accent.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it } from "vitest";
+import { Schema } from "@tiptap/pm/model";
+import { EditorState, TextSelection } from "@tiptap/pm/state";
+import {
+ STRESS_ACCENT,
+ hasStressAfterSelection,
+ toggleStressAccent,
+} from "./stress-accent";
+
+// Minimal ProseMirror schema: paragraph of text with a single `bold` mark.
+const schema = new Schema({
+ nodes: {
+ doc: { content: "block+" },
+ paragraph: {
+ group: "block",
+ content: "text*",
+ toDOM: () => ["p", 0],
+ },
+ text: { group: "inline" },
+ },
+ marks: {
+ bold: { toDOM: () => ["strong", 0] },
+ },
+});
+
+function makeState(
+ text: string,
+ from: number,
+ to: number,
+ marked = false,
+): EditorState {
+ const marks = marked ? [schema.marks.bold.create()] : [];
+ const textNode = schema.text(text, marks);
+ const doc = schema.node("doc", null, [
+ schema.node("paragraph", null, [textNode]),
+ ]);
+ const state = EditorState.create({ schema, doc });
+ return state.apply(
+ state.tr.setSelection(TextSelection.create(state.doc, from, to)),
+ );
+}
+
+describe("stress-accent", () => {
+ it("uses U+0301 as the combining accent", () => {
+ expect(STRESS_ACCENT).toHaveLength(1);
+ expect(STRESS_ACCENT.codePointAt(0)).toBe(0x0301);
+ });
+
+ it("inserts the accent right after the selected vowel", () => {
+ // "кот", select "о" (positions 2..3).
+ const state = makeState("кот", 2, 3);
+ expect(hasStressAfterSelection(state)).toBe(false);
+
+ const next = state.apply(toggleStressAccent(state));
+ expect(next.doc.textContent).toBe(`ко${STRESS_ACCENT}т`);
+ // Selection is preserved on the letter, so the button reads active.
+ expect(next.selection.from).toBe(2);
+ expect(next.selection.to).toBe(3);
+ expect(hasStressAfterSelection(next)).toBe(true);
+ });
+
+ it("removes the accent on a second toggle (round-trips to original)", () => {
+ const state = makeState("кот", 2, 3);
+ const inserted = state.apply(toggleStressAccent(state));
+ const removed = inserted.apply(toggleStressAccent(inserted));
+
+ expect(removed.doc.textContent).toBe("кот");
+ expect(hasStressAfterSelection(removed)).toBe(false);
+ expect(removed.selection.from).toBe(2);
+ expect(removed.selection.to).toBe(3);
+ });
+
+ it("inherits the letter's marks so the accent stays bold", () => {
+ // Whole word is bold; select "о".
+ const state = makeState("кот", 2, 3, true);
+ const next = state.apply(toggleStressAccent(state));
+
+ // The accent lands at positions 3..4 (right after "о")...
+ expect(next.doc.textBetween(3, 4)).toBe(STRESS_ACCENT);
+ // ...inside a bold text node, so it inherits the letter's bold mark.
+ const accentNode = next.doc.nodeAt(3);
+ expect(accentNode?.marks.some((m) => m.type.name === "bold")).toBe(true);
+ });
+
+ it("handles a selection at the end of the doc without throwing", () => {
+ // "а" is the whole paragraph; select it (1..2), end of content.
+ const state = makeState("а", 1, 2);
+ expect(hasStressAfterSelection(state)).toBe(false);
+
+ const next = state.apply(toggleStressAccent(state));
+ expect(next.doc.textContent).toBe(`а${STRESS_ACCENT}`);
+ expect(hasStressAfterSelection(next)).toBe(true);
+ });
+});
diff --git a/apps/client/src/features/editor/components/bubble-menu/stress-accent.ts b/apps/client/src/features/editor/components/bubble-menu/stress-accent.ts
new file mode 100644
index 00000000..b8e76a32
--- /dev/null
+++ b/apps/client/src/features/editor/components/bubble-menu/stress-accent.ts
@@ -0,0 +1,41 @@
+import { EditorState, TextSelection, Transaction } from "@tiptap/pm/state";
+
+// U+0301 COMBINING ACUTE ACCENT — a plain Unicode combining char inserted
+// right after a vowel to render a Russian-style stress accent over it.
+// It is stored as literal text (not a TipTap mark), so it survives HTML/
+// Markdown export, full-text search and public share with zero server or
+// converter changes.
+export const STRESS_ACCENT = "́";
+
+// True when a stress accent already sits immediately after the selection end
+// (the single char following the selection). Used both for the toolbar
+// active state and to decide the toggle direction.
+export function hasStressAfterSelection(state: EditorState): boolean {
+ const { to } = state.selection;
+ const docSize = state.doc.content.size;
+ // Clamp to the doc size so a selection at the very end never reads past it.
+ const afterChar = state.doc.textBetween(to, Math.min(to + 1, docSize));
+ return afterChar === STRESS_ACCENT;
+}
+
+// Build a single transaction that toggles the stress accent after the
+// selection. One transaction => one undo step (Ctrl+Z reverts the toggle).
+export function toggleStressAccent(state: EditorState): Transaction {
+ const { from, to } = state.selection;
+ const tr = state.tr;
+
+ if (hasStressAfterSelection(state)) {
+ // Toggle off: drop the accent that immediately follows the letter.
+ tr.delete(to, to + 1);
+ } else {
+ // Toggle on: insertText inherits the marks at `to`, so the accent lands
+ // in the same text node as the letter and renders over it even when the
+ // letter is bold / italic / colored.
+ tr.insertText(STRESS_ACCENT, to);
+ }
+
+ // Restore the original selection so the accented letter stays highlighted
+ // and a re-click toggles the accent back off.
+ tr.setSelection(TextSelection.create(tr.doc, from, to));
+ return tr;
+}