Compare commits

..

2 Commits

Author SHA1 Message Date
agent_coder
0bdc9f98f5 refactor(editor): widen BubbleMenuItem.icon type, drop IconStress cast (#270 review F1)
Icons are rendered only as <item.icon style={...} stroke={2} />, 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) <noreply@anthropic.com>
2026-07-02 03:06:23 +03:00
agent_coder
23c80f727a 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>
2026-07-02 01:28:39 +03:00
9 changed files with 195 additions and 89 deletions

View File

@@ -12,13 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Place several images side by side in a row.** A new "Inline (side by
side)" alignment mode in the image bubble menu renders consecutive inline
images as a row that wraps onto the next line on narrow screens. Unlike the
float modes, text does not wrap around inline images. The mode round-trips
losslessly through markdown as `data-align`, like the other alignment
values.
- **Editable captions for images.** Images gain an optional caption shown
below them, edited inline from the image bubble menu and stored as a `caption` attribute. Captions round-trip
losslessly through markdown as a `data-caption` attribute on the image, so

View File

@@ -356,6 +356,7 @@
"Strike": "Strike",
"Code": "Code",
"Spoiler": "Spoiler",
"Stress": "Stress",
"Comment": "Comment",
"Text": "Text",
"Heading 1": "Heading 1",
@@ -1322,7 +1323,6 @@
"Move to space": "Move to space",
"Float left (wrap text)": "Float left (wrap text)",
"Float right (wrap text)": "Float right (wrap text)",
"Inline (side by side)": "Inline (side by side)",
"Switch to tree": "Switch to tree",
"Switch to flat list": "Switch to flat list",
"Toggle subpages display mode": "Toggle subpages display mode",

View File

@@ -352,6 +352,7 @@
"Strike": "Перечёркнутый",
"Code": "Код",
"Spoiler": "Спойлер",
"Stress": "Ударение",
"Comment": "Комментарий",
"Text": "Текст",
"Heading 1": "Заголовок 1",
@@ -1175,7 +1176,6 @@
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
"Float left (wrap text)": "Обтекание слева",
"Float right (wrap text)": "Обтекание справа",
"Inline (side by side)": "В ряд",
"Switch to tree": "Переключить на дерево",
"Switch to flat list": "Переключить на плоский список",
"Toggle subpages display mode": "Переключить режим отображения подстраниц",

View File

@@ -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 (
<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 {
name: string;
isActive: () => boolean;
command: () => void;
icon: typeof IconBold;
// Rendered as <item.icon style={...} stroke={2} />, 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<BubbleMenuProps, "children" | "editor"> & {
@@ -77,6 +118,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (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<EditorBubbleMenuProps> = (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.

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -15,7 +15,6 @@ import {
IconLayoutAlignRight,
IconFloatLeft,
IconFloatRight,
IconLayoutColumns,
IconDownload,
IconRefresh,
IconTrash,
@@ -47,7 +46,6 @@ export function ImageMenu({ editor }: EditorMenuProps) {
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
isFloatLeft: ctx.editor.isActive("image", { align: "floatLeft" }),
isFloatRight: ctx.editor.isActive("image", { align: "floatRight" }),
isInline: ctx.editor.isActive("image", { align: "inline" }),
src: imageAttrs?.src || null,
alt: imageAttrs?.alt || "",
caption: imageAttrs?.caption || "",
@@ -128,14 +126,6 @@ export function ImageMenu({ editor }: EditorMenuProps) {
.run();
}, [editor]);
const alignImageInline = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setImageAlign("inline")
.run();
}, [editor]);
const handleDownload = useCallback(() => {
if (!editorState?.src) return;
const url = getFileUrl(editorState.src);
@@ -269,18 +259,6 @@ export function ImageMenu({ editor }: EditorMenuProps) {
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Inline (side by side)")} withinPortal={false}>
<ActionIcon
onClick={alignImageInline}
size="lg"
aria-label={t("Inline (side by side)")}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isInline })}
>
<IconLayoutColumns size={18} />
</ActionIcon>
</Tooltip>
<div className={classes.divider} />
{altTextButton}

View File

@@ -63,38 +63,6 @@ describe("applyAlignment", () => {
expect(el.dataset.imageAlign).toBe("center");
});
it("inline -> inline-block + top alignment + gap padding, no float", () => {
applyAlignment(el, "inline");
expect(el.style.display).toBe("inline-block");
expect(el.style.verticalAlign).toBe("top");
expect(el.style.padding).toBe("0px 10px 10px 0px");
expect(el.dataset.imageAlign).toBe("inline");
expect(el.style.cssFloat).toBe("");
});
it("clears inline-block when switching inline -> center (reset-then-apply)", () => {
applyAlignment(el, "inline");
expect(el.style.display).toBe("inline-block");
// Switching back to a flex alignment must replace the inline-block
// override with the constructor-style flex, not just clear it.
applyAlignment(el, "center");
expect(el.style.display).toBe("flex");
expect(el.style.verticalAlign).toBe("");
expect(el.style.padding).toBe("");
expect(el.dataset.imageAlign).toBe("center");
expect(el.style.justifyContent).toBe("center");
});
it("clears a previous float when switching floatLeft -> inline", () => {
applyAlignment(el, "floatLeft");
expect(el.style.cssFloat).toBe("left");
applyAlignment(el, "inline");
expect(el.style.cssFloat).toBe("");
expect(el.style.display).toBe("inline-block");
expect(el.style.verticalAlign).toBe("top");
expect(el.dataset.imageAlign).toBe("inline");
});
it("clears a previous float when switching floatLeft -> left (reset-then-apply)", () => {
applyAlignment(el, "floatLeft");
expect(el.style.cssFloat).toBe("left");

View File

@@ -53,13 +53,7 @@ declare module "@tiptap/core" {
attributes: ImageAttributes & { pos: number | Range },
) => ReturnType;
setImageAlign: (
align:
| "left"
| "center"
| "right"
| "floatLeft"
| "floatRight"
| "inline",
align: "left" | "center" | "right" | "floatLeft" | "floatRight",
) => ReturnType;
setImageWidth: (width: number) => ReturnType;
setImageSize: (width: number, height: number) => ReturnType;
@@ -421,14 +415,6 @@ export function applyAlignment(container: HTMLElement, align: string) {
// (a previous float must not leak into a later left/center/right).
container.style.cssFloat = "";
container.style.padding = "";
// The ResizableNodeView constructor sets an inline `display: flex` on the
// container; the inline mode overrides it with `inline-block`, so the reset
// restores the constructor's flex here. This keeps the container's layout
// independent of any app-level CSS class (which also happens to set flex)
// and makes non-inline modes carry exactly the same inline styles as before
// the inline mode existed.
container.style.display = "flex";
container.style.verticalAlign = "";
// Mirror the resolved alignment onto the CONTAINER as a data attribute so the
// responsive stylesheet can neutralize the float on small screens (an inline
// `float` can only be overridden by `!important`, which keys off this attr).
@@ -444,15 +430,6 @@ export function applyAlignment(container: HTMLElement, align: string) {
container.style.cssFloat = "right";
container.style.padding = "0 0 0 10px";
container.style.justifyContent = "flex-end";
} else if (align === "inline") {
// Consecutive inline images sit side by side on one line box and wrap to
// the next line when the viewport is narrow. The right/bottom padding
// provides the gap between images in a row and between wrapped rows;
// vertical-align: top keeps rows of different-height images aligned by
// their top edge.
container.style.display = "inline-block";
container.style.verticalAlign = "top";
container.style.padding = "0 10px 10px 0";
} else if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {