Compare commits

...

14 Commits

Author SHA1 Message Date
claude_code c90caeb21a docs: update changelog and readme with new feature details
Add recent feature entries to CHANGELOG, including inline image row centering, AI chat docking, comment hover tooltips, temporary‑note trash button, code‑block overlay controls, stress‑accent button, reading‑position restore, and slash‑menu layout fixes. Update README and Russian README to reflect these changes.
2026-07-02 14:53:53 +03:00
claude_code 5664da57ad feat(editor): center inline image rows by default via CSS :has()
Follow-up to #284: rows of inline-aligned images were pinned left while
a single image defaults to centered — inconsistent. A row has no DOM
wrapper (each image is an independent block node), so its placement is
controlled by the text-align of the nearest block ancestor.

- media.css: enable text-align:center only on containers that actually
  hold a direct inline-image child (:has), and reset every other child
  back to text-align:start so ordinary text is unaffected; explicit
  per-block toolbar alignment (inline style) still wins; browsers
  without :has() keep the previous start-pinned rows
- image.ts: comment in the inline branch now points to the media.css
  rule (cross-package discoverability), no code change

Reviewed: math/caption/table-header/footnote text-align rules audited;
React node views are wrapped in .react-renderer, so .mathBlock is not a
direct child and keeps its own centering (verified in happy-dom).
2026-07-02 14:51:50 +03:00
vvzvlad 3a5794894e Merge pull request 'feat(editor): inline image alignment — place several images side by side' (#284) from image-inline-row into develop
Reviewed-on: #284
2026-07-02 14:12:05 +03:00
vvzvlad 8d745352d1 Merge pull request 'fix(editor): вернуть фокус редактора после закрытия меню таблицы (Ctrl+Z undo)' (#279) from fix/269-table-menu-refocus into develop
Reviewed-on: #279
2026-07-02 14:11:55 +03:00
vvzvlad f0a69abd0f Merge pull request 'feat(editor): кнопка «Ударение» (U+0301) в bubble-меню' (#280) from feat/270-stress-accent into develop
Reviewed-on: #280
2026-07-02 14:11:38 +03:00
vvzvlad f8c4343fa8 Merge pull request 'fix(editor): короткие wrong-layout префиксы матчатся по заголовку (#283)' (#287) from fix/283-short-remap-title into develop
Reviewed-on: #287
2026-07-02 14:11:25 +03:00
vvzvlad 4d0f791471 Merge pull request 'feat(ai-chat): закрепление окна чата в боковом меню (dock)' (#282) from feat/276-ai-chat-dock into develop
Reviewed-on: #282
2026-07-02 14:10:46 +03:00
claude_code 20032be921 feat(editor): inline image alignment — place several images side by side
Add a new value "inline" to the image align attribute (alongside
left/center/right/floatLeft/floatRight). Inline images render as
inline-block containers, so consecutive ones form a row that wraps
naturally on narrow viewports; unlike the float modes, text does not
wrap around them.

- applyAlignment: reset-then-apply extended to display/vertical-align;
  the reset restores the constructor's inline display:flex so non-inline
  modes keep byte-identical styles and editor-ext stays independent of
  the client CSS class
- image bubble menu: new "Inline (side by side)" button (IconLayoutColumns)
  with active state, mirroring the float buttons
- i18n: key registered in en-US and ru-RU ("В ряд"), like the float labels
- tests: 3 new applyAlignment specs (apply, reset on switch-away, float->inline)
- no schema/MCP/markdown changes needed: align round-trips as data-align
2026-07-02 04:22:25 +03:00
agent_coder c16942777d test(ai-chat): extract+test navbar-visibility predicate; dock label on useDock (#276 review F1/F2)
F1: extract the navbar-visibility crux (width/height 0 or right<=0 -> hidden)
from getNavbarRect into a pure isNavbarRectVisible in dock-helpers.ts + 3 tests;
getNavbarRect calls it (identical null cases).
F2: base the dock/undock button's label/icon/title on the effective useDock state
(docked && dockRect present) rather than the raw docked flag, so a docked window
that fell back to floating (collapsed sidebar) doesn't show 'Undock'. Toggle
action unchanged; no remount.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 03:09:03 +03:00
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 6e70c7bd6a test(editor): cover refocusEditorAfterMenuClose guard (#269 review F1)
Unit-test the focus-restore guard: an external <input> active -> editor.view.focus
NOT called (deliberate move respected); a non-focusable element active -> focus
called once. Fake editor + fake timers (rAF via setTimeout stub); view.focus is a
spy. Regression lock for the guard that keeps focus out of the page-title input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 02:02:44 +03:00
agent_coder 85b303e387 feat(ai-chat): dock the floating chat window into the sidebar (closes #276)
Drag the floating AI-chat window onto the sidebar and release over it to DOCK it
— the window pins to the live navbar rect, overlaying the page tree; a drop-zone
highlight shows while dragging over it. Closing the chat re-shows the tree.
Undock via a header button or by dragging the docked window back onto content
(pops out floating at the drop point). The docked/floating mode persists in
localStorage and the docked window follows the navbar width (manual resize,
space<->shared route change) via a ResizeObserver + sidebar-toggle/transitionend
re-sync; when the navbar is collapsed/absent the window falls back to floating
instead of vanishing. Dock/undock only flips a mode atom + geometry — ChatThread
is never remounted, so an in-flight response stream is not interrupted.
Frontend only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 01:57:00 +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
agent_coder 2b36997c63 fix(editor): restore editor focus after table menu closes so Ctrl+Z works (closes #269)
The row/column grip and cell-chevron menus are Mantine <Menu>s with
returnFocus:true whose targets live outside the editor's contenteditable. After
a menu action focus returns to that outside target, so ProseMirror's undo keymap
never sees Ctrl+Z until the user clicks back into a cell. Add
refocusEditorAfterMenuClose(editor): on the next frame (after Mantine's
returnFocus) restore editor focus via view.focus(), unless the user intentionally
moved to another input/editable. Wired into both onClose paths (the shared
row/column lifecycle hook + cell-chevron).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 01:27:21 +03:00
22 changed files with 922 additions and 34 deletions
+75
View File
@@ -12,6 +12,15 @@ 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. The row is
centered horizontally by default in modern browsers (CSS `:has()`), falling
back to start-aligned rows in browsers without support. 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
@@ -77,6 +86,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
with the `||text||` input rule; the rendered span blurs until clicked to reveal.
The mark is preserved losslessly through Markdown export/import (as a raw
`<span data-spoiler="true">…</span>`) and on public shares. (#259)
- **Dock the AI chat window into the side menu.** The floating chat window can
be pinned to the sidebar — drag it onto the navbar (a drop-zone highlight
shows where it lands) or use the new "Dock to sidebar" header button; while
docked it fills the sidebar area and follows its live size. "Undock" (or
dragging it back out) restores the floating window, a collapsed/absent
sidebar falls back to floating, and the docked state survives a reload.
(#276, #282)
- **Hovering commented text shows the comment thread in a tooltip.** Pointing
at a highlighted comment mark pops a small card with the author and plain
text of the root comment and its replies, so a thread can be skimmed without
opening the side panel. The card appears after a short delay (no flicker on a
passing glance), skips resolved and text-less threads, and dismisses on
scroll or click — clicking a mark still opens the comments panel. (#268,
#271)
- **"Move to trash" button in the temporary-note banner.** Besides "Make
permanent", the banner on an open temporary note now also offers to trash the
note immediately instead of waiting out its lifetime. It reuses the regular
soft-delete path, so the "Page moved to trash" undo toast is the safety net —
no confirmation dialog. (#273, #277)
- **Code-block controls float as an overlay instead of taking a row above the
code.** The language selector and copy button now sit in the block's top-right
corner, and the selector stays invisible until the block is hovered or the
selector is focused, so reading code is chrome-free. In read-only views only
the copy button renders. (#275, #278)
- **The AI agent is told about your page edits between turns.** The server
snapshots the open page's Markdown at the end of every agent turn and, on the
next turn, injects a unified diff of what changed in between, so the agent
knows its earlier copy of the page is stale and builds on the user's edits
instead of reverting or overwriting them. The diff is whitespace-normalized
(pure formatting churn injects nothing) and size-capped, with a hint to
re-read the full page via `getPage` when truncated. (#274, #281)
- **Stress-accent button (U+0301) in the bubble menu.** Select a vowel and
toggle a combining acute accent over it — a Russian-style stress mark. The
accent is stored as plain text (no custom mark), so it survives Markdown/HTML
export, full-text search and public shares unchanged; the toggle is a single
undo step and re-clicking removes the accent. (#270, #280)
- **Reading position survives a reload.** The editor remembers how far you
scrolled in each page (per tab, in `sessionStorage`) and restores that
position after an F5 or reopening the document, waiting for the collaborative
content to finish laying out first. A URL `#hash` anchor still wins — restore
is a no-op then. (#266, #267)
- **The slash menu finds commands typed in the wrong keyboard layout.** A query
typed with the wrong layout active (e.g. `/сщву` for `/code`, or `/cyjcrf`
for the Cyrillic «сноска» → Footnote) is additionally remapped ЙЦУКЕН↔QWERTY
by physical key position and matched against the commands; genuine Cyrillic
search terms keep priority over remapped candidates, and short wrong-layout
prefixes match by command title. (#283, #285, #287)
### Changed
@@ -142,6 +198,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
emits a single-use "intentional clear" signal that lets exactly that one empty
write through the guard, so genuinely emptying a page is persisted while
accidental empties are blocked. (#248, #251)
- **Ctrl+Z works again right after using a table menu.** Closing a table
row/column menu (grip or chevron) left focus on the menu's portaled target
outside the editor, so undo keystrokes went nowhere until you clicked back
into a cell. The editor is now refocused after the menu closes — unless you
deliberately moved focus to another input or editable (e.g. the page title).
(#269, #279)
- **The AI reindex progress counter no longer freezes at 0.** Right after
"Reindex now" the client could read the stale pre-reindex snapshot of an
already-indexed workspace (`reindexing=false`, all pages counted) as
"finished" and stop polling on the very first tick, leaving the counter
frozen until a manual reload. Polling now keeps going until it has actually
observed the active run. (#262, #264)
- **An MCP edit can no longer be silently lost to a duplicate collab document.**
When the agent addressed a page by its short slugId, the MCP opened a
collaboration document named after that slugId while the web editor always
uses the page's canonical UUID — two independent live documents for one page,
whose debounced stores clobbered each other. The MCP now resolves every page
id to the canonical UUID before opening the collab doc (a UUID input
short-circuits locally; a slugId is resolved once and cached). (#260, #265)
### Security
+6 -3
View File
@@ -104,7 +104,7 @@ community feature, with no enterprise license. Open it from the page header; the
-**Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks).
-**Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle.
-**Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP.
-**Temporary notes**mark a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview, or the space sidebar, with a "Make permanent" rescue banner on the open note.
-**Temporary notes**create a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview.
### In progress
@@ -187,14 +187,17 @@ start the new migrations apply on top of your existing schema (`CREATE EXTENSION
- Spaces
- Permissions management
- Groups
- Comments (with resolve / re-open)
- Comments (with resolve / re-open and hover tooltips showing the comment text)
- Page history
- Search
- File attachments
- Embeds (Airtable, Loom, Miro and more)
- Translations (10+ languages)
- Embedded MCP server (`/mcp`)
- AI agent chat over your wiki (read + write, RAG search, external MCP / web access)
- AI agent chat over your wiki (read + write, RAG search, external MCP / web access); the chat window docks into the side menu, and the agent is told about your in-page edits between turns
- Code-block buttons as an overlay, with the language selector revealed on hover
- Stress-accent button (U+0301) in the bubble menu
- Reading scroll position restored on reload
### Screenshots
+7 -3
View File
@@ -105,7 +105,7 @@ real-time-коллаборации Docmost, поэтому запись нико
-**Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
-**AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
-**Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
-**Временные заметки**пометьте заметку временной, и она автоматически уедет в корзину по истечении настраиваемого срока жизни воркспейса (по умолчанию 24 ч), если её предварительно не сделать постоянной; создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства, а на открытой заметке есть баннер «Сделать постоянной».
-**Временные заметки**создайте временную заметку, и она автоматически уедет в корзину по истечении настраиваемого срока жизни (по умолчанию 24 ч); создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства.
### В процессе
@@ -174,14 +174,18 @@ dump/restore, существующий каталог данных переис
- Пространства (Spaces)
- Управление правами доступа
- Группы
- Комментарии (с резолвом / переоткрытием)
- Комментарии (с резолвом / переоткрытием и всплывающими подсказками с текстом комментария при наведении)
- История страниц
- Поиск
- Вложения файлов
- Встраивания (Airtable, Loom, Miro и другие)
- Переводы (10+ языков)
- Встроенный MCP-сервер (`/mcp`)
- Чат с AI-агентом по вики (чтение + запись, RAG-поиск, внешние MCP / доступ в интернет)
- Чат с AI-агентом по вики (чтение + запись, RAG-поиск, внешние MCP / доступ в интернет); окно чата закрепляется в боковом меню, а агент узнаёт о ваших правках страницы между ходами
- Кнопки код-блока оверлеем, селектор языка появляется при наведении
- Кнопка «Ударение» (U+0301) в bubble-меню
- Позиция чтения (прокрутка) восстанавливается после перезагрузки
- Slash-меню терпимо к неправильной раскладке (ЙЦУКЕН↔QWERTY)
### Скриншоты
@@ -257,6 +257,8 @@
"Copy": "Copy",
"Copy to space": "Copy to space",
"Copy chat": "Copy chat",
"Dock to sidebar": "Dock to sidebar",
"Undock": "Undock",
"Copied": "Copied",
"Failed to export chat": "Failed to export chat",
"Duplicate": "Duplicate",
@@ -356,6 +358,7 @@
"Strike": "Strike",
"Code": "Code",
"Spoiler": "Spoiler",
"Stress": "Stress",
"Comment": "Comment",
"Text": "Text",
"Heading 1": "Heading 1",
@@ -1322,6 +1325,7 @@
"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",
@@ -352,6 +352,7 @@
"Strike": "Перечёркнутый",
"Code": "Код",
"Spoiler": "Спойлер",
"Stress": "Ударение",
"Comment": "Комментарий",
"Text": "Текст",
"Heading 1": "Заголовок 1",
@@ -715,6 +716,8 @@
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
"Ask the AI agent…": "Спросите AI-агента…",
"Copy chat": "Копировать чат",
"Dock to sidebar": "Закрепить в боковой панели",
"Undock": "Открепить",
"Created successfully": "Успешно создано",
"Context size / model limit": "Размер контекста / лимит модели",
"Context window (tokens)": "Окно контекста (токены)",
@@ -1175,6 +1178,7 @@
"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": "Переключить режим отображения подстраниц",
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
import { useAtom } from "jotai";
import {
APP_NAVBAR_ID,
asideStateAtom,
desktopSidebarAtom,
mobileSidebarAtom,
@@ -106,6 +107,7 @@ export default function GlobalAppShell({
<AppHeader />
</AppShell.Header>
<AppShell.Navbar
id={APP_NAVBAR_ID}
className={classes.navbar}
withBorder={false}
ref={sidebarRef}
@@ -1,6 +1,12 @@
import { atomWithWebStorage } from "@/lib/jotai-helper.ts";
import { atom } from "jotai";
// Stable DOM id set on the app-shell navbar (<AppShell.Navbar>). Declared here —
// alongside the sidebar atoms — rather than in the chat window so the AI chat
// window can reference the navbar by id without importing the app shell (which
// would create a shell -> chat-window -> shell import cycle).
export const APP_NAVBAR_ID = "app-shell-navbar";
export const mobileSidebarAtom = atom<boolean>(false);
export const desktopSidebarAtom = atomWithWebStorage<boolean>(
@@ -18,6 +18,18 @@ export const aiChatWindowGeomAtom = atomWithStorage<AiChatWindowGeom | null>(
null,
);
/**
* Whether the AI chat window is docked into the sidebar (page-tree navbar).
* Persisted to localStorage so the docked/floating mode survives a full page
* reload and close/reopen. `false` = the default floating window. When docked,
* the SAME window instance pins itself to the live bounding rect of the app
* navbar (see AiChatWindow), overlaying the page tree.
*/
export const aiChatWindowDockedAtom = atomWithStorage<boolean>(
"ai-chat-window-docked",
false,
);
/**
* The currently selected chat id. `null` means a fresh (not-yet-created) chat:
* the server creates the chat row on the first streamed message and echoes its
@@ -35,6 +35,35 @@
background: transparent;
}
/* Docked into the sidebar: the window pins itself to the live navbar rect
(position/size supplied inline). It sits flush inside the navbar area, so we
drop the floating chrome — no border-radius, drop shadow or user resize — and
remove the floating min/max clamps so the size is driven ENTIRELY by the
inline navbar rect (which may be narrower than the floating min-width of
300px, e.g. the 220px navbar minimum). z-index 105 keeps it above the page
tree (navbar 101) but below the header and Mantine overlays. */
.docked {
border-radius: 0;
box-shadow: none;
resize: none;
min-width: 0;
min-height: 0;
max-width: none;
max-height: none;
}
/* Drop-zone highlight shown over the navbar bounds while a floating window is
dragged onto the sidebar. Sits just above the docked window (106) so the cue
is visible; purely decorative, so it never intercepts pointer events. */
.dockHighlight {
position: fixed;
z-index: 106;
border: 2px dashed light-dark(var(--mantine-color-blue-5), var(--mantine-color-blue-4));
background: light-dark(rgba(34, 139, 230, 0.08), rgba(34, 139, 230, 0.14));
border-radius: var(--mantine-radius-sm);
pointer-events: none;
}
/* When minimized the window collapses to the header only: auto height, no
resize. Width/height inline values are overridden. */
.minimized {
@@ -13,21 +13,29 @@ import {
IconChevronDown,
IconCopy,
IconGripVertical,
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarLeftExpand,
IconMinus,
IconPlus,
IconX,
} from "@tabler/icons-react";
import { useAtom, useSetAtom } from "jotai";
import { useMatch } from "react-router-dom";
import { useLocation, useMatch } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatWindowGeomAtom,
aiChatWindowDockedAtom,
aiChatDraftAtom,
selectedAiRoleIdAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
import {
APP_NAVBAR_ID,
desktopSidebarAtom,
mobileSidebarAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { extractPageSlugId } from "@/lib";
import {
@@ -46,6 +54,11 @@ import {
isHeaderClick,
} from "@/features/ai-chat/utils/collapse-helpers.ts";
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
import {
isPointWithinRect,
isNavbarRectVisible,
type NavbarRect,
} from "@/features/ai-chat/utils/dock-helpers.ts";
import { useClipboard } from "@/hooks/use-clipboard";
import { notifications } from "@mantine/notifications";
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
@@ -112,6 +125,28 @@ function clampGeom(g: {
};
}
// Live bounding rect of the app-shell navbar (the page-tree sidebar), by its
// stable id. Returns null when the navbar is absent OR collapsed: Mantine
// collapses the navbar by translating it off-screen (its right edge lands at or
// left of the viewport), so a zero-size or off-screen rect is treated as "no
// navbar" — the docked window then falls back to floating instead of pinning to
// an off-screen box. Reads the DOM, so call it inside effects / handlers only.
function getNavbarRect(): NavbarRect | null {
const el = document.getElementById(APP_NAVBAR_ID);
if (!el) return null;
const r = el.getBoundingClientRect();
// Off-screen/collapsed navbar (visibility predicate extracted + unit-tested).
if (!isNavbarRectVisible(r)) return null;
return { left: r.left, top: r.top, width: r.width, height: r.height };
}
// Whether a viewport point falls within the (visible) navbar bounds. Used to
// decide dock-on-drop and undock-on-drag-out. The point-in-rect math is the pure
// isPointWithinRect helper (unit-tested); this only supplies the live rect.
function isPointerOverNavbar(x: number, y: number): boolean {
return isPointWithinRect(x, y, getNavbarRect());
}
/**
* Floating, draggable, resizable, minimizable AI chat window. Replaces the
* former right-aside `AiChatPanel`: it owns ALL chat orchestration (active
@@ -138,6 +173,43 @@ export default function AiChatWindow() {
const minimizedRef = useRef(minimized);
minimizedRef.current = minimized;
// Docked-into-sidebar mode (#276). Persisted so it survives reload + reopen.
// When docked the SAME window instance pins itself to the navbar rect below.
const [docked, setDocked] = useAtom(aiChatWindowDockedAtom);
// Mirror for the useCallback([]) drag handlers (same reason as minimizedRef).
const dockedRef = useRef(docked);
dockedRef.current = docked;
// Live navbar rect the docked window is pinned to; synced before paint by the
// layout effect below. null = navbar absent/collapsed -> floating fallback.
const [dockRect, setDockRect] = useState<NavbarRect | null>(null);
// While dragging a FLOATING window over the navbar: show the drop-zone hint.
const [dockHint, setDockHint] = useState(false);
// Live window position during a drag. Normally the drag is fully imperative
// (el.style updated per mousemove, no re-render — matching the pre-#276
// behavior), so this stays null. It is set ONLY at a navbar-boundary crossing:
// that crossing already forces a re-render (dockHint flips), which would
// otherwise re-apply the committed geom and snap the box back for a frame — so
// we hand the render the live position at that instant instead. Cleared on drop.
const [dragPos, setDragPos] = useState<{ left: number; top: number } | null>(
null,
);
// Subscribed (read-only) so this component re-renders — and the dockRect-sync
// effect below re-runs — when the sidebar is collapsed/expanded via the header
// toggle. Mantine collapses the navbar with a transform (width/border-box
// unchanged), so the navbar's ResizeObserver never fires; these deps + the
// navbar `transitionend` listener are what re-measure the rect on toggle.
const [desktopSidebarOpen] = useAtom(desktopSidebarAtom);
const [mobileSidebarOpen] = useAtom(mobileSidebarAtom);
// Dock mode is only EFFECTIVE when a navbar rect is available. When docked but
// the navbar is absent/collapsed (dockRect === null) the window falls back to
// the floating look, so effects gated on "is docked" must use this — not the
// raw `docked` flag — or a fallback-floating window would behave half-docked.
const useDock = docked && dockRect !== null;
const location = useLocation();
const winRef = useRef<HTMLDivElement>(null);
// Live window geometry (position + size); persisted to localStorage so a
// drag/resize survives a full page reload (and close/reopen). `null` means
@@ -325,6 +397,47 @@ export default function AiChatWindow() {
setMinimized(false);
}, [windowOpen]);
// While docked, keep the window pinned to the navbar's LIVE rect. useLayoutEffect
// (not useEffect) so dockRect is measured/committed before the browser paints,
// avoiding a first-frame jump. Re-measures on: navbar size changes (manual
// sidebar resize -> ResizeObserver), viewport resize (window `resize`), and
// route changes that swap the navbar width (space <-> shared/global sidebar are
// 300px vs sidebarWidth -> re-run on location.pathname). If the navbar is
// absent/collapsed, getNavbarRect() returns null and the render falls back to
// the floating look (the window does NOT vanish).
useLayoutEffect(() => {
if (!windowOpen || !docked) return;
const sync = () => setDockRect(getNavbarRect());
sync();
const navbar = document.getElementById(APP_NAVBAR_ID);
let ro: ResizeObserver | null = null;
if (navbar) {
ro = new ResizeObserver(sync);
ro.observe(navbar);
// Collapsing/expanding the sidebar translates the navbar off-screen WITHOUT
// changing its width/border-box, so the ResizeObserver never fires and the
// effect's initial sync() may measure mid-transition (stale). Re-measure at
// transitionend so getNavbarRect() sees the final position: null once the
// navbar is translated off (right <= 0) -> fall back to floating; the real
// rect once it slides back -> re-dock. The sidebar-state deps below force
// this effect (and the immediate sync) to re-run on each toggle, covering
// the reduced-motion case where no transition -> no transitionend.
navbar.addEventListener("transitionend", sync);
}
window.addEventListener("resize", sync);
return () => {
ro?.disconnect();
navbar?.removeEventListener("transitionend", sync);
window.removeEventListener("resize", sync);
};
}, [
windowOpen,
docked,
location.pathname,
desktopSidebarOpen,
mobileSidebarOpen,
]);
// Auto-collapse the window into its header as soon as the user interacts with
// anything outside it (clicks the page/editor). Armed ONLY while the window is
// open and expanded, so it never fires repeatedly and never collapses on the
@@ -333,7 +446,12 @@ export default function AiChatWindow() {
// (shouldCollapseOnOutsidePointer) prevent false collapses from clicks inside
// the window or inside Mantine portals (kebab menu, delete-confirm modal).
useEffect(() => {
if (!windowOpen || minimized) return;
// Disabled while EFFECTIVELY docked: a docked window intentionally overlays
// the page tree, so a click on the surrounding page must NOT auto-collapse
// it. Gated on useDock (not raw `docked`) so a fallback-floating window
// (docked but navbar absent/collapsed) still auto-collapses like a normal
// floating window.
if (!windowOpen || minimized || useDock) return;
const onPointerDown = (e: MouseEvent): void => {
if (shouldCollapseOnOutsidePointer(e.target, winRef.current)) {
setMinimized(true);
@@ -341,13 +459,18 @@ export default function AiChatWindow() {
};
document.addEventListener("mousedown", onPointerDown, true);
return () => document.removeEventListener("mousedown", onPointerDown, true);
}, [windowOpen, minimized]);
}, [windowOpen, minimized, useDock]);
// Persist the user's resize into state so it survives close/reopen. Skipped
// while minimized so the collapsed (auto) height is never captured. The
// equality guard avoids an update loop.
useEffect(() => {
if (!windowOpen || minimized) return;
// Disabled while EFFECTIVELY docked: in dock mode the size is driven by the
// navbar rect, not a user resize, so we must not capture the navbar-sized box
// into the persisted floating geom (it would clobber the remembered floating
// size). Gated on useDock so a fallback-floating window (docked but navbar
// absent) still persists user resizes like a normal floating window.
if (!windowOpen || minimized || useDock) return;
const el = winRef.current;
// `geom` is in the deps so this re-runs once geometry is settled and the
// window is actually rendered (on the first open `geom` is still null on the
@@ -365,18 +488,30 @@ export default function AiChatWindow() {
});
ro.observe(el);
return () => ro.disconnect();
}, [windowOpen, minimized, geom !== null]);
}, [windowOpen, minimized, useDock, geom !== null]);
const startDrag = useCallback((e: React.MouseEvent): void => {
// Ignore drags that originate on a button (minimize/close/new chat).
// Ignore drags that originate on a button (dock/minimize/close/new chat).
if ((e.target as HTMLElement).closest("button")) return;
const el = winRef.current;
if (!el) return;
const sx = e.clientX;
const sy = e.clientY;
// Starting position: the element's current inline left/top, whether it was
// placed by the floating geom or pinned to the navbar rect (both render as
// "<n>px"). getBoundingClientRect would work too, but the inline values keep
// the drag math identical to the pre-#276 floating behavior.
const ol = parseFloat(el.style.left) || 0;
const ot = parseFloat(el.style.top) || 0;
// Freeze the box size for the drag: a docked window keeps its navbar size
// while being pulled out, a floating window keeps its own size.
const dragW = el.offsetWidth;
const dragH = el.offsetHeight;
// Latch for the drop-zone hint so setState fires only when the pointer
// actually crosses the navbar boundary, not on every mousemove.
let overNavbar = false;
const move = (ev: MouseEvent): void => {
let nl = ol + (ev.clientX - sx);
@@ -385,20 +520,58 @@ export default function AiChatWindow() {
// with position: fixed) with an 8px margin.
nl = Math.max(
EDGE_MARGIN,
Math.min(nl, window.innerWidth - el.offsetWidth - EDGE_MARGIN),
Math.min(nl, window.innerWidth - dragW - EDGE_MARGIN),
);
nt = Math.max(
EDGE_MARGIN,
Math.min(nt, window.innerHeight - el.offsetHeight - EDGE_MARGIN),
Math.min(nt, window.innerHeight - dragH - EDGE_MARGIN),
);
el.style.left = `${nl}px`;
el.style.top = `${nt}px`;
// Drop-zone highlight: only meaningful when dragging a FLOATING window in
// to dock it (a docked window is already over the navbar).
if (!dockedRef.current) {
const nowOver = isPointerOverNavbar(ev.clientX, ev.clientY);
if (nowOver !== overNavbar) {
overNavbar = nowOver;
// This re-render would re-apply the committed geom; hand it the live
// position so the box does not snap back for a frame.
setDragPos({ left: nl, top: nt });
setDockHint(nowOver);
}
}
};
const up = (ev: MouseEvent): void => {
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
document.body.style.userSelect = "";
setDragPos(null);
setDockHint(false);
const overNavbarNow = isPointerOverNavbar(ev.clientX, ev.clientY);
if (dockedRef.current) {
// Docked window: releasing OUTSIDE the navbar pops it out as a floating
// window at the drop point (clamped to the viewport). Released over the
// navbar -> stays docked (a header click is a no-op here). The response
// stream is untouched — only the mode flag / geom change.
if (!overNavbarNow) {
const el2 = winRef.current;
const dropLeft = el2 ? parseFloat(el2.style.left) || 0 : 0;
const dropTop = el2 ? parseFloat(el2.style.top) || 0 : 0;
setGeom((prev) =>
clampGeom({
...(prev ?? computeInitialGeom()),
left: dropLeft,
top: dropTop,
}),
);
setDocked(false);
}
return;
}
// Floating window.
// Treat a near-zero-movement press as a click (not a drag). When the
// window is minimized, a header click expands it; nothing to persist
// because the position did not change. minimizedRef avoids the stale
@@ -410,6 +583,13 @@ export default function AiChatWindow() {
setMinimized(false);
return;
}
// Released over the navbar -> dock. The layout effect then pins the window
// to the navbar rect; the last floating geom is left untouched so a later
// undock/close restores the remembered floating placement.
if (overNavbarNow) {
setDocked(true);
return;
}
const el2 = winRef.current;
// Persist the final position back into state (preserving the size) so
// re-renders keep it.
@@ -432,6 +612,20 @@ export default function AiChatWindow() {
e.preventDefault();
}, []);
// Dock/undock via the header button. Docking pins the window to the navbar;
// undocking restores the floating window at its last remembered geom. On
// undock we re-clamp that geom to the current viewport (matching drag-undock's
// clampGeom) so a viewport shrink while docked can't leave the popped-out
// window partly off-screen. The chat thread stays mounted across the toggle,
// so a live stream is intact. dockedRef gives the live value inside this
// useCallback([]) handler.
const toggleDock = useCallback((): void => {
if (dockedRef.current) {
setGeom((prev) => (prev ? clampGeom(prev) : prev));
}
setDocked((d) => !d);
}, [setDocked, setGeom]);
// Just toggle the flag. The `.minimized` CSS handles the collapsed height and
// disables resize, and `.minimized .content` hides the body while keeping
// ChatThread mounted (so an in-flight stream is not aborted).
@@ -441,17 +635,45 @@ export default function AiChatWindow() {
if (!windowOpen || !geom) return null;
return (
<div
ref={winRef}
className={`${classes.window}${minimized ? ` ${classes.minimized}` : ""}`}
style={{
// `useDock` (computed above) is the EFFECTIVE dock state: docked AND a navbar
// rect is available. If the navbar is absent/collapsed we keep the persisted
// `docked` flag but render the floating look so the window never vanishes (it
// re-docks once the navbar reappears — see the layout effect above). Minimize
// is suppressed while actually docked.
const showMinimized = minimized && !useDock;
// Position/size of the window this frame. `dragPos` (set only at a mid-drag
// navbar-boundary crossing) overrides the committed position so the box does
// not snap back for a frame when that crossing forces a re-render.
const boxStyle = dockRect && useDock
? {
left: dockRect.left,
top: dockRect.top,
width: dockRect.width,
height: dockRect.height,
}
: {
left: geom.left,
top: geom.top,
width: geom.width,
// Height omitted when minimized so the `.minimized` CSS auto-height wins.
height: minimized ? undefined : geom.height,
}}
height: showMinimized ? undefined : geom.height,
};
const style = dragPos
? { ...boxStyle, left: dragPos.left, top: dragPos.top }
: boxStyle;
// Drop-zone highlight over the navbar bounds while dragging a floating window
// onto the sidebar. Rendered as a viewport-fixed sibling overlay (not inside
// the moving window), so its position is independent of the drag.
const hintRect = dockHint ? getNavbarRect() : null;
return (
<>
<div
ref={winRef}
className={`${classes.window}${showMinimized ? ` ${classes.minimized}` : ""}${useDock ? ` ${classes.docked}` : ""}`}
style={style}
>
{/* drag bar / header. Mouse users expand a minimized window by clicking
anywhere on the bar (the click-vs-drag logic in startDrag, which
@@ -471,11 +693,11 @@ export default function AiChatWindow() {
is a plain, non-focusable label. */}
<span
className={classes.title}
role={minimized ? "button" : undefined}
tabIndex={minimized ? 0 : undefined}
aria-label={minimized ? t("Expand") : undefined}
role={showMinimized ? "button" : undefined}
tabIndex={showMinimized ? 0 : undefined}
aria-label={showMinimized ? t("Expand") : undefined}
onKeyDown={
minimized
showMinimized
? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
@@ -531,15 +753,39 @@ export default function AiChatWindow() {
)}
</button>
)}
{/* Dock/undock toggle. Effectively docked -> "Undock" (expand icon) pops
the window back out to floating; floating -> "Dock to sidebar"
(collapse icon) pins it into the navbar. The LABEL/icon reflect the
EFFECTIVE state (useDock), consistent with the Minimize gate: when
docked but the navbar is absent/collapsed the window renders floating,
so an "Undock" label there would misdescribe a floating window. The
action still toggles the raw `docked` atom. */}
<button
type="button"
className={classes.headerBtn}
title={t("Minimize")}
aria-label={t("Minimize")}
onClick={toggleMinimize}
title={useDock ? t("Undock") : t("Dock to sidebar")}
aria-label={useDock ? t("Undock") : t("Dock to sidebar")}
onClick={toggleDock}
>
<IconMinus size={14} />
{useDock ? (
<IconLayoutSidebarLeftExpand size={14} />
) : (
<IconLayoutSidebarLeftCollapse size={14} />
)}
</button>
{/* Minimize (collapse to header) makes no sense while docked — the
window fills the navbar — so it is hidden in dock mode. */}
{!useDock && (
<button
type="button"
className={classes.headerBtn}
title={t("Minimize")}
aria-label={t("Minimize")}
onClick={toggleMinimize}
>
<IconMinus size={14} />
</button>
)}
<button
type="button"
className={classes.headerBtn}
@@ -641,12 +887,29 @@ export default function AiChatWindow() {
</div>
</div>
{/* resize affordance icon (drawn manually; native resizer is hidden) */}
{!minimized && (
{/* resize affordance icon (drawn manually; native resizer is hidden).
Hidden while docked — the docked size follows the navbar, not a manual
resize. */}
{!showMinimized && !useDock && (
<span className={classes.resizeHandle}>
<IconArrowsDiagonal size={12} />
</span>
)}
</div>
{/* Drop-zone highlight over the navbar while dragging a floating window in
to dock it. Sibling of the window (position: fixed) so it tracks the
navbar bounds, not the moving window. */}
{hintRect && (
<div
className={classes.dockHighlight}
style={{
left: hintRect.left,
top: hintRect.top,
width: hintRect.width,
height: hintRect.height,
}}
/>
)}
</>
);
}
@@ -0,0 +1,58 @@
import { describe, it, expect } from "vitest";
import {
isPointWithinRect,
isNavbarRectVisible,
type NavbarRect,
} from "./dock-helpers.ts";
const NAVBAR: NavbarRect = { left: 0, top: 45, width: 300, height: 800 };
describe("isPointWithinRect", () => {
it("returns true for a point inside the navbar", () => {
expect(isPointWithinRect(150, 400, NAVBAR)).toBe(true);
});
it("treats the boundary edges as inside (drop exactly on the edge docks)", () => {
// Top-left corner and bottom-right corner are both inclusive.
expect(isPointWithinRect(0, 45, NAVBAR)).toBe(true);
expect(isPointWithinRect(300, 845, NAVBAR)).toBe(true);
});
it("returns false for a point in the content area (to the right)", () => {
expect(isPointWithinRect(500, 400, NAVBAR)).toBe(false);
});
it("returns false above the navbar (in the header band)", () => {
expect(isPointWithinRect(150, 10, NAVBAR)).toBe(false);
});
it("returns false when the navbar rect is null (absent/collapsed)", () => {
expect(isPointWithinRect(150, 400, null)).toBe(false);
});
});
describe("isNavbarRectVisible", () => {
it("returns true for a normal on-screen navbar rect", () => {
expect(isNavbarRectVisible({ width: 300, height: 800, right: 300 })).toBe(
true,
);
});
it("returns false for a zero-size rect (width or height 0)", () => {
expect(isNavbarRectVisible({ width: 0, height: 800, right: 300 })).toBe(
false,
);
expect(isNavbarRectVisible({ width: 300, height: 0, right: 300 })).toBe(
false,
);
});
it("returns false when the navbar is translated off-screen (right <= 0)", () => {
expect(isNavbarRectVisible({ width: 300, height: 800, right: 0 })).toBe(
false,
);
expect(isNavbarRectVisible({ width: 300, height: 800, right: -50 })).toBe(
false,
);
});
});
@@ -0,0 +1,48 @@
// Pure geometry helper for the AI chat window dock/undock decision (#276). Kept
// free of React and the DOM so it can be unit-tested in isolation (see
// dock-helpers.test.ts). The DOM-reading getNavbarRect() lives in the window
// component; this is only the point-in-rect math that decides dock-on-drop and
// undock-on-drag-out from the measured navbar rect.
export type NavbarRect = {
left: number;
top: number;
width: number;
height: number;
};
/**
* Whether a viewport point (x, y) falls within `rect`. Edges are inclusive so a
* drop exactly on the navbar boundary counts as "over the navbar". Returns false
* when the rect is null (navbar absent/collapsed) so the caller falls back to the
* floating behavior.
*/
export function isPointWithinRect(
x: number,
y: number,
rect: NavbarRect | null,
): boolean {
if (!rect) return false;
return (
x >= rect.left &&
x <= rect.left + rect.width &&
y >= rect.top &&
y <= rect.top + rect.height
);
}
/**
* Whether a measured navbar rect represents a VISIBLE navbar. Mantine collapses
* the navbar by translating it off-screen (its right edge lands at or left of the
* viewport) without changing its width/border-box, so a zero-size or off-screen
* rect means "no navbar" — the docked window then falls back to floating instead
* of pinning to an invisible box. Pure (no DOM) so it can be unit-tested; the
* DOM-reading getNavbarRect() in the window component supplies the rect.
*/
export function isNavbarRectVisible(r: {
width: number;
height: number;
right: number;
}): boolean {
return !(r.width === 0 || r.height === 0 || r.right <= 0);
}
@@ -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.
@@ -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;
}
@@ -15,6 +15,7 @@ import {
IconLayoutAlignRight,
IconFloatLeft,
IconFloatRight,
IconLayoutColumns,
IconDownload,
IconRefresh,
IconTrash,
@@ -46,6 +47,7 @@ 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 || "",
@@ -126,6 +128,14 @@ 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);
@@ -259,6 +269,18 @@ 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}
@@ -11,6 +11,7 @@ import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { isCellSelection } from "@docmost/editor-ext";
import { CellChevronMenu } from "./menus/cell-chevron-menu";
import { refocusEditorAfterMenuClose } from "./hooks/use-column-row-menu-lifecycle";
import classes from "./handle.module.css";
interface CellChevronProps {
@@ -87,6 +88,7 @@ export const CellChevron = React.memo(function CellChevron({
const onClose = useCallback(() => {
editor.commands.unfreezeHandles();
refocusEditorAfterMenuClose(editor);
}, [editor]);
if (!cellDom) return null;
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { Editor } from "@tiptap/react";
import { refocusEditorAfterMenuClose } from "./use-column-row-menu-lifecycle";
// A minimal fake editor. `view.dom` is a real element so `.contains()` works,
// and `view.focus` is a spy so we assert on it without relying on real DOM
// focus (unreliable in jsdom). rAF is stubbed to a `setTimeout(0)` so fake
// timers can flush the deferred callback deterministically.
function makeEditor() {
const dom = document.createElement("div");
document.body.appendChild(dom);
const focus = vi.fn();
const editor = { isDestroyed: false, view: { dom, focus } };
return { editor: editor as unknown as Editor, focus, dom };
}
describe("refocusEditorAfterMenuClose", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) =>
setTimeout(() => cb(0), 0),
);
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
document.body.innerHTML = "";
});
it("(a) does not refocus the editor when an external <input> is active", () => {
const { editor, focus } = makeEditor();
const input = document.createElement("input");
document.body.appendChild(input);
input.focus();
expect(document.activeElement).toBe(input);
refocusEditorAfterMenuClose(editor);
vi.runAllTimers();
expect(focus).not.toHaveBeenCalled();
});
it("(b) refocuses the editor when a non-focusable element (body) is active", () => {
const { editor, focus } = makeEditor();
// Ensure focus rests on body: nothing is focused / an <input> was blurred.
(document.activeElement as HTMLElement | null)?.blur();
expect(document.activeElement).toBe(document.body);
refocusEditorAfterMenuClose(editor);
vi.runAllTimers();
expect(focus).toHaveBeenCalledTimes(1);
});
});
@@ -11,6 +11,39 @@ interface Args {
tablePos: number;
}
/**
* Restore focus to the editor after a table handle/cell menu closes.
*
* The grip/chevron menus are Mantine `<Menu>`s with `returnFocus: true`, and
* their targets live in a floating/portaled layer OUTSIDE the editor's
* contenteditable. After an action (delete row/column, insert, etc.) the menu
* closes and Mantine returns focus to that outside target, so ProseMirror's
* undo keymap never sees Ctrl+Z until the user clicks back into a cell.
*
* We defer with `requestAnimationFrame` so this runs AFTER Mantine's
* returnFocus, and guard against stealing focus if the user intentionally
* moved to another input/editable (e.g. the page title).
*/
export function refocusEditorAfterMenuClose(editor: Editor) {
requestAnimationFrame(() => {
if (editor.isDestroyed) return;
const active = document.activeElement as HTMLElement | null;
// Already inside the editor — nothing to do.
if (active && editor.view.dom.contains(active)) return;
// Respect a deliberate move to another field/editable.
const tag = active?.tagName;
if (
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
active?.isContentEditable
) {
return;
}
editor.view.focus(); // pure DOM focus, no extra transaction
});
}
export function useColumnRowMenuLifecycle({
editor,
orientation,
@@ -34,6 +67,7 @@ export function useColumnRowMenuLifecycle({
const onClose = useCallback(() => {
editor.commands.unfreezeHandles();
refocusEditorAfterMenuClose(editor);
}, [editor]);
return { onOpen, onClose };
@@ -71,3 +71,22 @@
}
}
/* Inline image rows (#284): center the anonymous line boxes formed by
consecutive [data-image-align="inline"] node-view containers. A row has no
DOM wrapper of its own, so its horizontal placement is controlled by the
text-align of the nearest block ancestor (the editor root or a nested
block container: blockquote, callout, list item, table cell, details).
Centering is enabled only in containers that actually hold an inline
image (:has), and every other child of such a container gets its default
alignment back so ordinary text is unaffected. Explicit per-block
alignment from the toolbar is an inline style and still wins. Browsers
without :has() degrade to left-pinned rows. */
.ProseMirror:has(> [data-image-align="inline"]),
.ProseMirror :has(> [data-image-align="inline"]) {
text-align: center;
}
.ProseMirror:has(> [data-image-align="inline"]) > :not([data-image-align="inline"]),
.ProseMirror :has(> [data-image-align="inline"]) > :not([data-image-align="inline"]) {
text-align: start;
}
@@ -63,6 +63,38 @@ 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");
+26 -1
View File
@@ -53,7 +53,13 @@ declare module "@tiptap/core" {
attributes: ImageAttributes & { pos: number | Range },
) => ReturnType;
setImageAlign: (
align: "left" | "center" | "right" | "floatLeft" | "floatRight",
align:
| "left"
| "center"
| "right"
| "floatLeft"
| "floatRight"
| "inline",
) => ReturnType;
setImageWidth: (width: number) => ReturnType;
setImageSize: (width: number, height: number) => ReturnType;
@@ -415,6 +421,14 @@ 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).
@@ -430,6 +444,17 @@ 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. Horizontal centering of the whole row is handled by the
// client stylesheet (media.css) via a :has() rule on the parent block
// container, since the row has no wrapper element of its own.
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") {