Merge branch 'develop' into feature/offline-sync
# Conflicts: # apps/client/src/features/editor/page-editor.tsx # apps/server/src/collaboration/extensions/persistence.extension.ts
This commit is contained in:
@@ -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",
|
||||
@@ -1334,6 +1337,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",
|
||||
@@ -727,6 +728,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)": "Окно контекста (токены)",
|
||||
@@ -1187,6 +1190,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);
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import { useRef } from "react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
// Stub the comments query so the component renders without react-query/network.
|
||||
const mockUseCommentsQuery = vi.fn();
|
||||
vi.mock("@/features/comment/queries/comment-query", () => ({
|
||||
useCommentsQuery: (params: { pageId: string }) =>
|
||||
mockUseCommentsQuery(params),
|
||||
}));
|
||||
|
||||
import CommentHoverPreview from "./comment-hover-preview";
|
||||
import { commentContentToText } from "@/features/comment/utils/comment-content-to-text";
|
||||
|
||||
const doc = (text: string) =>
|
||||
JSON.stringify({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
||||
});
|
||||
|
||||
const comment = (over?: Partial<IComment>): IComment =>
|
||||
({
|
||||
id: "c-1",
|
||||
content: doc("Hello world"),
|
||||
creatorId: "u-1",
|
||||
pageId: "page-1",
|
||||
workspaceId: "ws-1",
|
||||
createdAt: new Date(),
|
||||
creator: { id: "u-1", name: "User", avatarUrl: null } as any,
|
||||
...over,
|
||||
}) as IComment;
|
||||
|
||||
function setComments(items: IComment[]) {
|
||||
mockUseCommentsQuery.mockReturnValue({
|
||||
data: { items, meta: {} },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Test harness: owns the container ref, hosts a comment-mark span and the
|
||||
// preview component, mirroring how page-editor mounts it next to EditorContent.
|
||||
function Harness({
|
||||
spanAttrs = { "data-comment-id": "c-1" },
|
||||
pageId = "page-1",
|
||||
}: {
|
||||
spanAttrs?: Record<string, string>;
|
||||
pageId?: string;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<MantineProvider>
|
||||
<div ref={containerRef}>
|
||||
<span data-testid="mark" className="comment-mark" {...spanAttrs}>
|
||||
marked text
|
||||
</span>
|
||||
<CommentHoverPreview pageId={pageId} containerRef={containerRef} />
|
||||
</div>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function hoverMark() {
|
||||
const span = screen.getByTestId("mark");
|
||||
act(() => {
|
||||
span.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function leaveMark() {
|
||||
const span = screen.getByTestId("mark");
|
||||
act(() => {
|
||||
span.dispatchEvent(new MouseEvent("mouseout", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
describe("commentContentToText", () => {
|
||||
it("flattens a multi-node ProseMirror doc to plain text", () => {
|
||||
const content = JSON.stringify({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "Hello " },
|
||||
{ type: "text", text: "world" },
|
||||
],
|
||||
},
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Second line" }] },
|
||||
],
|
||||
});
|
||||
expect(commentContentToText(content)).toBe("Hello world\nSecond line");
|
||||
});
|
||||
|
||||
it("joins nested block structures (lists) on block boundaries", () => {
|
||||
const content = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "one" }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "two" }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(commentContentToText(content)).toBe("one\ntwo");
|
||||
});
|
||||
|
||||
it("accepts an already-parsed object", () => {
|
||||
expect(commentContentToText({ type: "doc", content: [] })).toBe("");
|
||||
});
|
||||
|
||||
it("returns '' for empty / missing / malformed content", () => {
|
||||
expect(commentContentToText("")).toBe("");
|
||||
expect(commentContentToText(" ")).toBe("");
|
||||
expect(commentContentToText(undefined)).toBe("");
|
||||
expect(commentContentToText(null)).toBe("");
|
||||
expect(commentContentToText(JSON.stringify({ type: "doc", content: [] }))).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the raw string when content is not JSON", () => {
|
||||
expect(commentContentToText("plain text")).toBe("plain text");
|
||||
});
|
||||
|
||||
it("preserves a hardBreak inside a paragraph as a newline", () => {
|
||||
const content = JSON.stringify({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "line1" },
|
||||
{ type: "hardBreak" },
|
||||
{ type: "text", text: "line2" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(commentContentToText(content)).toBe("line1\nline2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommentHoverPreview — hover behaviour", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockUseCommentsQuery.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows the parent comment text and author after the open delay", () => {
|
||||
setComments([
|
||||
comment({
|
||||
content: doc("Hello world"),
|
||||
creator: { id: "u-1", name: "Alice", avatarUrl: null } as any,
|
||||
}),
|
||||
]);
|
||||
render(<Harness />);
|
||||
|
||||
hoverMark();
|
||||
// Before the delay elapses there is no card.
|
||||
expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
const card = screen.getByTestId("comment-hover-preview");
|
||||
// The line shows "Author: text" — both the author name and the comment text.
|
||||
expect(card.textContent).toContain("Alice:");
|
||||
expect(card.textContent).toContain("Hello world");
|
||||
// The card MUST NOT intercept the mark's click (which opens the side panel):
|
||||
// pointer-events:none is the single property guaranteeing that — lock it so
|
||||
// a regression dropping it from the style object fails here.
|
||||
expect(card.style.pointerEvents).toBe("none");
|
||||
});
|
||||
|
||||
it("renders the whole thread: parent plus replies, each with its author", () => {
|
||||
setComments([
|
||||
comment({
|
||||
id: "c-1",
|
||||
content: doc("Parent comment"),
|
||||
createdAt: new Date("2026-01-01T10:00:00Z"),
|
||||
creator: { id: "u-1", name: "Alice", avatarUrl: null } as any,
|
||||
}),
|
||||
comment({
|
||||
id: "c-3",
|
||||
content: doc("Second reply"),
|
||||
parentCommentId: "c-1",
|
||||
createdAt: new Date("2026-01-01T12:00:00Z"),
|
||||
creator: { id: "u-3", name: "Carol", avatarUrl: null } as any,
|
||||
}),
|
||||
comment({
|
||||
id: "c-2",
|
||||
content: doc("First reply"),
|
||||
parentCommentId: "c-1",
|
||||
createdAt: new Date("2026-01-01T11:00:00Z"),
|
||||
creator: { id: "u-2", name: "Bob", avatarUrl: null } as any,
|
||||
}),
|
||||
]);
|
||||
render(<Harness />);
|
||||
|
||||
hoverMark();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
const card = screen.getByTestId("comment-hover-preview");
|
||||
|
||||
// Parent and both replies are present, each as "Author: text".
|
||||
const body = card.textContent ?? "";
|
||||
expect(body).toContain("Alice: Parent comment");
|
||||
expect(body).toContain("Bob: First reply");
|
||||
expect(body).toContain("Carol: Second reply");
|
||||
|
||||
// Replies are ordered by createdAt ascending after the parent
|
||||
// (Parent -> First reply -> Second reply), even though the input was
|
||||
// out of order (Second reply's comment came before First reply's).
|
||||
expect(body.indexOf("Parent comment")).toBeLessThan(
|
||||
body.indexOf("First reply"),
|
||||
);
|
||||
expect(body.indexOf("First reply")).toBeLessThan(
|
||||
body.indexOf("Second reply"),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the thread even when the parent text is empty but it has replies", () => {
|
||||
setComments([
|
||||
comment({
|
||||
id: "c-1",
|
||||
content: JSON.stringify({ type: "doc", content: [] }),
|
||||
creator: { id: "u-1", name: "Alice", avatarUrl: null } as any,
|
||||
}),
|
||||
comment({
|
||||
id: "c-2",
|
||||
content: doc("A reply"),
|
||||
parentCommentId: "c-1",
|
||||
createdAt: new Date(),
|
||||
creator: { id: "u-2", name: "Bob", avatarUrl: null } as any,
|
||||
}),
|
||||
]);
|
||||
render(<Harness />);
|
||||
|
||||
hoverMark();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
const card = screen.getByTestId("comment-hover-preview");
|
||||
expect(card.textContent).toContain("Bob: A reply");
|
||||
});
|
||||
|
||||
it("shows nothing when neither the parent nor its reply has any text", () => {
|
||||
// The card is gated on rows-with-text (not thread length), so a text-less
|
||||
// root whose only reply is also text-less must NOT open an empty card.
|
||||
const emptyDoc = JSON.stringify({ type: "doc", content: [] });
|
||||
setComments([
|
||||
comment({
|
||||
id: "c-1",
|
||||
content: emptyDoc,
|
||||
creator: { id: "u-1", name: "Alice", avatarUrl: null } as any,
|
||||
}),
|
||||
comment({
|
||||
id: "c-2",
|
||||
content: emptyDoc,
|
||||
parentCommentId: "c-1",
|
||||
createdAt: new Date(),
|
||||
creator: { id: "u-2", name: "Bob", avatarUrl: null } as any,
|
||||
}),
|
||||
]);
|
||||
render(<Harness />);
|
||||
|
||||
hoverMark();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides on mouseout", () => {
|
||||
setComments([comment()]);
|
||||
render(<Harness />);
|
||||
|
||||
hoverMark();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
expect(
|
||||
screen.getByTestId("comment-hover-preview").textContent,
|
||||
).toContain("Hello world");
|
||||
|
||||
leaveMark();
|
||||
expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show a card for a resolved comment (data-resolved)", () => {
|
||||
setComments([comment()]);
|
||||
render(
|
||||
<Harness
|
||||
spanAttrs={{ "data-comment-id": "c-1", "data-resolved": "true" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
hoverMark();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show a card for a resolved comment (resolvedAt set)", () => {
|
||||
setComments([comment({ resolvedAt: new Date() })]);
|
||||
render(<Harness />);
|
||||
|
||||
hoverMark();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show a card for an unknown comment id", () => {
|
||||
setComments([comment()]);
|
||||
render(<Harness spanAttrs={{ "data-comment-id": "missing" }} />);
|
||||
|
||||
hoverMark();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show a card when the comment text is empty", () => {
|
||||
setComments([comment({ content: JSON.stringify({ type: "doc", content: [] }) })]);
|
||||
render(<Harness />);
|
||||
|
||||
hoverMark();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides on scroll", () => {
|
||||
setComments([comment()]);
|
||||
render(<Harness />);
|
||||
|
||||
hoverMark();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
expect(
|
||||
screen.getByTestId("comment-hover-preview").textContent,
|
||||
).toContain("Hello world");
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides on mousedown (clicking the mark to open the panel dismisses the card)", () => {
|
||||
setComments([comment()]);
|
||||
render(<Harness />);
|
||||
|
||||
hoverMark();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
expect(
|
||||
screen.getByTestId("comment-hover-preview").textContent,
|
||||
).toContain("Hello world");
|
||||
|
||||
const span = screen.getByTestId("mark");
|
||||
act(() => {
|
||||
span.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||
});
|
||||
expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not hide when the pointer moves WITHIN the same span (anti-flicker)", () => {
|
||||
setComments([comment()]);
|
||||
render(<Harness />);
|
||||
|
||||
hoverMark();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
expect(screen.queryByTestId("comment-hover-preview")).not.toBeNull();
|
||||
|
||||
// mouseout whose relatedTarget is still inside the span must NOT hide.
|
||||
const span = screen.getByTestId("mark");
|
||||
act(() => {
|
||||
span.dispatchEvent(
|
||||
new MouseEvent("mouseout", { bubbles: true, relatedTarget: span }),
|
||||
);
|
||||
});
|
||||
expect(screen.queryByTestId("comment-hover-preview")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("hides when the page changes", () => {
|
||||
setComments([comment()]);
|
||||
const { rerender } = render(<Harness pageId="page-1" />);
|
||||
|
||||
hoverMark();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
expect(screen.queryByTestId("comment-hover-preview")).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
rerender(<Harness pageId="page-2" />);
|
||||
});
|
||||
expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Paper, Text } from "@mantine/core";
|
||||
import { useCommentsQuery } from "@/features/comment/queries/comment-query";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
import { commentContentToText } from "@/features/comment/utils/comment-content-to-text";
|
||||
|
||||
interface CommentHoverPreviewProps {
|
||||
pageId: string;
|
||||
containerRef: React.RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
// Delay before the card appears, to avoid flicker when the pointer quickly
|
||||
// passes over comment marks (kept generous so it does not pop up on a passing
|
||||
// glance).
|
||||
const OPEN_DELAY_MS = 350;
|
||||
const CARD_MAX_WIDTH = 360;
|
||||
const CARD_MAX_HEIGHT = 300;
|
||||
const GAP = 6;
|
||||
// Reserve roughly this much room below the span; flip above when it doesn't fit.
|
||||
// Match CARD_MAX_HEIGHT so the flip-above decision reserves the real worst-case
|
||||
// height — otherwise a tall thread placed below near the viewport bottom passes
|
||||
// the "fits below" check and then overflows off-screen (clipped, no scroll).
|
||||
const ESTIMATED_CARD_HEIGHT = 300;
|
||||
|
||||
// One rendered line of the thread: the author and the comment's plain text,
|
||||
// pre-computed at hover time so render stays cheap. Shown as "Author: text".
|
||||
interface ThreadRow {
|
||||
id: string;
|
||||
name: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface HoverState {
|
||||
thread: ThreadRow[];
|
||||
rect: { top: number; bottom: number; left: number };
|
||||
}
|
||||
|
||||
function isResolved(comment: IComment): boolean {
|
||||
return comment.resolvedAt != null || comment.resolvedById != null;
|
||||
}
|
||||
|
||||
// Build the thread for a root (parent) comment: the root first, followed by its
|
||||
// replies sorted by createdAt ascending. Reads every comment from the map.
|
||||
function buildThread(
|
||||
commentMap: Map<string, IComment>,
|
||||
root: IComment,
|
||||
): ThreadRow[] {
|
||||
const replies: IComment[] = [];
|
||||
commentMap.forEach((comment) => {
|
||||
if (comment.parentCommentId === root.id) replies.push(comment);
|
||||
});
|
||||
replies.sort(
|
||||
(a, b) =>
|
||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
);
|
||||
|
||||
return [root, ...replies].map((comment) => ({
|
||||
id: comment.id,
|
||||
name: comment.creator?.name ?? "",
|
||||
text: commentContentToText(comment.content),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a small floating card when the user hovers a `.comment-mark` span in the
|
||||
* main editor: the parent comment plus all its replies, one per line as
|
||||
* "Author: text" (plain — no avatars or timestamps). Read-only:
|
||||
* `pointer-events: none` so it never intercepts the mark's click (which opens
|
||||
* the side panel via ACTIVE_COMMENT_EVENT). Resolved/unknown marks show nothing.
|
||||
*/
|
||||
export default function CommentHoverPreview({
|
||||
pageId,
|
||||
containerRef,
|
||||
}: CommentHoverPreviewProps) {
|
||||
const { data } = useCommentsQuery({ pageId });
|
||||
|
||||
// Map of commentId -> comment. The map indexes every comment (parents and
|
||||
// replies) so a thread can be assembled from a single source.
|
||||
const commentMap = useMemo(() => {
|
||||
const map = new Map<string, IComment>();
|
||||
data?.items?.forEach((comment) => map.set(comment.id, comment));
|
||||
return map;
|
||||
}, [data]);
|
||||
|
||||
// Read the latest map from the delegated listeners without re-attaching them
|
||||
// every time the comments query refreshes.
|
||||
const commentMapRef = useRef(commentMap);
|
||||
useEffect(() => {
|
||||
commentMapRef.current = commentMap;
|
||||
}, [commentMap]);
|
||||
|
||||
const [hover, setHover] = useState<HoverState | null>(null);
|
||||
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const activeSpanRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const clearOpenTimer = () => {
|
||||
if (openTimerRef.current !== null) {
|
||||
clearTimeout(openTimerRef.current);
|
||||
openTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
clearOpenTimer();
|
||||
activeSpanRef.current = null;
|
||||
setHover(null);
|
||||
};
|
||||
|
||||
// Hide and reset when the page changes (the comment set belongs to a page):
|
||||
// the cleanup runs on every pageId change before the effect re-runs.
|
||||
useEffect(() => {
|
||||
return () => hide();
|
||||
}, [pageId]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleMouseOver = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const span = target?.closest<HTMLElement>(
|
||||
".comment-mark[data-comment-id]",
|
||||
);
|
||||
if (!span) return;
|
||||
|
||||
const commentId = span.getAttribute("data-comment-id");
|
||||
if (!commentId) return;
|
||||
|
||||
const comment = commentMapRef.current.get(commentId);
|
||||
// Unknown (not loaded yet) or resolved -> no tooltip. Resolved marks also
|
||||
// carry data-resolved="true"; check both the data attribute and the model.
|
||||
if (
|
||||
!comment ||
|
||||
span.hasAttribute("data-resolved") ||
|
||||
isResolved(comment)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Already tracking this span: nothing to do (avoids re-building the thread
|
||||
// on every intra-span mousemove).
|
||||
if (span === activeSpanRef.current) return;
|
||||
|
||||
const thread = buildThread(commentMapRef.current, comment);
|
||||
// Show the card only when SOME comment has text. Gating on thread length
|
||||
// could open an empty card (a text-less root whose only reply is also
|
||||
// text-less), since the render filters out empty-text rows.
|
||||
const hasContent = thread.some((row) => row.text.length > 0);
|
||||
if (!hasContent) return;
|
||||
|
||||
activeSpanRef.current = span;
|
||||
|
||||
clearOpenTimer();
|
||||
openTimerRef.current = setTimeout(() => {
|
||||
openTimerRef.current = null;
|
||||
if (activeSpanRef.current !== span || !span.isConnected) return;
|
||||
const rect = span.getBoundingClientRect();
|
||||
setHover({
|
||||
thread,
|
||||
rect: { top: rect.top, bottom: rect.bottom, left: rect.left },
|
||||
});
|
||||
}, OPEN_DELAY_MS);
|
||||
};
|
||||
|
||||
const handleMouseOut = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const span = target?.closest<HTMLElement>(
|
||||
".comment-mark[data-comment-id]",
|
||||
);
|
||||
if (!span) return;
|
||||
|
||||
// Ignore moves that stay within the same comment-mark span.
|
||||
const related = event.relatedTarget as HTMLElement | null;
|
||||
if (related && span.contains(related)) return;
|
||||
|
||||
if (span === activeSpanRef.current) hide();
|
||||
};
|
||||
|
||||
// Scroll uses capture so it also catches scrolling inside nested containers.
|
||||
const handleScroll = () => hide();
|
||||
const handleResize = () => hide();
|
||||
// Dismiss on press: clicking a mark opens the side panel, and the card
|
||||
// would otherwise linger (no mouseout fires while the pointer stays put).
|
||||
const handleMouseDown = () => hide();
|
||||
|
||||
container.addEventListener("mouseover", handleMouseOver);
|
||||
container.addEventListener("mouseout", handleMouseOut);
|
||||
container.addEventListener("mousedown", handleMouseDown);
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("mouseover", handleMouseOver);
|
||||
container.removeEventListener("mouseout", handleMouseOut);
|
||||
container.removeEventListener("mousedown", handleMouseDown);
|
||||
window.removeEventListener("scroll", handleScroll, true);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
clearOpenTimer();
|
||||
};
|
||||
}, [containerRef]);
|
||||
|
||||
if (!hover) return null;
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
// Flip above when there isn't enough room below the span.
|
||||
const placeAbove =
|
||||
hover.rect.bottom + ESTIMATED_CARD_HEIGHT > viewportHeight &&
|
||||
hover.rect.top > ESTIMATED_CARD_HEIGHT;
|
||||
|
||||
const left = Math.max(
|
||||
8,
|
||||
Math.min(hover.rect.left, viewportWidth - CARD_MAX_WIDTH - 8),
|
||||
);
|
||||
|
||||
const positionStyle: React.CSSProperties = placeAbove
|
||||
? { bottom: viewportHeight - hover.rect.top + GAP }
|
||||
: { top: hover.rect.bottom + GAP };
|
||||
|
||||
return createPortal(
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="md"
|
||||
radius="sm"
|
||||
role="tooltip"
|
||||
data-testid="comment-hover-preview"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left,
|
||||
...positionStyle,
|
||||
zIndex: 1000,
|
||||
maxWidth: CARD_MAX_WIDTH,
|
||||
// The card is pointer-events:none, so it can't scroll; clamp long
|
||||
// threads instead (most threads are short).
|
||||
maxHeight: CARD_MAX_HEIGHT,
|
||||
overflow: "hidden",
|
||||
padding: "8px 10px",
|
||||
fontSize: "13px",
|
||||
lineHeight: 1.4,
|
||||
// Never intercept clicks targeting the comment-mark span beneath.
|
||||
pointerEvents: "none",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{hover.thread
|
||||
// A comment with no plain text (e.g. an image-only reply) adds nothing
|
||||
// to a text preview — skip its line.
|
||||
.filter((row) => row.text.length > 0)
|
||||
.map((row) => (
|
||||
<Text
|
||||
key={row.id}
|
||||
size="xs"
|
||||
mt={4}
|
||||
style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
|
||||
>
|
||||
{/* "Author: text" — one line per comment, parent then replies. */}
|
||||
<Text span fw={600}>
|
||||
{row.name}:
|
||||
</Text>{" "}
|
||||
{row.text}
|
||||
</Text>
|
||||
))}
|
||||
</Paper>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Flatten a comment's ProseMirror JSON document to plain text.
|
||||
*
|
||||
* `IComment.content` is stored as a stringified ProseMirror doc, but this also
|
||||
* accepts an already-parsed object. Walks the node tree, concatenating `text`
|
||||
* leaves and joining text-bearing blocks with newlines. Missing, empty or
|
||||
* malformed content yields an empty string (never throws).
|
||||
*/
|
||||
export function commentContentToText(content: unknown): string {
|
||||
let doc: any = content;
|
||||
|
||||
if (typeof content === "string") {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return "";
|
||||
try {
|
||||
doc = JSON.parse(trimmed);
|
||||
} catch {
|
||||
// Not JSON — fall back to treating the raw string as plain text.
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
if (!doc || typeof doc !== "object") return "";
|
||||
|
||||
const blocks: string[] = [];
|
||||
|
||||
const walk = (node: any): void => {
|
||||
if (!node || typeof node !== "object") return;
|
||||
|
||||
if (typeof node.text === "string") {
|
||||
// Inline text leaf: append to the current block line.
|
||||
if (blocks.length === 0) blocks.push("");
|
||||
blocks[blocks.length - 1] += node.text;
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.type === "hardBreak") {
|
||||
// A soft line break inside a block: keep the newline so the two halves
|
||||
// do not run together.
|
||||
if (blocks.length === 0) blocks.push("");
|
||||
blocks[blocks.length - 1] += "\n";
|
||||
return;
|
||||
}
|
||||
|
||||
const children = Array.isArray(node.content) ? node.content : [];
|
||||
const containsText = children.some(
|
||||
(child: any) =>
|
||||
child && typeof child === "object" && typeof child.text === "string",
|
||||
);
|
||||
|
||||
if (containsText) {
|
||||
// Text-bearing block (paragraph, heading, ...): start a fresh line, then
|
||||
// collect its inline text.
|
||||
blocks.push("");
|
||||
children.forEach(walk);
|
||||
return;
|
||||
}
|
||||
|
||||
// Structural container (doc, list, blockquote, ...): recurse so each nested
|
||||
// text block becomes its own line.
|
||||
children.forEach(walk);
|
||||
};
|
||||
|
||||
walk(doc);
|
||||
|
||||
return blocks
|
||||
.map((block) => block.trim())
|
||||
.filter((block) => block.length > 0)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
|
||||
// Shared, hoisted test state the module mocks write into. `onSpeechEnd` is the
|
||||
// VAD callback the hook registers on MicVAD.new — capturing it lets us drive
|
||||
// "a speech segment ended" deterministically. `pending` collects the deferred
|
||||
// transcription promises so the test controls their resolution order, which is
|
||||
// the whole point: out-of-order HTTP responses must NOT scramble the emitted
|
||||
// text (the in-order emitter under test).
|
||||
const h = vi.hoisted(() => {
|
||||
return {
|
||||
onSpeechEnd: null as null | ((audio: Float32Array) => void),
|
||||
pending: [] as { resolve: (s: string) => void; reject: (e: unknown) => void }[],
|
||||
notify: null as null | ReturnType<typeof Object>,
|
||||
};
|
||||
});
|
||||
|
||||
// Lazy-imported VAD: capture the onSpeechEnd handler and hand back a no-op
|
||||
// instance (start/pause/destroy all resolve).
|
||||
vi.mock("@ricky0123/vad-web", () => ({
|
||||
MicVAD: {
|
||||
new: vi.fn(async (opts: { onSpeechEnd: (a: Float32Array) => void }) => {
|
||||
h.onSpeechEnd = opts.onSpeechEnd;
|
||||
return {
|
||||
start: vi.fn(async () => {}),
|
||||
pause: vi.fn(async () => {}),
|
||||
destroy: vi.fn(async () => {}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Each transcribeAudio call returns a promise we resolve/reject by index.
|
||||
vi.mock("@/features/dictation/services/dictation-service", () => ({
|
||||
transcribeAudio: vi.fn(
|
||||
() =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
h.pending.push({ resolve, reject });
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
// Avoid real WAV encoding; the segment payload is irrelevant to ordering.
|
||||
vi.mock("@/features/dictation/utils/encode-wav", () => ({
|
||||
encodeWavPcm16: vi.fn(() => new Blob()),
|
||||
}));
|
||||
|
||||
const notifyShow = vi.fn();
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: (...args: unknown[]) => notifyShow(...args) },
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (s: string) => s }),
|
||||
}));
|
||||
|
||||
import { useStreamingDictation } from "./use-streaming-dictation";
|
||||
|
||||
// jsdom has no AudioContext; the hook constructs one and calls resume(). A
|
||||
// trivial stub is enough — the real audio path is irrelevant to ordering.
|
||||
class FakeAudioContext {
|
||||
state = "running";
|
||||
resume() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
close() {
|
||||
this.state = "closed";
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
async function startRecording(onText: (t: string) => void) {
|
||||
const hook = renderHook(() => useStreamingDictation({ onText }));
|
||||
await act(async () => {
|
||||
await hook.result.current.start();
|
||||
});
|
||||
// The VAD registered its onSpeechEnd and start() resolved into "recording".
|
||||
expect(h.onSpeechEnd).toBeTypeOf("function");
|
||||
expect(hook.result.current.status).toBe("recording");
|
||||
return hook;
|
||||
}
|
||||
|
||||
// Fire N ended speech segments (seq 0..N-1), each kicking off one transcription.
|
||||
async function emitSegments(n: number) {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < n; i++) h.onSpeechEnd!(new Float32Array(8));
|
||||
});
|
||||
}
|
||||
|
||||
describe("useStreamingDictation — in-order segment emitter", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
h.onSpeechEnd = null;
|
||||
h.pending = [];
|
||||
notifyShow.mockClear();
|
||||
(window as unknown as { AudioContext: unknown }).AudioContext =
|
||||
FakeAudioContext;
|
||||
});
|
||||
|
||||
it("emits transcriptions in segment order even when responses resolve out of order", async () => {
|
||||
const emitted: string[] = [];
|
||||
await startRecording((t) => emitted.push(t));
|
||||
await emitSegments(3);
|
||||
expect(h.pending).toHaveLength(3);
|
||||
|
||||
// Resolve seq 1 FIRST: it must be buffered, not emitted, because seq 0 is
|
||||
// still outstanding (nextEmit == 0).
|
||||
await act(async () => {
|
||||
h.pending[1].resolve("second");
|
||||
});
|
||||
expect(emitted).toEqual([]);
|
||||
|
||||
// Resolve seq 0: this unblocks the buffer and flushes 0 then 1 in order.
|
||||
await act(async () => {
|
||||
h.pending[0].resolve("first");
|
||||
});
|
||||
expect(emitted).toEqual(["first", "second"]);
|
||||
|
||||
// seq 2 resolves last and flushes immediately (it is now next).
|
||||
await act(async () => {
|
||||
h.pending[2].resolve("third");
|
||||
});
|
||||
expect(emitted).toEqual(["first", "second", "third"]);
|
||||
});
|
||||
|
||||
it("trims whitespace and drops empty/whitespace-only transcriptions while still advancing", async () => {
|
||||
const emitted: string[] = [];
|
||||
await startRecording((t) => emitted.push(t));
|
||||
await emitSegments(3);
|
||||
|
||||
await act(async () => {
|
||||
h.pending[0].resolve(" hello "); // leading/trailing space trimmed
|
||||
h.pending[1].resolve(" "); // whitespace-only -> not emitted, but seq advances
|
||||
h.pending[2].resolve("world");
|
||||
});
|
||||
|
||||
expect(emitted).toEqual(["hello", "world"]);
|
||||
});
|
||||
|
||||
it("a failed segment shows one notification and is skipped so later segments still flush in order", async () => {
|
||||
const emitted: string[] = [];
|
||||
await startRecording((t) => emitted.push(t));
|
||||
await emitSegments(2);
|
||||
|
||||
// seq 0 fails: the user sees a notification and the emitter advances past it.
|
||||
await act(async () => {
|
||||
h.pending[0].reject({ message: "boom" });
|
||||
});
|
||||
expect(notifyShow).toHaveBeenCalledTimes(1);
|
||||
expect(emitted).toEqual([]);
|
||||
|
||||
// seq 1 still flushes (it is now next), proving one failure did not stall.
|
||||
await act(async () => {
|
||||
h.pending[1].resolve("survivor");
|
||||
});
|
||||
expect(emitted).toEqual(["survivor"]);
|
||||
});
|
||||
|
||||
it("an OUT-OF-ORDER failed segment is buffered as empty and skipped without stalling later text", async () => {
|
||||
const emitted: string[] = [];
|
||||
await startRecording((t) => emitted.push(t));
|
||||
await emitSegments(3);
|
||||
|
||||
// seq 1 (NOT next-to-emit) fails first: it takes the else branch — an empty
|
||||
// placeholder is buffered (resultsRef.set(seq, "")) so the emitter can later
|
||||
// skip it. One notification, nothing emitted yet (seq 0 still gates).
|
||||
await act(async () => {
|
||||
h.pending[1].reject({ message: "boom" });
|
||||
});
|
||||
expect(notifyShow).toHaveBeenCalledTimes(1);
|
||||
expect(emitted).toEqual([]);
|
||||
|
||||
// seq 0 flushes; the drain then reaches the buffered empty seq 1 and SKIPS
|
||||
// past it to seq 2.
|
||||
await act(async () => {
|
||||
h.pending[0].resolve("alpha");
|
||||
});
|
||||
expect(emitted).toEqual(["alpha"]);
|
||||
|
||||
// seq 2 emits — proving the empty placeholder let the emitter advance past
|
||||
// the failed seq 1. Without the else branch's placeholder the drain would
|
||||
// stall at the missing seq 1 and "gamma" would never flush.
|
||||
await act(async () => {
|
||||
h.pending[2].resolve("gamma");
|
||||
});
|
||||
expect(emitted).toEqual(["alpha", "gamma"]);
|
||||
});
|
||||
|
||||
it("ignores a transcription that resolves AFTER cancel() (stale epoch — no emit)", async () => {
|
||||
const emitted: string[] = [];
|
||||
const hook = await startRecording((t) => emitted.push(t));
|
||||
await emitSegments(1);
|
||||
|
||||
// Hard discard the session: the in-flight request is now stale.
|
||||
act(() => {
|
||||
hook.result.current.cancel();
|
||||
});
|
||||
expect(hook.result.current.status).toBe("idle");
|
||||
|
||||
// Its late resolution must be dropped (no emit into the new/empty session).
|
||||
await act(async () => {
|
||||
h.pending[0].resolve("late");
|
||||
});
|
||||
expect(emitted).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
@@ -10,6 +17,7 @@ import {
|
||||
IconUnderline,
|
||||
IconMessage,
|
||||
IconEyeOff,
|
||||
IconClearFormatting,
|
||||
} from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import classes from "./bubble-menu.module.css";
|
||||
@@ -28,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"> & {
|
||||
@@ -76,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),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -117,6 +161,26 @@ 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.
|
||||
isActive: () => false,
|
||||
// Mirror the fixed-toolbar behavior: strip all inline marks from the selection.
|
||||
command: () => props.editor.chain().focus().unsetAllMarks().run(),
|
||||
icon: IconClearFormatting,
|
||||
},
|
||||
];
|
||||
|
||||
const commentItem: BubbleMenuItem = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
|
||||
// Covers the read-only render branch (PR #278): the language <Select> renders
|
||||
// only when `editor.isEditable`; in read-only the copy button still shows.
|
||||
// Mocks mirror the #146 structural harness (footnote-views.structure.test.tsx),
|
||||
// except Select becomes a detectable node so we can assert its presence/absence.
|
||||
vi.mock("@tiptap/react", () => ({
|
||||
NodeViewWrapper: ({ children }: any) => <div>{children}</div>,
|
||||
NodeViewContent: (props: any) => <div data-node-view-content="" {...props} />,
|
||||
}));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("@mantine/core", () => ({
|
||||
Group: ({ children }: any) => <div>{children}</div>,
|
||||
Select: () => <div data-testid="language-select" />,
|
||||
Tooltip: ({ children }: any) => <>{children}</>,
|
||||
ActionIcon: ({ children, onClick }: any) => (
|
||||
<button data-testid="copy-button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/common/copy-button", () => ({
|
||||
CopyButton: ({ children }: any) => children({ copied: false, copy: () => {} }),
|
||||
}));
|
||||
vi.mock("@tabler/icons-react", () => ({
|
||||
IconCheck: () => null,
|
||||
IconCopy: () => null,
|
||||
}));
|
||||
vi.mock("@/features/editor/components/code-block/mermaid-view.tsx", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
import CodeBlockView from "./code-block-view";
|
||||
|
||||
const makeProps = (isEditable: boolean) =>
|
||||
({
|
||||
node: { attrs: { language: "javascript" }, textContent: "", nodeSize: 1 },
|
||||
editor: {
|
||||
state: { selection: { from: 0, to: 0 } },
|
||||
isEditable,
|
||||
commands: {},
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
},
|
||||
extension: {
|
||||
options: { lowlight: { listLanguages: () => ["javascript", "python"] } },
|
||||
},
|
||||
getPos: () => 0,
|
||||
updateAttributes: () => {},
|
||||
deleteNode: () => {},
|
||||
}) as any;
|
||||
|
||||
describe("CodeBlockView language selector visibility (#278)", () => {
|
||||
it("renders the language selector when the editor is editable", () => {
|
||||
const { queryByTestId } = render(<CodeBlockView {...makeProps(true)} />);
|
||||
expect(queryByTestId("language-select")).not.toBeNull();
|
||||
expect(queryByTestId("copy-button")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("hides the language selector in read-only but keeps the copy button", () => {
|
||||
const { queryByTestId } = render(<CodeBlockView {...makeProps(false)} />);
|
||||
expect(queryByTestId("language-select")).toBeNull();
|
||||
expect(queryByTestId("copy-button")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -50,10 +50,10 @@ export default function CodeBlockView(props: NodeViewProps) {
|
||||
{/* #146: the editable <pre><code> (contentDOM) MUST come first in the DOM.
|
||||
With the non-editable menu rendered before it, the browser's click
|
||||
hit-testing snapped the caret up one line. Render content first; the
|
||||
menu is rendered after it and lifted back above visually via flex
|
||||
`order: -1` (the `.codeBlock` wrapper is a flex column — see
|
||||
code-block.module.css). It stays fully in flow as a full-width row
|
||||
above the code: no overlay/absolute positioning. The second #146
|
||||
menu is rendered after it and floated into the top-right corner as an
|
||||
absolute overlay (see `.menuGroup` in code-block.module.css, anchored
|
||||
to the `position: relative` `.codeBlock` wrapper in code.css). It no
|
||||
longer takes a full-width row above the code. The second #146
|
||||
mitigation lives in editor-paste-handler.tsx (reflowAfterPaste). */}
|
||||
<pre
|
||||
spellCheck="false"
|
||||
@@ -67,22 +67,23 @@ export default function CodeBlockView(props: NodeViewProps) {
|
||||
<NodeViewContent as="code" className={`language-${language}`} />
|
||||
</pre>
|
||||
|
||||
<Group
|
||||
justify="flex-end"
|
||||
contentEditable={false}
|
||||
className={classes.menuGroup}
|
||||
>
|
||||
<Select
|
||||
placeholder="auto"
|
||||
checkIconPosition="right"
|
||||
data={extension.options.lowlight.listLanguages().sort()}
|
||||
value={languageValue}
|
||||
onChange={changeLanguage}
|
||||
searchable
|
||||
style={{ maxWidth: "130px" }}
|
||||
classNames={{ input: classes.selectInput }}
|
||||
disabled={!editor.isEditable}
|
||||
/>
|
||||
<Group contentEditable={false} className={classes.menuGroup}>
|
||||
{/* In read-only (published) there is no language selector at all —
|
||||
only the copy button. When editable the selector is hidden until
|
||||
the block is hovered/focused (or its dropdown is open) via the
|
||||
`.languageSelect` class (see code-block.module.css). */}
|
||||
{editor.isEditable && (
|
||||
<Select
|
||||
placeholder="auto"
|
||||
checkIconPosition="right"
|
||||
data={extension.options.lowlight.listLanguages().sort()}
|
||||
value={languageValue}
|
||||
onChange={changeLanguage}
|
||||
searchable
|
||||
style={{ maxWidth: "130px" }}
|
||||
classNames={{ root: classes.languageSelect, input: classes.selectInput }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CopyButton value={node?.textContent} timeout={2000}>
|
||||
{({ copied, copy }) => (
|
||||
|
||||
@@ -17,15 +17,37 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* #146: the menu now follows the <pre> in the DOM (so the editable contentDOM is
|
||||
FIRST and click hit-testing is correct). Lift it back ABOVE the code visually
|
||||
with flex `order` — the .codeBlock wrapper is a flex column (see code.css) —
|
||||
so the menu still reads as a row above the code, exactly as before, without
|
||||
sitting in-flow before the contentDOM. */
|
||||
/* #146: the menu follows the <pre> in the DOM (so the editable contentDOM is
|
||||
FIRST and click hit-testing is correct). Instead of sitting in-flow, it is
|
||||
floated into the top-right corner as an absolute overlay anchored to the
|
||||
`position: relative` .codeBlock wrapper (see code.css), so it no longer
|
||||
takes a full-width row above the code. The Mantine dropdown is portaled, so
|
||||
it is never clipped by the overlay. */
|
||||
.menuGroup {
|
||||
order: -1;
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
gap: 4px;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The language selector is hidden until the block is hovered, or the selector
|
||||
itself is focused / its dropdown is open. It keeps its width in the flex
|
||||
Group (only opacity toggles) so the copy button never jumps, and
|
||||
`pointer-events: none` while hidden lets clicks fall through to the code.
|
||||
`.codeBlock` is the global NodeViewWrapper class → use :global(). */
|
||||
.languageSelect {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
:global(.codeBlock):hover .languageSelect,
|
||||
.languageSelect:focus-within {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock the page-service so importing the module under test does not pull in the
|
||||
// axios/api-client chain. `createMentionAction` is wired to `getPageById`; the
|
||||
// spy lets us assert that wiring without any network. `vi.hoisted` keeps the spy
|
||||
// available inside the hoisted vi.mock factory.
|
||||
const { getPageById } = vi.hoisted(() => ({ getPageById: vi.fn() }));
|
||||
vi.mock("@/features/page/services/page-service.ts", () => ({
|
||||
getPageById,
|
||||
}));
|
||||
|
||||
// `uuid` v7 is used for the mention node id; pin only v7 so assertions are
|
||||
// stable, keeping the rest (e.g. `validate`, used by extractPageSlugId) real.
|
||||
vi.mock("uuid", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("uuid")>()),
|
||||
v7: () => "fixed-mention-uuid",
|
||||
}));
|
||||
|
||||
import {
|
||||
handleInternalLink,
|
||||
createMentionAction,
|
||||
} from "./internal-link-paste";
|
||||
|
||||
// Minimal ProseMirror-ish EditorView fake. We record what handleInternalLink
|
||||
// builds and dispatches without standing up a real schema/state.
|
||||
function makeView() {
|
||||
const tr = {
|
||||
replaceWith: vi.fn(function (this: unknown) {
|
||||
return tr;
|
||||
}),
|
||||
insertText: vi.fn(function (this: unknown) {
|
||||
return tr;
|
||||
}),
|
||||
addMark: vi.fn(function (this: unknown) {
|
||||
return tr;
|
||||
}),
|
||||
};
|
||||
const schema = {
|
||||
nodes: {
|
||||
mention: {
|
||||
// Echo the attrs back so we can assert exactly what was created.
|
||||
create: vi.fn((attrs: Record<string, unknown>) => ({
|
||||
type: "mention",
|
||||
attrs,
|
||||
})),
|
||||
},
|
||||
},
|
||||
marks: {
|
||||
link: {
|
||||
create: vi.fn((attrs: Record<string, unknown>) => ({
|
||||
type: "link",
|
||||
attrs,
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
const view = {
|
||||
state: { schema, tr },
|
||||
dispatch: vi.fn(),
|
||||
};
|
||||
return { view, tr, schema };
|
||||
}
|
||||
|
||||
describe("handleInternalLink", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("does nothing when validateFn rejects the url (no resolve, no dispatch)", async () => {
|
||||
const onResolveLink = vi.fn();
|
||||
const validateFn = vi.fn(() => false);
|
||||
const { view } = makeView();
|
||||
|
||||
await handleInternalLink({ validateFn, onResolveLink })(
|
||||
"any-url",
|
||||
view as never,
|
||||
3,
|
||||
"creator-1",
|
||||
);
|
||||
|
||||
expect(validateFn).toHaveBeenCalledWith("any-url", view);
|
||||
expect(onResolveLink).not.toHaveBeenCalled();
|
||||
expect(view.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("on resolve: inserts a mention node carrying the resolved page + anchor and dispatches replaceWith at pos", async () => {
|
||||
const page = {
|
||||
id: "page-id-99",
|
||||
title: "My Page",
|
||||
slugId: "slugABC",
|
||||
};
|
||||
const onResolveLink = vi.fn().mockResolvedValue(page);
|
||||
const { view, tr, schema } = makeView();
|
||||
|
||||
// extractPageSlugId("doc-slug-xyz789") -> "xyz789" (last hyphen segment).
|
||||
await handleInternalLink({ validateFn: () => true, onResolveLink })(
|
||||
"doc-slug-xyz789",
|
||||
view as never,
|
||||
5,
|
||||
"creator-7",
|
||||
"anchor-42",
|
||||
);
|
||||
|
||||
// The linked page id is the extracted slug-id, not the whole url.
|
||||
expect(onResolveLink).toHaveBeenCalledWith("xyz789", "creator-7");
|
||||
expect(schema.nodes.mention.create).toHaveBeenCalledWith({
|
||||
id: "fixed-mention-uuid",
|
||||
label: "My Page",
|
||||
entityType: "page",
|
||||
entityId: "page-id-99",
|
||||
slugId: "slugABC",
|
||||
creatorId: "creator-7",
|
||||
anchorId: "anchor-42",
|
||||
});
|
||||
expect(tr.replaceWith).toHaveBeenCalledWith(5, 5, {
|
||||
type: "mention",
|
||||
attrs: expect.objectContaining({ entityId: "page-id-99" }),
|
||||
});
|
||||
expect(tr.insertText).not.toHaveBeenCalled();
|
||||
expect(view.dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(view.dispatch).toHaveBeenCalledWith(tr);
|
||||
});
|
||||
|
||||
it("falls back to 'Untitled' label when the resolved page has no title", async () => {
|
||||
const onResolveLink = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ id: "p", title: "", slugId: "s" });
|
||||
const { view, schema } = makeView();
|
||||
|
||||
await handleInternalLink({ validateFn: () => true, onResolveLink })(
|
||||
"abc-id1",
|
||||
view as never,
|
||||
0,
|
||||
"c",
|
||||
);
|
||||
|
||||
expect(schema.nodes.mention.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ label: "Untitled" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("on reject: inserts the raw url as plain text with a link mark and dispatches", async () => {
|
||||
const onResolveLink = vi.fn().mockRejectedValue(new Error("not found"));
|
||||
const { view, tr, schema } = makeView();
|
||||
|
||||
await handleInternalLink({ validateFn: () => true, onResolveLink })(
|
||||
"http://x/page-id2",
|
||||
view as never,
|
||||
4,
|
||||
"creator-1",
|
||||
);
|
||||
|
||||
// No mention node on the failure path.
|
||||
expect(schema.nodes.mention.create).not.toHaveBeenCalled();
|
||||
expect(tr.insertText).toHaveBeenCalledWith("http://x/page-id2", 4);
|
||||
expect(schema.marks.link.create).toHaveBeenCalledWith({
|
||||
href: "http://x/page-id2",
|
||||
});
|
||||
// Mark spans exactly the inserted url text: [pos, pos + url.length].
|
||||
expect(tr.addMark).toHaveBeenCalledWith(4, 4 + "http://x/page-id2".length, {
|
||||
type: "link",
|
||||
attrs: { href: "http://x/page-id2" },
|
||||
});
|
||||
expect(view.dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMentionAction", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("resolves the link via getPageById and inserts the mention", async () => {
|
||||
getPageById.mockResolvedValue({
|
||||
id: "real-page",
|
||||
title: "Real",
|
||||
slugId: "rslug",
|
||||
});
|
||||
const { view, schema } = makeView();
|
||||
|
||||
await createMentionAction("ref-pageABC", view as never, 2, "creator-9");
|
||||
|
||||
expect(getPageById).toHaveBeenCalledWith({ pageId: "pageABC" });
|
||||
expect(schema.nodes.mention.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entityId: "real-page", label: "Real" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates a getPageById failure to the plain-link fallback", async () => {
|
||||
getPageById.mockRejectedValue(new Error("404"));
|
||||
const { view, tr } = makeView();
|
||||
|
||||
await createMentionAction("ref-pageABC", view as never, 1, "creator-9");
|
||||
|
||||
// Failure path: the url is inserted as text, not as a mention node.
|
||||
expect(tr.insertText).toHaveBeenCalledWith("ref-pageABC", 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildLayoutCandidates,
|
||||
getSuggestionItems,
|
||||
} from "./menu-items";
|
||||
|
||||
/**
|
||||
* `buildLayoutCandidates` maps a slash query across physical keyboard layouts
|
||||
* (RU ЙЦУКЕН <-> US QWERTY) so the menu matches Latin item titles/terms even
|
||||
* when typed with the wrong layout active, while keeping the original query so
|
||||
* genuine Cyrillic search terms still match. See bug #283.
|
||||
*/
|
||||
describe("buildLayoutCandidates", () => {
|
||||
it("remaps a RU-layout query to its US-QWERTY equivalent (сщву -> code)", () => {
|
||||
expect(buildLayoutCandidates("сщву")).toContain("code");
|
||||
});
|
||||
|
||||
it("remaps a US-layout query to its RU-ЙЦУКЕН equivalent (cyjcrf -> сноска)", () => {
|
||||
expect(buildLayoutCandidates("cyjcrf")).toContain("сноска");
|
||||
});
|
||||
|
||||
it("always includes the original query", () => {
|
||||
expect(buildLayoutCandidates("сщву")).toContain("сщву");
|
||||
expect(buildLayoutCandidates("cyjcrf")).toContain("cyjcrf");
|
||||
expect(buildLayoutCandidates("сноска")).toContain("сноска");
|
||||
});
|
||||
|
||||
it("leaves a query with no mappable keys as a single-element set", () => {
|
||||
// Digits are on neither layout map, so both remaps are no-ops and de-dup
|
||||
// back to one entry.
|
||||
expect(buildLayoutCandidates("123")).toEqual(["123"]);
|
||||
});
|
||||
});
|
||||
|
||||
/** Helper: flatten grouped suggestion items to a flat list of titles. */
|
||||
const titles = (groups: ReturnType<typeof getSuggestionItems>): string[] =>
|
||||
Object.values(groups).flatMap((items) => items.map((i) => i.title));
|
||||
|
||||
describe("getSuggestionItems layout-aware matching", () => {
|
||||
it("finds Code when 'code' is typed in RU layout (/сщву)", () => {
|
||||
expect(titles(getSuggestionItems({ query: "сщву" }))).toContain("Code");
|
||||
});
|
||||
|
||||
it("still finds Code for the plain /code query", () => {
|
||||
expect(titles(getSuggestionItems({ query: "code" }))).toContain("Code");
|
||||
});
|
||||
|
||||
it("finds Code for a short wrong-layout prefix (/сщ -> co)", () => {
|
||||
// "сщ" RU->EN remaps to "co", which fuzzy-matches the "Code" title. Short
|
||||
// remaps are title-only, but a title match must still get through. See #283.
|
||||
expect(titles(getSuggestionItems({ query: "сщ" }))).toContain("Code");
|
||||
});
|
||||
|
||||
it("still finds Code for the plain short query (/co)", () => {
|
||||
// Sanity: the original (non-remapped) short query keeps full matching.
|
||||
expect(titles(getSuggestionItems({ query: "co" }))).toContain("Code");
|
||||
});
|
||||
|
||||
it("still matches genuine Cyrillic search terms (/сноска -> Footnote)", () => {
|
||||
expect(titles(getSuggestionItems({ query: "сноска" }))).toContain(
|
||||
"Footnote",
|
||||
);
|
||||
});
|
||||
|
||||
it("finds Footnote when 'сноска' is typed in EN layout (/cyjcrf)", () => {
|
||||
expect(titles(getSuggestionItems({ query: "cyjcrf" }))).toContain(
|
||||
"Footnote",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not surface Footnote for a short wrong-layout query (/cy)", () => {
|
||||
// "cy" EN->RU remaps to "сн", a substring of the "сноска" searchTerm, but
|
||||
// the gate blocks it because the remapped candidate is < 3 chars.
|
||||
expect(titles(getSuggestionItems({ query: "cy" }))).not.toContain(
|
||||
"Footnote",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not surface Footnote for a single-char wrong-layout query (/b)", () => {
|
||||
// "b" EN->RU remaps to "и", a substring of the "примечание" searchTerm, but
|
||||
// the gate blocks it because the remapped candidate is < 3 chars.
|
||||
expect(titles(getSuggestionItems({ query: "b" }))).not.toContain(
|
||||
"Footnote",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import { PAGE_EMBED_PICKER_EVENT } from "@/features/editor/components/page-embed
|
||||
import {
|
||||
CommandProps,
|
||||
SlashMenuGroupedItemsType,
|
||||
SlashMenuItemType,
|
||||
} from "@/features/editor/components/slash-menu/types";
|
||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
||||
@@ -835,6 +836,49 @@ export function isHtmlEmbedFeatureEnabled(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Russian ЙЦУКЕН -> US QWERTY by physical key position (lowercase; callers
|
||||
// lowercase first). Lets the slash menu match Latin item titles/terms even when
|
||||
// a command is typed with the wrong keyboard layout active (e.g. "/сщву" while
|
||||
// ЙЦУКЕН is on physically types the same keys as "/code").
|
||||
const RU_TO_EN_LAYOUT: Record<string, string> = {
|
||||
й: "q", ц: "w", у: "e", к: "r", е: "t", н: "y", г: "u", ш: "i", щ: "o",
|
||||
з: "p", х: "[", ъ: "]",
|
||||
ф: "a", ы: "s", в: "d", а: "f", п: "g", р: "h", о: "j", л: "k", д: "l",
|
||||
ж: ";", э: "'",
|
||||
я: "z", ч: "x", с: "c", м: "v", и: "b", т: "n", ь: "m", б: ",", ю: ".",
|
||||
ё: "`",
|
||||
};
|
||||
// Inverse map: US QWERTY -> Russian ЙЦУКЕН by physical key position. Handles the
|
||||
// mirror case (e.g. "cyjcrf" typed with EN layout on == "сноска" == Footnote).
|
||||
const EN_TO_RU_LAYOUT: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(RU_TO_EN_LAYOUT).map(([ru, en]) => [en, ru]),
|
||||
);
|
||||
|
||||
function translitByLayout(text: string, map: Record<string, string>): string {
|
||||
let out = "";
|
||||
for (const ch of text) out += map[ch] ?? ch;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the list of search strings to try for a given query: the original
|
||||
* query first, followed by its RU->EN and EN->RU physical-layout remappings.
|
||||
* Keeping the original first preserves genuine Cyrillic search terms (e.g.
|
||||
* "сноска"/"примечание" for Footnote) and lets callers treat the original
|
||||
* differently from the remapped candidates. De-duplication only collapses the
|
||||
* list to one element when nothing is remappable (e.g. digits/spaces), so a
|
||||
* typical ASCII query still yields multiple candidates.
|
||||
*/
|
||||
export function buildLayoutCandidates(search: string): string[] {
|
||||
return [
|
||||
...new Set([
|
||||
search,
|
||||
translitByLayout(search, RU_TO_EN_LAYOUT),
|
||||
translitByLayout(search, EN_TO_RU_LAYOUT),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
export const getSuggestionItems = ({
|
||||
query,
|
||||
excludeItems,
|
||||
@@ -843,6 +887,18 @@ export const getSuggestionItems = ({
|
||||
excludeItems?: Set<string>;
|
||||
}): SlashMenuGroupedItemsType => {
|
||||
const search = query.toLowerCase();
|
||||
const candidates = buildLayoutCandidates(search);
|
||||
// buildLayoutCandidates dedupes the remaps against the original, so
|
||||
// candidates[0] is the original query and the rest are wrong-layout remaps.
|
||||
// The original query matches on everything (title, description, searchTerms).
|
||||
// A remapped candidate matches fully only when it is long enough to be
|
||||
// unambiguous; a short (1-2 char) remap is restricted to a TITLE match so it
|
||||
// does not spuriously substring-match unrelated Cyrillic search terms
|
||||
// (e.g. "/cy" -> "сн" hitting the "сноска" searchTerm, "/b" -> "и" hitting
|
||||
// "примечание"), while still letting a real short wrong-layout prefix through
|
||||
// (e.g. "/сщ" -> "co" fuzzy-matching the "Code" title).
|
||||
const REMAP_FULL_MATCH_MIN_LEN = 3;
|
||||
const [originalCandidate, ...remapped] = candidates;
|
||||
const filteredGroups: SlashMenuGroupedItemsType = {};
|
||||
const htmlEmbedFeatureEnabled = isHtmlEmbedFeatureEnabled();
|
||||
|
||||
@@ -856,24 +912,52 @@ export const getSuggestionItems = ({
|
||||
return false;
|
||||
};
|
||||
|
||||
const candidateMatchesItem = (
|
||||
candidate: string,
|
||||
item: SlashMenuItemType,
|
||||
description: string,
|
||||
titleOnly: boolean,
|
||||
) => {
|
||||
if (fuzzyMatch(candidate, item.title)) return true;
|
||||
if (titleOnly) return false;
|
||||
return (
|
||||
description.includes(candidate) ||
|
||||
(item.searchTerms != null &&
|
||||
item.searchTerms.some((term: string) => term.includes(candidate)))
|
||||
);
|
||||
};
|
||||
|
||||
for (const [group, items] of Object.entries(CommandGroups)) {
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (excludeItems?.has(item.title)) return false;
|
||||
// Hide the HTML embed item unless the workspace master toggle is ON.
|
||||
if (item.requiresHtmlEmbedFeature && !htmlEmbedFeatureEnabled)
|
||||
return false;
|
||||
const description = item.description.toLowerCase();
|
||||
return (
|
||||
fuzzyMatch(search, item.title) ||
|
||||
item.description.toLowerCase().includes(search) ||
|
||||
(item.searchTerms &&
|
||||
item.searchTerms.some((term: string) => term.includes(search)))
|
||||
candidateMatchesItem(originalCandidate, item, description, false) ||
|
||||
remapped.some((candidate) =>
|
||||
candidateMatchesItem(
|
||||
candidate,
|
||||
item,
|
||||
description,
|
||||
candidate.length < REMAP_FULL_MATCH_MIN_LEN,
|
||||
),
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
if (filteredItems.length) {
|
||||
const titleMatchesAnyCandidate = (title: string) => {
|
||||
const lower = title.toLowerCase();
|
||||
return (
|
||||
lower.includes(originalCandidate) ||
|
||||
remapped.some((candidate) => lower.includes(candidate))
|
||||
);
|
||||
};
|
||||
filteredGroups[group] = filteredItems.sort((a, b) => {
|
||||
const aTitle = a.title.toLowerCase().includes(search) ? 0 : 1;
|
||||
const bTitle = b.title.toLowerCase().includes(search) ? 0 : 1;
|
||||
const aTitle = titleMatchesAnyCandidate(a.title) ? 0 : 1;
|
||||
const bTitle = titleMatchesAnyCandidate(b.title) ? 0 : 1;
|
||||
return aTitle - bTitle;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useScrollPosition } from "./use-scroll-position";
|
||||
|
||||
const KEY_PREFIX = "gitmost:scroll-position:";
|
||||
|
||||
function setScrollY(value: number): void {
|
||||
Object.defineProperty(window, "scrollY", {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
function setScrollHeight(value: number): void {
|
||||
Object.defineProperty(document.documentElement, "scrollHeight", {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
function setInnerHeight(value: number): void {
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
describe("useScrollPosition", () => {
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
setScrollY(0);
|
||||
setScrollHeight(0);
|
||||
setInnerHeight(800);
|
||||
// jsdom does not implement window.scrollTo; stub it.
|
||||
window.scrollTo = vi.fn();
|
||||
// Ensure no anchor leaks between tests.
|
||||
window.location.hash = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
window.location.hash = "";
|
||||
});
|
||||
|
||||
it("(a) saves window.scrollY to sessionStorage under the pageId key, throttled", () => {
|
||||
vi.useFakeTimers();
|
||||
const { unmount } = renderHook(() => useScrollPosition("p1"));
|
||||
|
||||
// Leading-edge save fires immediately.
|
||||
setScrollY(123);
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(window.sessionStorage.getItem(`${KEY_PREFIX}p1`)).toBe("123");
|
||||
|
||||
// Within the throttle window the next scroll is suppressed.
|
||||
setScrollY(456);
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(window.sessionStorage.getItem(`${KEY_PREFIX}p1`)).toBe("123");
|
||||
|
||||
// After the throttle window elapses, the next scroll persists again.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
setScrollY(789);
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(window.sessionStorage.getItem(`${KEY_PREFIX}p1`)).toBe("789");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("(a2) the restore target is captured at mount and survives a fresh scroll@0 clobber", () => {
|
||||
vi.useFakeTimers();
|
||||
// A previous session saved 500.
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}clob`, "500");
|
||||
|
||||
const { result } = renderHook(() => useScrollPosition("clob"));
|
||||
|
||||
// On load the page is at the top; a scroll@0 fires and overwrites storage
|
||||
// with 0. This is exactly the clobber the synchronous mount-capture defends
|
||||
// against: the stored value becomes "0", but the target was already captured.
|
||||
setScrollY(0);
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(window.sessionStorage.getItem(`${KEY_PREFIX}clob`)).toBe("0");
|
||||
|
||||
// Restore still scrolls to 500 (the captured target), NOT the clobbered 0.
|
||||
// If the capture were moved into an effect (after handlers register), it
|
||||
// would read the clobbered 0 and this assertion would fail.
|
||||
setScrollHeight(2000); // maxScroll = 1200 >= 500
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
});
|
||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
||||
});
|
||||
|
||||
it("(a3) restores at most once per mount even if called again", () => {
|
||||
vi.useFakeTimers();
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}once`, "500");
|
||||
setScrollHeight(2000); // tall enough to restore synchronously
|
||||
|
||||
const { result } = renderHook(() => useScrollPosition("once"));
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
});
|
||||
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A second call (e.g. the wiring effect re-running on [showStatic, editor,
|
||||
// restoreScrollPosition]) must NOT scroll again and yank the reader.
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
});
|
||||
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("(b) does not restore when the URL has a #hash anchor", () => {
|
||||
vi.useFakeTimers();
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}p2`, "500");
|
||||
// Content is ALREADY tall enough (maxScroll = 2000 - 800 = 1200 >= 500), so
|
||||
// without the hash guard tryRestore would call scrollTo synchronously on the
|
||||
// first tick. The assertion below therefore genuinely proves the hash guard
|
||||
// short-circuits before any scroll (not just that the poll has not fired).
|
||||
setScrollHeight(2000);
|
||||
window.location.hash = "#some-heading";
|
||||
|
||||
const { result } = renderHook(() => useScrollPosition("p2"));
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("(f) cancels the in-flight restore poll on unmount (no scroll on the next page)", () => {
|
||||
vi.useFakeTimers();
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}p7`, "500");
|
||||
setInnerHeight(800);
|
||||
setScrollHeight(100); // maxScroll = -700: target not reachable yet, so it polls.
|
||||
|
||||
const { result, unmount } = renderHook(() => useScrollPosition("p7"));
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
});
|
||||
expect(window.scrollTo).not.toHaveBeenCalled(); // still polling
|
||||
|
||||
// Navigate away (the hook unmounts) BEFORE the content grows tall enough.
|
||||
unmount();
|
||||
|
||||
// Content of the NEXT page becomes tall; advancing time must NOT resurrect
|
||||
// the cancelled poll (without the cleanup it would scroll the new page).
|
||||
setScrollHeight(2000);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("(c) does nothing when nothing is saved or the saved value is <= 0", () => {
|
||||
// Nothing saved.
|
||||
const a = renderHook(() => useScrollPosition("nope"));
|
||||
act(() => {
|
||||
a.result.current.restoreScrollPosition();
|
||||
});
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
// Saved value <= 0.
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}zero`, "0");
|
||||
const b = renderHook(() => useScrollPosition("zero"));
|
||||
act(() => {
|
||||
b.result.current.restoreScrollPosition();
|
||||
});
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("(d) scrolls to the saved Y once the content is tall enough", () => {
|
||||
vi.useFakeTimers();
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}p4`, "500");
|
||||
setInnerHeight(800);
|
||||
setScrollHeight(100); // maxScroll = -700, target not yet reachable.
|
||||
|
||||
const { result } = renderHook(() => useScrollPosition("p4"));
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
});
|
||||
|
||||
// Still polling: content not laid out yet.
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
// Content becomes tall enough: maxScroll = 2000 - 800 = 1200 >= 500.
|
||||
setScrollHeight(2000);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
||||
});
|
||||
|
||||
it("(d2) clamps to the max reachable position after the timeout", () => {
|
||||
vi.useFakeTimers();
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}p5`, "5000");
|
||||
setInnerHeight(800);
|
||||
setScrollHeight(1000); // maxScroll stays 200, never reaches 5000.
|
||||
|
||||
const { result } = renderHook(() => useScrollPosition("p5"));
|
||||
act(() => {
|
||||
result.current.restoreScrollPosition();
|
||||
});
|
||||
|
||||
// Advance past the 5s timeout; restore should fire clamped to maxScroll.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 200, behavior: "auto" });
|
||||
});
|
||||
|
||||
it("(e) never throws when storage access throws", () => {
|
||||
const err = new Error("storage denied");
|
||||
vi.spyOn(window.sessionStorage, "getItem").mockImplementation(() => {
|
||||
throw err;
|
||||
});
|
||||
vi.spyOn(window.sessionStorage, "setItem").mockImplementation(() => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
const { result, unmount } = renderHook(() => useScrollPosition("p6"));
|
||||
act(() => {
|
||||
setScrollY(42);
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
result.current.restoreScrollPosition();
|
||||
});
|
||||
unmount();
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
// Throttle interval for persisting the scroll position while the user reads.
|
||||
const SAVE_THROTTLE_MS = 250;
|
||||
// Give up polling for the live content height after this long and restore to
|
||||
// the furthest reachable position (handles "collab never finishes laying out").
|
||||
const MAX_RESTORE_WAIT_MS = 5000;
|
||||
// How often to re-check the document height while waiting for content to load.
|
||||
const RESTORE_POLL_MS = 100;
|
||||
|
||||
// sessionStorage key prefix. sessionStorage survives an F5 in the same tab and
|
||||
// is cleared on tab close, which is exactly the lifetime we want for an MVP
|
||||
// "remember where I was reading" feature (self-limiting, no cross-tab leak).
|
||||
const STORAGE_PREFIX = "gitmost:scroll-position:";
|
||||
|
||||
function storageKey(pageId: string): string {
|
||||
return `${STORAGE_PREFIX}${pageId}`;
|
||||
}
|
||||
|
||||
// All storage access is wrapped: private mode / quota / disabled storage must
|
||||
// never throw out of the hook and break the page.
|
||||
function readStorage(pageId: string): number | null {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(storageKey(pageId));
|
||||
if (raw === null) return null;
|
||||
const value = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
} catch (err) {
|
||||
// Best-effort feature: storage may be unavailable (private mode / quota).
|
||||
// No user-facing notification (a missed scroll restore is not actionable),
|
||||
// but log per the AGENTS.md "errors must never be swallowed" rule.
|
||||
console.warn("[useScrollPosition] sessionStorage read failed", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(pageId: string, scrollY: number): void {
|
||||
try {
|
||||
window.sessionStorage.setItem(storageKey(pageId), String(Math.round(scrollY)));
|
||||
} catch (err) {
|
||||
// Storage unavailable (private mode / quota). Non-actionable for the user,
|
||||
// but log it rather than swallow silently (AGENTS.md error-handling rule).
|
||||
console.warn("[useScrollPosition] sessionStorage write failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists and restores the window scroll position per page so a reader keeps
|
||||
* their place across a reload (F5) or reopening the document.
|
||||
*
|
||||
* Returns `restoreScrollPosition`, which the page editor calls once the live
|
||||
* (non-static) content is laid out. The two scroll mechanisms are mutually
|
||||
* exclusive: if the URL has a `#hash` anchor, the existing anchor-scroll logic
|
||||
* wins and restore is a no-op.
|
||||
*/
|
||||
export function useScrollPosition(pageId: string): {
|
||||
restoreScrollPosition: () => void;
|
||||
} {
|
||||
// CONTRACT: this hook assumes PageEditor REMOUNTS per page — page.tsx renders
|
||||
// `<MemoizedFullEditor key={page.id} ...>`, so switching pages creates a fresh
|
||||
// hook instance with fresh refs. These refs latch per-mount and are NOT reset
|
||||
// when `pageId` changes in place (only the effect re-runs on [pageId]). If that
|
||||
// `key={page.id}` is ever removed, restore would silently break on the 2nd page
|
||||
// (refs would hold the first page's target / already-restored flag) — in that
|
||||
// case the refs must be reset on a pageId change.
|
||||
//
|
||||
// The target Y captured synchronously at mount, BEFORE any scroll/visibility
|
||||
// handler can overwrite the stored value with a fresh 0 (the page starts
|
||||
// scrolled to top on load). `null` means "not yet captured".
|
||||
const initialTargetRef = useRef<number | null>(null);
|
||||
// Guards so restore runs at most once per page mount.
|
||||
const hasRestoredRef = useRef(false);
|
||||
// Holds the in-flight restore poll timer so the cleanup can cancel it: without
|
||||
// this, a fast SPA navigation away mid-poll would let the old page's poll fire
|
||||
// window.scrollTo against the NEW page's document (visible wrong-page scroll).
|
||||
const pollTimerRef = useRef<number | null>(null);
|
||||
|
||||
// Capture the previously-saved value synchronously during render, before the
|
||||
// effect below registers handlers that would persist the current (0) scrollY.
|
||||
if (initialTargetRef.current === null) {
|
||||
const saved = readStorage(pageId);
|
||||
// Store 0 when nothing is saved so the "already captured" check (!== null)
|
||||
// holds; restore treats targetY <= 0 as a no-op anyway.
|
||||
initialTargetRef.current = saved ?? 0;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let throttleTimer: number | null = null;
|
||||
|
||||
const save = () => {
|
||||
writeStorage(pageId, window.scrollY);
|
||||
};
|
||||
|
||||
// Throttle the high-frequency scroll handler: persist immediately on the
|
||||
// leading edge, then at most once per SAVE_THROTTLE_MS.
|
||||
const onScroll = () => {
|
||||
if (throttleTimer !== null) return;
|
||||
save();
|
||||
throttleTimer = window.setTimeout(() => {
|
||||
throttleTimer = null;
|
||||
}, SAVE_THROTTLE_MS);
|
||||
};
|
||||
|
||||
// pagehide fires on reload/navigation (more reliable than unload); save now.
|
||||
const onPageHide = () => {
|
||||
save();
|
||||
};
|
||||
|
||||
// Save when the tab is being backgrounded — covers mobile where pagehide is
|
||||
// not always emitted.
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
save();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
window.addEventListener("pagehide", onPageHide);
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
window.removeEventListener("pagehide", onPageHide);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
if (throttleTimer !== null) {
|
||||
window.clearTimeout(throttleTimer);
|
||||
throttleTimer = null;
|
||||
}
|
||||
// Cancel any in-flight restore poll so it cannot scroll the next page.
|
||||
if (pollTimerRef.current !== null) {
|
||||
window.clearTimeout(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
// SPA navigation away from this page: persist the final position.
|
||||
save();
|
||||
};
|
||||
}, [pageId]);
|
||||
|
||||
const restoreScrollPosition = useCallback(() => {
|
||||
// Run at most once per page mount.
|
||||
if (hasRestoredRef.current) return;
|
||||
hasRestoredRef.current = true;
|
||||
|
||||
// Anchor priority: a `#hash` in the URL is handled by useEditorScroll.
|
||||
if (window.location.hash) return;
|
||||
|
||||
const targetY = initialTargetRef.current ?? 0;
|
||||
// Nothing meaningful to restore to.
|
||||
if (targetY <= 0) return;
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
const tryRestore = () => {
|
||||
const maxScroll =
|
||||
document.documentElement.scrollHeight - window.innerHeight;
|
||||
const timedOut = Date.now() - start >= MAX_RESTORE_WAIT_MS;
|
||||
|
||||
// Restore once the content is tall enough to reach the target, or bail out
|
||||
// after the timeout and scroll as far as currently possible.
|
||||
if (maxScroll >= targetY || timedOut) {
|
||||
window.scrollTo({
|
||||
top: Math.min(targetY, Math.max(maxScroll, 0)),
|
||||
behavior: "auto",
|
||||
});
|
||||
pollTimerRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Stored in a ref so the effect cleanup can cancel it on unmount.
|
||||
pollTimerRef.current = window.setTimeout(tryRestore, RESTORE_POLL_MS);
|
||||
};
|
||||
|
||||
tryRestore();
|
||||
}, []);
|
||||
|
||||
return { restoreScrollPosition };
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
showReadOnlyCommentPopupAtom,
|
||||
} from "@/features/comment/atoms/comment-atom";
|
||||
import CommentDialog from "@/features/comment/components/comment-dialog";
|
||||
import CommentHoverPreview from "@/features/comment/components/comment-hover-preview";
|
||||
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
|
||||
import { ReadonlyBubbleMenu } from "@/features/editor/components/bubble-menu/readonly-bubble-menu";
|
||||
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
||||
@@ -66,6 +67,7 @@ import {
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||
import { useScrollPosition } from "./hooks/use-scroll-position";
|
||||
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
||||
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
||||
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
|
||||
@@ -117,6 +119,13 @@ export default function PageEditor({
|
||||
[isComponentMounted],
|
||||
);
|
||||
const { handleScrollTo } = useEditorScroll({ canScroll });
|
||||
// Scroll-position restore hook from develop. The provider state that develop
|
||||
// also declared here (providersRef / providersReady useState) is intentionally
|
||||
// dropped: the offline-sync feature moved provider creation into the shared
|
||||
// usePageCollabProviders context, and `providersReady` is derived from that
|
||||
// context below (via useEditorProviders), so redeclaring it here would shadow
|
||||
// and conflict with the branch's provider model.
|
||||
const { restoreScrollPosition } = useScrollPosition(pageId);
|
||||
|
||||
// Shared providers + Y.Doc lifted into full-editor via context. The provider
|
||||
// lifecycle (creation, idle/visibility connect, attach, destroy, token
|
||||
@@ -341,6 +350,11 @@ export default function PageEditor({
|
||||
}
|
||||
}, [yjsConnectionStatus, isSynced]);
|
||||
|
||||
// Restore the saved reading position once the live content is laid out.
|
||||
useEffect(() => {
|
||||
if (!showStatic && editor) restoreScrollPosition();
|
||||
}, [showStatic, editor, restoreScrollPosition]);
|
||||
|
||||
return (
|
||||
<TransclusionLookupProvider>
|
||||
<PageEmbedLookupProvider>
|
||||
@@ -388,6 +402,11 @@ export default function PageEditor({
|
||||
<div ref={menuContainerRef}>
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
<CommentHoverPreview
|
||||
pageId={pageId}
|
||||
containerRef={menuContainerRef}
|
||||
/>
|
||||
|
||||
{editor && (
|
||||
<SearchAndReplaceDialog editor={editor} editable={editable} />
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
.ProseMirror {
|
||||
.codeBlock {
|
||||
/* #146: flex column so the menu (rendered AFTER <pre> in the DOM, so the
|
||||
editable contentDOM is first) is lifted back above the code via `order`. */
|
||||
/* #146: flex column keeps the editable <pre> (first in the DOM so click
|
||||
hit-testing is correct) laid out above any Mermaid diagram. `position:
|
||||
relative` anchors the control panel, which is floated into the top-right
|
||||
corner as an absolute overlay (see `.menuGroup` in code-block.module.css). */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
padding: 4px;
|
||||
border-radius: var(--mantine-radius-default);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Button, Group, Paper, Text } from "@mantine/core";
|
||||
import { IconClockHour4 } from "@tabler/icons-react";
|
||||
import { IconClockHour4, IconTrash } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||
import {
|
||||
useToggleTemporaryMutation,
|
||||
syncTemporaryExpiresInCache,
|
||||
@@ -31,6 +33,11 @@ export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
||||
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
|
||||
const expiresTimeAgo = useTimeAgo(page?.temporaryExpiresAt);
|
||||
const toggleTemporary = useToggleTemporaryMutation();
|
||||
// Reuse the exact soft-delete path the tree/header menus use: optimistic
|
||||
// tree removal, the "Page moved to trash" undo-toast, the deletedAt cache
|
||||
// stamp, and the redirect to space home (which unmounts this banner).
|
||||
const { handleDelete: trashPage } = useTreeMutation(page?.spaceId ?? "");
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Don't show on a note that is already in trash; the deleted-page banner
|
||||
// owns that state.
|
||||
@@ -38,6 +45,16 @@ export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
||||
|
||||
const canEdit = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Page);
|
||||
|
||||
const handleTrashNow = async () => {
|
||||
// No confirm modal by convention — the undo-toast is the safety net.
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await trashPage(page.id);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMakePermanent = async () => {
|
||||
try {
|
||||
const res = await toggleTemporary.mutateAsync({
|
||||
@@ -70,16 +87,28 @@ export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
||||
</Text>
|
||||
</Group>
|
||||
{canEdit && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={handleMakePermanent}
|
||||
loading={toggleTemporary.isPending}
|
||||
>
|
||||
{t("Make permanent")}
|
||||
</Button>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={handleTrashNow}
|
||||
loading={isDeleting}
|
||||
>
|
||||
{t("Move to trash")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={handleMakePermanent}
|
||||
loading={toggleTemporary.isPending}
|
||||
>
|
||||
{t("Make permanent")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
+50
-7
@@ -77,7 +77,9 @@ describe('resolveKeyField (write-only key payload)', () => {
|
||||
|
||||
describe('nextReindexPollInterval', () => {
|
||||
const INTERVAL = 5000;
|
||||
const base = { now: 1_000, intervalMs: INTERVAL };
|
||||
// `seenActive: true` is the steady state for most of a run — a poll has
|
||||
// observed `reindexing === true` (the server pre-seeds it from enqueue time).
|
||||
const base = { now: 1_000, intervalMs: INTERVAL, seenActive: true };
|
||||
|
||||
it('does not poll when no reindex deadline is set', () => {
|
||||
expect(
|
||||
@@ -111,7 +113,7 @@ describe('nextReindexPollInterval', () => {
|
||||
).toBe(INTERVAL);
|
||||
});
|
||||
|
||||
it('stops once the run is finished AND fully indexed', () => {
|
||||
it('stops once the run is finished AND fully indexed (after having been active)', () => {
|
||||
expect(
|
||||
nextReindexPollInterval({
|
||||
...base,
|
||||
@@ -121,11 +123,29 @@ describe('nextReindexPollInterval', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT stop on the stale pre-reindex snapshot (fully indexed, never seen active)', () => {
|
||||
// Regression for #262: right after "Reindex now" the client still holds the
|
||||
// PRE-reindex settings (an already fully-indexed workspace reads as
|
||||
// reindexing=false, indexed>=total). Without the seenActive gate this looked
|
||||
// "done" and stopped polling on the very first tick, freezing the counter at
|
||||
// 0 until a manual reload. The fresh window has not observed the active run,
|
||||
// so polling must continue until the first real poll lands.
|
||||
expect(
|
||||
nextReindexPollInterval({
|
||||
...base,
|
||||
seenActive: false,
|
||||
deadline: 10_000,
|
||||
status: { reindexing: false, indexedPages: 478, totalPages: 478 },
|
||||
}),
|
||||
).toBe(INTERVAL);
|
||||
});
|
||||
|
||||
it('keeps polling within the deadline when not yet done and no active flag', () => {
|
||||
// First poll right after enqueue, before the worker publishes progress.
|
||||
expect(
|
||||
nextReindexPollInterval({
|
||||
...base,
|
||||
seenActive: false,
|
||||
deadline: 10_000,
|
||||
status: { reindexing: false, indexedPages: 0, totalPages: 478 },
|
||||
}),
|
||||
@@ -138,12 +158,15 @@ describe('nextReindexPollInterval', () => {
|
||||
deadline: 1_000,
|
||||
now: 2_000, // past the deadline
|
||||
intervalMs: INTERVAL,
|
||||
seenActive: true,
|
||||
status: { reindexing: true, indexedPages: 200, totalPages: 478 },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('stops on an empty workspace (0 of 0) once the run is finished', () => {
|
||||
// The pre-seed publishes reindexing=true even for 0 pages, so a poll sees the
|
||||
// run active before the worker clears -> seenActive latches true.
|
||||
expect(
|
||||
nextReindexPollInterval({
|
||||
...base,
|
||||
@@ -156,26 +179,46 @@ describe('nextReindexPollInterval', () => {
|
||||
|
||||
describe('isReindexComplete', () => {
|
||||
it('false when no status yet', () => {
|
||||
expect(isReindexComplete(undefined)).toBe(false);
|
||||
expect(isReindexComplete(undefined, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('false while a run is still active (even at indexed==total)', () => {
|
||||
expect(
|
||||
isReindexComplete({ reindexing: true, indexedPages: 478, totalPages: 478 }),
|
||||
isReindexComplete(
|
||||
{ reindexing: true, indexedPages: 478, totalPages: 478 },
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('false when finished but not yet fully indexed', () => {
|
||||
expect(
|
||||
isReindexComplete({ reindexing: false, indexedPages: 120, totalPages: 478 }),
|
||||
isReindexComplete(
|
||||
{ reindexing: false, indexedPages: 120, totalPages: 478 },
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('true once finished and fully indexed', () => {
|
||||
it('true once finished and fully indexed (after having been active)', () => {
|
||||
expect(
|
||||
isReindexComplete({ reindexing: false, indexedPages: 478, totalPages: 478 }),
|
||||
isReindexComplete(
|
||||
{ reindexing: false, indexedPages: 478, totalPages: 478 },
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('false on the stale pre-reindex snapshot: finished+fully indexed but never seen active', () => {
|
||||
// The just-started edge: the gate keeps this from clearing the poll deadline
|
||||
// before the first post-reindex poll arrives.
|
||||
expect(
|
||||
isReindexComplete(
|
||||
{ reindexing: false, indexedPages: 478, totalPages: 478 },
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isReindexButtonLoading', () => {
|
||||
|
||||
+58
-17
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -185,14 +185,23 @@ type ReindexStatus = Pick<
|
||||
* has finished AND everything is indexed (server cleared its progress record and
|
||||
* fell back to the DB coverage count), or the deadline cap is hit — the cap
|
||||
* always wins so a stuck/never-clearing progress record can't poll forever.
|
||||
*
|
||||
* `seenActive` guards the just-started window: right after "Reindex now" the
|
||||
* client still holds the PRE-reindex settings snapshot, which for an already
|
||||
* fully-indexed workspace reads as `reindexing=false, indexed>=total`. Treating
|
||||
* that stale snapshot as "done" would stop polling before the first post-reindex
|
||||
* poll ever lands (counter frozen at 0). So completion is only honored once a
|
||||
* poll has actually observed the active run (the enqueue-time pre-seed makes
|
||||
* `reindexing=true` visible from the first poll until the run truly clears).
|
||||
*/
|
||||
export function nextReindexPollInterval(args: {
|
||||
deadline: number | null;
|
||||
now: number;
|
||||
intervalMs: number;
|
||||
status?: ReindexStatus;
|
||||
seenActive: boolean;
|
||||
}): number | false {
|
||||
const { deadline, now, intervalMs, status } = args;
|
||||
const { deadline, now, intervalMs, status, seenActive } = args;
|
||||
if (deadline === null) return false;
|
||||
// Cap always wins.
|
||||
if (now > deadline) return false;
|
||||
@@ -200,20 +209,33 @@ export function nextReindexPollInterval(args: {
|
||||
if (status?.reindexing) return intervalMs;
|
||||
// Finished and fully indexed (incl. an empty workspace, 0 >= 0) → stop. Reuse
|
||||
// isReindexComplete so the completeness check lives in exactly one place.
|
||||
if (isReindexComplete(status)) return false;
|
||||
if (isReindexComplete(status, seenActive)) return false;
|
||||
// Within the deadline and not yet done → keep polling.
|
||||
return intervalMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the reindex poll deadline should be cleared: the server reports no
|
||||
* active run AND the count is complete. The single source of truth for the
|
||||
* "reindex finished" check — `nextReindexPollInterval` reuses it for its stop
|
||||
* condition (sans the cap, which the effect handles via time).
|
||||
* Whether the reindex poll deadline should be cleared: a poll has observed the
|
||||
* active run (`seenActive`) AND the server now reports no active run AND the
|
||||
* count is complete. The single source of truth for the "reindex finished"
|
||||
* check — `nextReindexPollInterval` reuses it for its stop condition (sans the
|
||||
* cap, which the effect handles via time).
|
||||
*
|
||||
* The `seenActive` requirement is what keeps the STALE pre-reindex snapshot
|
||||
* (already fully indexed → `reindexing=false, indexed>=total`) from being read
|
||||
* as "finished" in the window before the first post-reindex poll arrives. Once
|
||||
* a poll has seen `reindexing=true` (guaranteed by the server's enqueue-time
|
||||
* pre-seed for the whole run), this flips to a genuine completion check.
|
||||
*/
|
||||
export function isReindexComplete(status?: ReindexStatus): boolean {
|
||||
export function isReindexComplete(
|
||||
status: ReindexStatus | undefined,
|
||||
seenActive: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
!!status && !status.reindexing && status.indexedPages >= status.totalPages
|
||||
seenActive &&
|
||||
!!status &&
|
||||
!status.reindexing &&
|
||||
status.indexedPages >= status.totalPages
|
||||
);
|
||||
}
|
||||
|
||||
@@ -290,6 +312,14 @@ export default function AiProviderSettings() {
|
||||
const REINDEX_POLL_INTERVAL = 5000; // ms between refetches while indexing
|
||||
const REINDEX_POLL_CAP_MS = 120000; // ~2 min hard cap
|
||||
const [reindexDeadline, setReindexDeadline] = useState<number | null>(null);
|
||||
// Whether any poll in the CURRENT window has actually observed the active run
|
||||
// (`reindexing === true`). Reset when a new reindex is kicked off. Gates the
|
||||
// completion check so the STALE pre-reindex snapshot (an already fully-indexed
|
||||
// workspace reads as `reindexing=false, indexed>=total`) can't be mistaken for
|
||||
// "finished" before the first post-reindex poll lands — which would freeze the
|
||||
// counter at 0 until a manual reload. A ref (not state) because it must not
|
||||
// trigger a render and is only ever read where `reindexing` is already false.
|
||||
const reindexSeenActiveRef = useRef(false);
|
||||
|
||||
// Only admins may read the (masked) AI settings; the server enforces this too.
|
||||
const { data: settings, isLoading } = useAiSettingsQuery(isAdmin, (query) =>
|
||||
@@ -298,6 +328,7 @@ export default function AiProviderSettings() {
|
||||
now: Date.now(),
|
||||
intervalMs: REINDEX_POLL_INTERVAL,
|
||||
status: query.state.data,
|
||||
seenActive: reindexSeenActiveRef.current,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -305,12 +336,17 @@ export default function AiProviderSettings() {
|
||||
// unmount because the deadline state goes away with the component.
|
||||
useEffect(() => {
|
||||
if (reindexDeadline === null) return;
|
||||
// "Done" matches the refetchInterval stop condition: the server reports no
|
||||
// active run AND the count is complete (indexed >= total, incl. an empty
|
||||
// workspace 0 >= 0), so the deadline clears promptly instead of waiting out
|
||||
// the cap. While `reindexing` is still true we keep the deadline so polling
|
||||
// continues for the whole run.
|
||||
if (isReindexComplete(settings)) {
|
||||
// Latch "we have seen the active run" the moment a poll reports it, so the
|
||||
// completion check below (and the refetchInterval's) only fires once the run
|
||||
// has genuinely started — never on the stale pre-reindex snapshot.
|
||||
if (settings?.reindexing) reindexSeenActiveRef.current = true;
|
||||
// "Done" matches the refetchInterval stop condition: a poll has observed the
|
||||
// active run AND the server now reports no active run AND the count is
|
||||
// complete (indexed >= total, incl. an empty workspace 0 >= 0), so the
|
||||
// deadline clears promptly instead of waiting out the cap. While `reindexing`
|
||||
// is still true (or no poll has seen it active yet) we keep the deadline so
|
||||
// polling continues for the whole run.
|
||||
if (isReindexComplete(settings, reindexSeenActiveRef.current)) {
|
||||
setReindexDeadline(null);
|
||||
return;
|
||||
}
|
||||
@@ -1117,8 +1153,13 @@ export default function AiProviderSettings() {
|
||||
reindexMutation.mutate(undefined, {
|
||||
// Begin bounded polling so the counter climbs as the async
|
||||
// background job indexes (it does not update on its own).
|
||||
onSuccess: () =>
|
||||
setReindexDeadline(Date.now() + REINDEX_POLL_CAP_MS),
|
||||
// Clear the "seen active" latch first so this fresh window
|
||||
// doesn't inherit a previous run's completion state and stop
|
||||
// immediately.
|
||||
onSuccess: () => {
|
||||
reindexSeenActiveRef.current = false;
|
||||
setReindexDeadline(Date.now() + REINDEX_POLL_CAP_MS);
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user