Compare commits
2 Commits
feat/268-c
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 2524f39a36 | |||
|
|
ef173f022d |
@@ -197,6 +197,12 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
||||
|
||||
Run from the repo root unless noted. The dev workflow needs **Postgres (with the `pgvector` extension) and Redis** reachable per `.env` (copy `.env.example` → `.env`).
|
||||
|
||||
> **Bringing up a full local stand** (API + client + the separate realtime
|
||||
> collaboration process) has several non-obvious gotchas — a missing collab
|
||||
> server, `APP_SECRET` mismatch between processes, a stale `editor-ext` white-
|
||||
> screening the client, LAN exposure. See **[docs/dev-stand.md](docs/dev-stand.md)**
|
||||
> for the step-by-step and the traps.
|
||||
|
||||
```bash
|
||||
pnpm install # install all workspaces (uses pnpm patches; see package.json `pnpm.patchedDependencies`)
|
||||
pnpm dev # client (Vite) + server (Nest watch) concurrently — primary dev loop
|
||||
@@ -241,6 +247,8 @@ Migration files live in `apps/server/src/database/migrations/` and are named `YY
|
||||
- **API server** — `dist/main` (`apps/server/src/main.ts`), the Fastify HTTP app (`AppModule`).
|
||||
- **Collaboration server** — `dist/collaboration/server/collab-main` (`pnpm collab`), a Hocuspocus/Yjs WebSocket server (`apps/server/src/collaboration/`) handling real-time document editing, persistence, and page-history snapshots. It listens on `COLLAB_PORT` (default `3001`), separate from the API server's `PORT` (default `3000`), and shares state with the API server through Redis.
|
||||
|
||||
`pnpm dev` starts **only** the API server + client — the collaboration process is separate and must be started too, or the editor never connects. See **[docs/dev-stand.md](docs/dev-stand.md)** for running both locally (and why `APP_SECRET` must match between them).
|
||||
|
||||
The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes `robots.txt`, public share pages, and `mcp` from the prefix). A `preHandler` hook enforces that a resolved `workspaceId` exists for most `/api` routes (multi-tenant by hostname/subdomain via `DomainMiddleware`). `GET /api/sb/:id` (the anonymous blob-sandbox read route) is listed in that preHandler's `excludedPaths`, so it is exempt from workspace resolution and carries no session auth at all (its capability is the unguessable UUID + TTL + TLS) — unlike `/api/files/public/...`, which still resolves a workspace and requires a workspace-bound attachment JWT. Auth is JWT (cookie + bearer); authorization is **CASL** (`core/casl`) — every data access is scoped to the user's abilities.
|
||||
|
||||
### Module structure (server)
|
||||
|
||||
@@ -1,434 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,267 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
@@ -42,7 +42,6 @@ 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";
|
||||
@@ -534,11 +533,6 @@ export default function PageEditor({
|
||||
<div ref={menuContainerRef}>
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
<CommentHoverPreview
|
||||
pageId={pageId}
|
||||
containerRef={menuContainerRef}
|
||||
/>
|
||||
|
||||
{editor && (
|
||||
<SearchAndReplaceDialog editor={editor} editable={editable} />
|
||||
)}
|
||||
|
||||
135
docs/dev-stand.md
Normal file
135
docs/dev-stand.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Running a local dev stand
|
||||
|
||||
How to bring up a working local instance (API + client + realtime collaboration)
|
||||
and the non-obvious gotchas that will otherwise eat an hour. Written from real
|
||||
setup pain — read the **Gotchas** section before you start.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node 20+ / pnpm 10+.**
|
||||
- **Postgres with pgvector.** Use the `pgvector/pgvector` image (e.g.
|
||||
`pgvector/pgvector:pg18`). The stock `postgres` image will FAIL the
|
||||
`CREATE EXTENSION vector` migration — the RAG feature stores embeddings in
|
||||
`page_embeddings`.
|
||||
- **Redis** — backs caching, BullMQ queues, the Socket.IO adapter, and collab
|
||||
sync.
|
||||
|
||||
## 1. Environment (`.env`)
|
||||
|
||||
The client (`apps/client/vite.config.ts`) and both server processes read env via
|
||||
`envPath` → the **workspace root `.env`**. Keep a single source of truth. Minimum:
|
||||
|
||||
```dotenv
|
||||
APP_URL=http://localhost:3000
|
||||
PORT=3000
|
||||
APP_SECRET=<one long secret — SAME value everywhere, see gotcha #3>
|
||||
DATABASE_URL="postgresql://<user>:<pass>@localhost:5432/<db>?schema=public"
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
COLLAB_URL=http://localhost:3001 # where the CLIENT connects for realtime
|
||||
COLLAB_PORT=3001 # where the COLLAB server listens
|
||||
STORAGE_DRIVER=local
|
||||
DISABLE_TELEMETRY=true
|
||||
```
|
||||
|
||||
> If you also keep an `apps/server/.env`, its `APP_SECRET` **must match** the
|
||||
> root one (see gotcha #3).
|
||||
|
||||
## 2. Migrations
|
||||
|
||||
Migrations do **not** auto-run in local dev. After a fresh checkout or switching
|
||||
branches, apply them yourself or endpoints touching a new column/table will 500:
|
||||
|
||||
```bash
|
||||
pnpm --filter server migration:latest
|
||||
```
|
||||
|
||||
## 3. Bring it up — THREE processes, not two
|
||||
|
||||
`pnpm dev` starts only the **API server** (Nest, `:3000`) and the **client**
|
||||
(Vite). Realtime collaboration is a **separate process** and `pnpm dev` does NOT
|
||||
start it. You need all three:
|
||||
|
||||
```bash
|
||||
# 1) API + client (from the repo root)
|
||||
pnpm dev
|
||||
# → API http://localhost:3000
|
||||
# → client http://localhost:5173 (Vite; localhost-only by default)
|
||||
|
||||
# 2) Collaboration server — SEPARATE process. Build first (see gotcha #2), then:
|
||||
pnpm --filter server build # produces dist/collaboration/server/collab-main.js
|
||||
pnpm collab:dev # node dist/.../collab-main → listens on :3001 (0.0.0.0)
|
||||
```
|
||||
|
||||
Without step 2 the editor shows **"Real-time editor connection lost. Retrying…"**,
|
||||
stays in read-only *static* mode, and anything that only mounts in the *live*
|
||||
editor won't appear.
|
||||
|
||||
## Seeding a login
|
||||
|
||||
Register through the UI, or reset an existing user's password directly in the DB
|
||||
(the server hashes with `bcrypt`):
|
||||
|
||||
```js
|
||||
// node -e '...' with pg + bcrypt from the repo's node_modules
|
||||
const bcrypt = require("bcrypt");
|
||||
const { Client } = require("pg");
|
||||
(async () => {
|
||||
const hash = await bcrypt.hash("demopass", 10);
|
||||
const c = new Client({ /* DATABASE_URL parts */ });
|
||||
await c.connect();
|
||||
await c.query("update users set password=$1 where email=$2", [hash, "admin@example.com"]);
|
||||
await c.end();
|
||||
})();
|
||||
```
|
||||
|
||||
> **Use a simple one-word password with no special characters** (e.g. `demopass`,
|
||||
> not `Str0ng!Pass@2026`). Demo/test credentials get passed through shells, JSON
|
||||
> payloads, and URLs by scripts and automation, where `!` `@` `$` `&` etc. get
|
||||
> mangled or need escaping — a plain alphanumeric word avoids a whole class of
|
||||
> "wrong password" confusion.
|
||||
|
||||
## Gotchas (the грабли)
|
||||
|
||||
1. **Collaboration is a third process.** `pnpm dev` runs API + client only.
|
||||
Start `pnpm collab:dev` (on `:3001`) separately or the live editor never
|
||||
connects. The client connects to `COLLAB_URL` directly (default
|
||||
`http://localhost:3001`), NOT through the Vite `/collab` proxy — the API
|
||||
server on `:3000` does **not** serve the collab websocket.
|
||||
|
||||
2. **The collab server must be built — you can't run it from source.**
|
||||
`collab:dev` runs `node dist/collaboration/server/collab-main.js`, so run
|
||||
`pnpm --filter server build` first. Running the entry via `tsx`/`ts-node`
|
||||
fails with a NestJS DI error ("dependency … appears to be undefined at
|
||||
runtime") because direct TS execution doesn't emit the decorator metadata the
|
||||
built output has.
|
||||
|
||||
3. **`APP_SECRET` must be identical for the API server and the collab server.**
|
||||
The API issues a collab-token (JWT signed with `APP_SECRET`); the collab
|
||||
server validates it with `APP_SECRET`. If they load different values (e.g. a
|
||||
root `.env` and an `apps/server/.env` with different secrets), every realtime
|
||||
connection is rejected with **`[onAuthenticate] Invalid collab token`** and
|
||||
the editor shows "connection lost". Keep one secret everywhere.
|
||||
|
||||
4. **Vite binds localhost only.** To reach the stand from another machine on the
|
||||
LAN, start the client with `--host` (`pnpm --filter client exec vite --host`)
|
||||
and use the box's LAN IP. The `/api`, `/socket.io`, and `/collab` Vite proxies
|
||||
forward to `APP_URL`, so the API just works over the LAN; realtime needs
|
||||
`COLLAB_URL` reachable from the browser (point it at the LAN IP:3001, and run
|
||||
collab on `0.0.0.0` — it does by default).
|
||||
|
||||
5. **A stale `@docmost/editor-ext` white-screens the client.** The client imports
|
||||
from `@docmost/editor-ext` (a workspace package). If that package's source is
|
||||
behind (missing a newer export, e.g. `Spoiler`), the client dies at load with
|
||||
*"The requested module … does not provide an export named 'Spoiler'"* → blank
|
||||
page. Make sure the workspace `packages/editor-ext` is current for the branch
|
||||
you're running (a stale sibling checkout resolved through a shared
|
||||
`node_modules` symlink is the usual cause).
|
||||
|
||||
6. **pgvector, not stock postgres** (see Prerequisites) — the `vector` extension
|
||||
migration fails otherwise.
|
||||
|
||||
7. **Migrations don't auto-run in dev** — run `migration:latest` after every pull
|
||||
or branch switch.
|
||||
|
||||
See also the **Commands** and **Architecture → Two server processes** sections in
|
||||
[`AGENTS.md`](../AGENTS.md).
|
||||
Reference in New Issue
Block a user