feat(editor): add stress-accent (U+0301) toggle button to the bubble menu (closes #270)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -356,6 +356,7 @@
|
|||||||
"Strike": "Strike",
|
"Strike": "Strike",
|
||||||
"Code": "Code",
|
"Code": "Code",
|
||||||
"Spoiler": "Spoiler",
|
"Spoiler": "Spoiler",
|
||||||
|
"Stress": "Stress",
|
||||||
"Comment": "Comment",
|
"Comment": "Comment",
|
||||||
"Text": "Text",
|
"Text": "Text",
|
||||||
"Heading 1": "Heading 1",
|
"Heading 1": "Heading 1",
|
||||||
|
|||||||
@@ -352,6 +352,7 @@
|
|||||||
"Strike": "Перечёркнутый",
|
"Strike": "Перечёркнутый",
|
||||||
"Code": "Код",
|
"Code": "Код",
|
||||||
"Spoiler": "Спойлер",
|
"Spoiler": "Спойлер",
|
||||||
|
"Stress": "Ударение",
|
||||||
"Comment": "Комментарий",
|
"Comment": "Комментарий",
|
||||||
"Text": "Текст",
|
"Text": "Текст",
|
||||||
"Heading 1": "Заголовок 1",
|
"Heading 1": "Заголовок 1",
|
||||||
|
|||||||
@@ -29,6 +29,37 @@ import { LinkSelector } from "@/features/editor/components/bubble-menu/link-sele
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
import { showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
||||||
import { userAtom } from "@/features/user/atoms/current-user-atom";
|
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 (
|
||||||
|
<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" />
|
||||||
|
<path d="M7.5 14h5" />
|
||||||
|
<path d="M13 5l4 -3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface BubbleMenuItem {
|
export interface BubbleMenuItem {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -77,6 +108,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
isCode: ctx.editor.isActive("code"),
|
isCode: ctx.editor.isActive("code"),
|
||||||
isComment: ctx.editor.isActive("comment"),
|
isComment: ctx.editor.isActive("comment"),
|
||||||
isSpoiler: ctx.editor.isActive("spoiler"),
|
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<EditorBubbleMenuProps> = (props) => {
|
|||||||
command: () => props.editor.chain().focus().toggleSpoiler().run(),
|
command: () => props.editor.chain().focus().toggleSpoiler().run(),
|
||||||
icon: IconEyeOff,
|
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",
|
name: "Clear formatting",
|
||||||
// Action, not a toggle — never show an active/highlighted state.
|
// Action, not a toggle — never show an active/highlighted state.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user