From 23c80f727a7206997455ff12b9de9a564ab84f4f Mon Sep 17 00:00:00 2001 From: agent_coder Date: Thu, 2 Jul 2026 01:28:39 +0300 Subject: [PATCH 1/2] feat(editor): add stress-accent (U+0301) toggle button to the bubble menu (closes #270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Select a vowel and one click places a combining acute accent over it; clicking again removes it (toggle). Inserts the literal Unicode char U+0301 right after the letter — plain text, not a custom TipTap mark — so it survives HTML/Markdown export, full-text search and public share with zero server/converter changes. Insert/remove is a single transaction (one Ctrl+Z), inherits the letter's marks (bold/italic/color), and restores the original selection so the active state toggles correctly. Editable bubble menu only. New pure helper stress-accent.ts (+ 5 unit tests). i18n: en 'Stress' / ru 'Ударение'. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../public/locales/en-US/translation.json | 1 + .../public/locales/ru-RU/translation.json | 1 + .../components/bubble-menu/bubble-menu.tsx | 47 ++++++++++ .../bubble-menu/stress-accent.test.ts | 94 +++++++++++++++++++ .../components/bubble-menu/stress-accent.ts | 41 ++++++++ 5 files changed, 184 insertions(+) create mode 100644 apps/client/src/features/editor/components/bubble-menu/stress-accent.test.ts create mode 100644 apps/client/src/features/editor/components/bubble-menu/stress-accent.ts 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..d3010831 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 @@ -29,6 +29,37 @@ 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?: number; +}) { + return ( + + + + + + ); +} export interface BubbleMenuItem { name: string; @@ -77,6 +108,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 +151,20 @@ 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(); + }, + // Local SVG icon; cast to the Tabler icon type used by the other items. + // It renders with the same { style, stroke } props they are given. + icon: IconStress as unknown as typeof IconBold, + }, { 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; +} From 0bdc9f98f5a27bbc432fcfb48575d2d4f1d8ba58 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Thu, 2 Jul 2026 03:06:23 +0300 Subject: [PATCH 2/2] refactor(editor): widen BubbleMenuItem.icon type, drop IconStress cast (#270 review F1) Icons are rendered only as , so type the field as ComponentType<{ style?; stroke? }> instead of typeof IconBold. stroke is string|number to match Tabler's own prop type, so Tabler icons and the local IconStress both satisfy it without the 'as unknown as' cast. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/bubble-menu/bubble-menu.tsx | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) 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 d3010831..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, @@ -41,7 +48,7 @@ function IconStress({ stroke = 2, }: { style?: React.CSSProperties; - stroke?: number; + stroke?: string | number; }) { return ( 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 & { @@ -161,9 +171,7 @@ export const EditorBubbleMenu: FC = (props) => { editor.view.dispatch(toggleStressAccent(editor.state)); editor.view.focus(); }, - // Local SVG icon; cast to the Tabler icon type used by the other items. - // It renders with the same { style, stroke } props they are given. - icon: IconStress as unknown as typeof IconBold, + icon: IconStress, }, { name: "Clear formatting",