Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 731a4f0dca | |||
| fa439d7c7b | |||
| affa32cbaa | |||
| 3a5794894e | |||
| 8d745352d1 | |||
| f0a69abd0f | |||
| f8c4343fa8 | |||
| 4d0f791471 | |||
| 20032be921 | |||
| c16942777d | |||
| 0bdc9f98f5 | |||
| 6e70c7bd6a | |||
| 85b303e387 | |||
| 23c80f727a | |||
| 2b36997c63 |
@@ -12,6 +12,13 @@ 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
|
||||
|
||||
@@ -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": "Переключить режим отображения подстраниц",
|
||||
|
||||
@@ -12,6 +12,7 @@ import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
NAVBAR_COLLAPSE_BREAKPOINT,
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
@@ -53,7 +54,13 @@ export function AppHeader() {
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
// Must match the AppShell navbar breakpoint (md). The navbar
|
||||
// collapses to the MOBILE drawer below md, so the mobile toggle
|
||||
// (which flips mobileOpened) must be the one visible across the
|
||||
// whole <md band — otherwise at 768-991 the desktop toggle showed
|
||||
// but flipped the wrong atom, leaving the drawer unopenable (the
|
||||
// regression from the initial sm->md navbar change).
|
||||
hiddenFrom={NAVBAR_COLLAPSE_BREAKPOINT}
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -63,7 +70,7 @@ export function AppHeader() {
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom="sm"
|
||||
visibleFrom={NAVBAR_COLLAPSE_BREAKPOINT}
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useTranslation } from "react-i18next";
|
||||
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
APP_NAVBAR_ID,
|
||||
NAVBAR_COLLAPSE_BREAKPOINT,
|
||||
asideStateAtom,
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
@@ -87,7 +89,13 @@ export default function GlobalAppShell({
|
||||
header={{ height: 45 }}
|
||||
navbar={{
|
||||
width: isSpaceRoute ? sidebarWidth : 300,
|
||||
breakpoint: "sm",
|
||||
// `md` (not `sm`): below 992px the fixed ~300px sidebar leaves too little
|
||||
// room for content — the settings tables (Members/…) overflow the offset
|
||||
// content area on tablet (~768px) and clip the Role/actions columns
|
||||
// off-screen with no horizontal scroll. Collapsing the navbar to a toggle
|
||||
// drawer across the whole tablet band frees the full width for content
|
||||
// (the mobile drawer is closed by default, so nothing overlaps on load).
|
||||
breakpoint: NAVBAR_COLLAPSE_BREAKPOINT,
|
||||
collapsed: {
|
||||
mobile: !mobileOpened,
|
||||
desktop: !desktopOpened,
|
||||
@@ -96,7 +104,7 @@ export default function GlobalAppShell({
|
||||
aside={
|
||||
isPageRoute && {
|
||||
width: 420,
|
||||
breakpoint: "sm",
|
||||
breakpoint: "md",
|
||||
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
|
||||
}
|
||||
}
|
||||
@@ -106,6 +114,7 @@ export default function GlobalAppShell({
|
||||
<AppHeader />
|
||||
</AppShell.Header>
|
||||
<AppShell.Navbar
|
||||
id={APP_NAVBAR_ID}
|
||||
className={classes.navbar}
|
||||
withBorder={false}
|
||||
ref={sidebarRef}
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
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";
|
||||
|
||||
// Single source of truth for the navbar collapse breakpoint. The AppShell navbar
|
||||
// `breakpoint` and BOTH burger toggles' `hiddenFrom`/`visibleFrom` MUST use this
|
||||
// exact value: if they drift, the sidebar becomes unreachable on tablet widths
|
||||
// (the round-1 regression of #292). Kept here so the shell and the header share
|
||||
// one constant the compiler enforces, instead of three hand-synced string literals.
|
||||
export const NAVBAR_COLLAPSE_BREAKPOINT = "md";
|
||||
|
||||
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;
|
||||
|
||||
+56
@@ -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);
|
||||
});
|
||||
});
|
||||
+34
@@ -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 };
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,15 @@ 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") {
|
||||
|
||||
Reference in New Issue
Block a user