Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c26d136bff | |||
| 86b2d7b350 | |||
| 3085ec1b50 | |||
| 05ec9feaf9 | |||
| 8e12579925 | |||
| 20703d06c2 | |||
| dab2660999 | |||
| 751d55e9db | |||
| 02308012a6 | |||
| 0665fcb630 | |||
| 97bd554cb5 | |||
| f77a6b42de | |||
| 43b11d92ab | |||
| f759084f41 |
@@ -151,6 +151,12 @@ jobs:
|
|||||||
- name: Build editor-ext
|
- name: Build editor-ext
|
||||||
run: pnpm --filter @docmost/editor-ext build
|
run: pnpm --filter @docmost/editor-ext build
|
||||||
|
|
||||||
|
# @docmost/prosemirror-markdown is an ESM workspace package the server
|
||||||
|
# imports at runtime; its build/ is gitignored and test:e2e has no pretest
|
||||||
|
# hook, so build it before the e2e run (mirrors the test.yml job).
|
||||||
|
- name: Build prosemirror-markdown
|
||||||
|
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||||
|
|
||||||
- name: Run migrations
|
- name: Run migrations
|
||||||
run: pnpm --filter ./apps/server migration:latest
|
run: pnpm --filter ./apps/server migration:latest
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
TiptapPdf,
|
TiptapPdf,
|
||||||
PageBreak,
|
PageBreak,
|
||||||
SearchAndReplace,
|
SearchAndReplace,
|
||||||
|
MultiCursor,
|
||||||
Mention,
|
Mention,
|
||||||
TableDndExtension,
|
TableDndExtension,
|
||||||
TableHandleCommandsExtension,
|
TableHandleCommandsExtension,
|
||||||
@@ -447,6 +448,10 @@ export const mainExtensions = [
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
}).configure(),
|
}).configure(),
|
||||||
|
// Multi-cursor editing (MVP / Variant A): select-all-occurrences + type into
|
||||||
|
// all at once. Does not depend on collaboration, so it lives in mainExtensions
|
||||||
|
// (available in both the plain and collaborative editors).
|
||||||
|
MultiCursor,
|
||||||
Columns,
|
Columns,
|
||||||
Column,
|
Column,
|
||||||
AutoJoiner.configure({
|
AutoJoiner.configure({
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
GitmostListPagesResult,
|
GitmostListPagesResult,
|
||||||
GitmostListSpacesResult,
|
GitmostListSpacesResult,
|
||||||
gitmostDecodePayloadToFile,
|
gitmostDecodePayloadToFile,
|
||||||
|
gitmostInsertTranscriptIntoEditor,
|
||||||
gitmostUploadFileToEditor,
|
gitmostUploadFileToEditor,
|
||||||
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||||
|
|
||||||
@@ -281,6 +282,18 @@ export default function GitmostGlobalBridge() {
|
|||||||
pageId: page.id,
|
pageId: page.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best-effort: append the transcript (heading + one paragraph per line)
|
||||||
|
// below the just-inserted audio node. The audio insert already
|
||||||
|
// succeeded, so a transcript failure must NOT turn this into an error —
|
||||||
|
// wrap it and, on any throw, log and still return ok. A missing/empty/
|
||||||
|
// non-string transcript is a no-op inside the helper (audio only).
|
||||||
|
try {
|
||||||
|
gitmostInsertTranscriptIntoEditor(editor, payload?.transcript);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[gitmost] transcript insert failed", err);
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: true, pageId: page.id };
|
return { ok: true, pageId: page.id };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("[gitmost] createPageWithRecording failed", err);
|
console.error("[gitmost] createPageWithRecording failed", err);
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import { Document } from "@tiptap/extension-document";
|
||||||
|
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||||
|
import { Text } from "@tiptap/extension-text";
|
||||||
|
import { Heading } from "@tiptap/extension-heading";
|
||||||
|
import { Bold } from "@tiptap/extension-bold";
|
||||||
|
import { Italic } from "@tiptap/extension-italic";
|
||||||
|
import { Link } from "@tiptap/extension-link";
|
||||||
|
import { gitmostInsertTranscriptIntoEditor } from "./gitmost-recording.ts";
|
||||||
|
|
||||||
|
const ZWSP = ""; // U+200B, the helper's block-trigger neutralizer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #377 — the web-side bridge must append the native host's transcript below the
|
||||||
|
* recording. These exercise the pure insert helper through a REAL Tiptap editor
|
||||||
|
* (Document/Paragraph/Text/Heading + Bold/Italic/Link marks so an HTML-parsing
|
||||||
|
* regression would be caught), asserting the resulting document rather than
|
||||||
|
* mocking the editor: transcript present -> "Transcript" heading + one paragraph
|
||||||
|
* per non-empty line; content is inserted as LITERAL TEXT (no HTML/markdown
|
||||||
|
* parsing); col-0 markdown block triggers are neutralized so git-sync keeps them
|
||||||
|
* paragraphs; absent/empty/non-string -> no-op.
|
||||||
|
*/
|
||||||
|
describe("gitmostInsertTranscriptIntoEditor", () => {
|
||||||
|
const makeEditor = () =>
|
||||||
|
new Editor({
|
||||||
|
// Bold/Italic/Link are registered specifically so that IF the helper ever
|
||||||
|
// regressed to inserting an HTML/markdown string (instead of a text node),
|
||||||
|
// TipTap would parse `<b>`/`*..*`/`[..](..)` into marks and the literal-
|
||||||
|
// text assertions below would fail.
|
||||||
|
extensions: [Document, Paragraph, Text, Heading, Bold, Italic, Link],
|
||||||
|
// Start from a single empty paragraph (a fresh page's baseline). The
|
||||||
|
// helper appends at the end of the doc, i.e. below existing content.
|
||||||
|
content: { type: "doc", content: [{ type: "paragraph" }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts a Transcript heading + one paragraph per non-empty line, verbatim", () => {
|
||||||
|
const editor = makeEditor();
|
||||||
|
|
||||||
|
const inserted = gitmostInsertTranscriptIntoEditor(
|
||||||
|
editor,
|
||||||
|
"You: hello there\nSpeaker 1: hi\n\nYou: bye",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(inserted).toBe(true);
|
||||||
|
|
||||||
|
const nodes = (editor.getJSON().content ?? []) as any[];
|
||||||
|
// A level-2 "Transcript" heading is present.
|
||||||
|
const heading = nodes.find((n) => n.type === "heading");
|
||||||
|
expect(heading?.attrs?.level).toBe(2);
|
||||||
|
expect(heading?.content?.[0]?.text).toBe("Transcript");
|
||||||
|
|
||||||
|
// Every non-empty transcript line becomes a paragraph, in order, verbatim;
|
||||||
|
// the blank line between them is dropped.
|
||||||
|
const texts = nodes
|
||||||
|
.filter((n) => n.type === "paragraph")
|
||||||
|
.map((n) => n.content?.[0]?.text)
|
||||||
|
.filter((t) => typeof t === "string");
|
||||||
|
expect(texts).toEqual(["You: hello there", "Speaker 1: hi", "You: bye"]);
|
||||||
|
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts HTML + markdown metacharacters as LITERAL text (no injection / no mark parsing)", () => {
|
||||||
|
const editor = makeEditor();
|
||||||
|
const line =
|
||||||
|
"You: <b>bold</b> <script>alert(1)</script> and *stars* and [link](x)";
|
||||||
|
|
||||||
|
const inserted = gitmostInsertTranscriptIntoEditor(editor, line);
|
||||||
|
expect(inserted).toBe(true);
|
||||||
|
|
||||||
|
const paras = (editor.getJSON().content ?? []).filter(
|
||||||
|
(n: any) => n.type === "paragraph",
|
||||||
|
) as any[];
|
||||||
|
// The transcript line is exactly ONE paragraph holding a SINGLE text node
|
||||||
|
// whose text is the verbatim string — not split into bold/link/other nodes,
|
||||||
|
// not carrying any marks, not raw HTML. This FAILS if the helper switched to
|
||||||
|
// insertContent(htmlString): TipTap would then parse <b>/[link](x)/*stars*.
|
||||||
|
const content = paras[paras.length - 1].content;
|
||||||
|
expect(content).toHaveLength(1);
|
||||||
|
expect(content[0].type).toBe("text");
|
||||||
|
expect(content[0].marks ?? []).toEqual([]);
|
||||||
|
expect(content[0].text).toBe(line);
|
||||||
|
// And no bold/italic/link mark exists anywhere in the document.
|
||||||
|
const html = editor.getHTML();
|
||||||
|
expect(html).not.toMatch(/<(strong|b|em|i|a)\b/);
|
||||||
|
// The angle brackets survived as escaped entities (literal text), not a live
|
||||||
|
// <script>/<b> element.
|
||||||
|
expect(html).not.toMatch(/<script/i);
|
||||||
|
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes col-0 markdown block triggers with a leading ZWSP (git-sync safety)", () => {
|
||||||
|
const editor = makeEditor();
|
||||||
|
// Trigger lines (some with a leaked indent) + a normal prefixed line.
|
||||||
|
const inserted = gitmostInsertTranscriptIntoEditor(
|
||||||
|
editor,
|
||||||
|
[
|
||||||
|
"- dash",
|
||||||
|
" > quote", // leading indent must be trimmed then neutralized
|
||||||
|
"# hash",
|
||||||
|
"1. one",
|
||||||
|
"> [!info] note",
|
||||||
|
"```js",
|
||||||
|
"---", // solid thematic break -> horizontalRule (text-losing) if unneutralized
|
||||||
|
"***",
|
||||||
|
"___",
|
||||||
|
"You: normal line",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
expect(inserted).toBe(true);
|
||||||
|
|
||||||
|
const texts = (editor.getJSON().content ?? [])
|
||||||
|
.filter((n: any) => n.type === "paragraph")
|
||||||
|
.map((n: any) => n.content?.[0]?.text)
|
||||||
|
.filter((t: any) => typeof t === "string") as string[];
|
||||||
|
|
||||||
|
// Every block-trigger line is prefixed with the invisible ZWSP (indent
|
||||||
|
// trimmed first); the normal `You:` line is left byte-exact.
|
||||||
|
expect(texts).toEqual([
|
||||||
|
ZWSP + "- dash",
|
||||||
|
ZWSP + "> quote",
|
||||||
|
ZWSP + "# hash",
|
||||||
|
ZWSP + "1. one",
|
||||||
|
ZWSP + "> [!info] note",
|
||||||
|
ZWSP + "```js",
|
||||||
|
ZWSP + "---",
|
||||||
|
ZWSP + "***",
|
||||||
|
ZWSP + "___",
|
||||||
|
"You: normal line",
|
||||||
|
]);
|
||||||
|
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op for undefined / empty / whitespace-only / non-string transcripts", () => {
|
||||||
|
for (const value of [undefined, "", " \n \n", 42, {}, null]) {
|
||||||
|
const editor = makeEditor();
|
||||||
|
const before = JSON.stringify(editor.getJSON());
|
||||||
|
|
||||||
|
const inserted = gitmostInsertTranscriptIntoEditor(editor, value as any);
|
||||||
|
|
||||||
|
expect(inserted).toBe(false);
|
||||||
|
// Document is untouched (audio-only behavior preserved).
|
||||||
|
expect(JSON.stringify(editor.getJSON())).toBe(before);
|
||||||
|
editor.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -65,6 +65,11 @@ export interface GitmostCreatePagePayload {
|
|||||||
base64: string;
|
base64: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
|
// Optional transcript for the recording: plain text, `\n`-separated, each
|
||||||
|
// line already formatted as `You: ...` / `Speaker N: ...` by the native host
|
||||||
|
// (ready to insert, no parsing needed). Omitted (no speech / no models) ->
|
||||||
|
// audio only.
|
||||||
|
transcript?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitmostCreatePageResult {
|
export interface GitmostCreatePageResult {
|
||||||
@@ -235,6 +240,83 @@ export async function gitmostUploadFileToEditor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zero-width space (U+200B). Prepended to a transcript line that begins with a
|
||||||
|
// markdown BLOCK trigger: it is invisible in the rendered doc but shifts the
|
||||||
|
// trigger off column 0, so the git-sync doc->markdown->doc round-trip keeps the
|
||||||
|
// line a plain paragraph (see GITMOST_MD_BLOCK_TRIGGER_RE).
|
||||||
|
const GITMOST_ZWSP = "";
|
||||||
|
|
||||||
|
// A markdown BLOCK-level construct that, sitting at column 0 of a paragraph
|
||||||
|
// line, the git-sync markdown serializer (packages/prosemirror-markdown
|
||||||
|
// markdown-converter.ts, `case "paragraph"`) would re-parse into a NON-paragraph
|
||||||
|
// block on the doc->markdown->doc cycle. That serializer emits paragraph text
|
||||||
|
// verbatim with NO block-escape (the pre-existing root cause), so a leading
|
||||||
|
// `#`/`-`/`*`/`+`/`>`, an ordered-list `N.`/`N)`, a code fence ```/~~~, a table
|
||||||
|
// `|`, or a `> [!info]` callout opener would silently become a heading / list /
|
||||||
|
// quote / code block / table / callout. The final alternative matches a WHOLE-
|
||||||
|
// LINE thematic break — solid `---`/`***`/`___` or spaced `- - -`/`_ _ _` (3+ of
|
||||||
|
// the same `-`/`*`/`_`) — which round-trips into a `horizontalRule`; because
|
||||||
|
// that node carries NO text, an un-neutralized separator line would LOSE its
|
||||||
|
// text entirely (worse than the list/quote case). This matches a TRIMMED line's
|
||||||
|
// start; the transcript's own `You:` / `Speaker N:` prefix begins with a letter
|
||||||
|
// and never matches, so prefixed lines are left byte-exact.
|
||||||
|
const GITMOST_MD_BLOCK_TRIGGER_RE =
|
||||||
|
/^(?:#{1,6}(?:\s|$)|[-*+](?:\s|$)|>|\d+[.)](?:\s|$)|```|~~~|\||([-*_])(?:\s*\1){2,}\s*$)/;
|
||||||
|
|
||||||
|
// Append a transcript block BELOW the recording's audio node in a live editor:
|
||||||
|
// a "Transcript" heading followed by one paragraph per non-empty transcript
|
||||||
|
// line. The transcript is plain text, `\n`-separated, each line already
|
||||||
|
// formatted as `You: ...` / `Speaker N: ...` by the native host — line text is
|
||||||
|
// inserted as a TEXT node (never HTML/markdown), so there is no injection or
|
||||||
|
// mark-parsing surface. Each kept line is trimmed (drops an indent that would
|
||||||
|
// both leak into the display and, at col 0, form a markdown block trigger) and,
|
||||||
|
// if it still begins with a col-0 markdown block trigger, gets an invisible
|
||||||
|
// zero-width space prepended so the git-sync round-trip cannot turn it into a
|
||||||
|
// list/quote/heading/callout/code/table (defensive boundary against the
|
||||||
|
// serializer's missing block-escape). This is best-effort and meant to run
|
||||||
|
// AFTER the audio has already been inserted; the caller must guard against a
|
||||||
|
// throw so a transcript failure never fails the (already successful) recording.
|
||||||
|
// Returns true when a block was inserted, false when there was nothing to
|
||||||
|
// insert (transcript undefined/empty/not-a-string). A non-string value is a
|
||||||
|
// no-op, not an error.
|
||||||
|
export function gitmostInsertTranscriptIntoEditor(
|
||||||
|
editor: Editor,
|
||||||
|
transcript: unknown,
|
||||||
|
): boolean {
|
||||||
|
if (typeof transcript !== "string") return false;
|
||||||
|
const lines = transcript
|
||||||
|
.split("\n")
|
||||||
|
// Trim each line and drop blank (whitespace-only) ones.
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
// Neutralize a col-0 markdown block trigger with an invisible ZWSP so the
|
||||||
|
// git-sync round-trip keeps the line a paragraph. Host lines (`You:` /
|
||||||
|
// `Speaker N:`) never match and stay byte-exact.
|
||||||
|
.map((line) =>
|
||||||
|
GITMOST_MD_BLOCK_TRIGGER_RE.test(line) ? GITMOST_ZWSP + line : line,
|
||||||
|
);
|
||||||
|
if (lines.length === 0) return false;
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
{
|
||||||
|
type: "heading",
|
||||||
|
attrs: { level: 2 },
|
||||||
|
content: [{ type: "text", text: "Transcript" }],
|
||||||
|
},
|
||||||
|
...lines.map((line) => ({
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: line }],
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Append at the end of the document. On a freshly-created recording page the
|
||||||
|
// audio node is the last block, so the end position places the transcript
|
||||||
|
// directly below it.
|
||||||
|
const endPos = editor.state.doc.content.size;
|
||||||
|
editor.chain().focus().insertContentAt(endPos, content).run();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Full insert path used by the open-page bridge (insertRecording): guard the
|
// Full insert path used by the open-page bridge (insertRecording): guard the
|
||||||
// editor, validate/decode the payload, then upload. Never throws — resolves to
|
// editor, validate/decode the payload, then upload. Never throws — resolves to
|
||||||
// a result code.
|
// a result code.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@import "./core.css";
|
@import "./core.css";
|
||||||
@import "./collaboration.css";
|
@import "./collaboration.css";
|
||||||
|
@import "./multi-cursor.css";
|
||||||
@import "./task-list.css";
|
@import "./task-list.css";
|
||||||
@import "./placeholder.css";
|
@import "./placeholder.css";
|
||||||
@import "./drag-handle.css";
|
@import "./drag-handle.css";
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
* Multi-cursor (issue #196). Deliberately DISTINCT from the collaboration
|
||||||
|
* carets (collaboration.css) so a user never confuses their own multi-cursors
|
||||||
|
* with a co-author's caret: solid accent-blue carets + a translucent blue
|
||||||
|
* range highlight, versus the thin dark collaboration caret with a name label.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* A secondary caret rendered as a Decoration.widget at each cursor position. */
|
||||||
|
.multi-cursor__caret {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 0;
|
||||||
|
height: 1em;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-cursor__caret::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -1px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: #2b6cb0;
|
||||||
|
animation: multi-cursor-blink 1s steps(1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optional label class reserved for future per-cursor annotations. */
|
||||||
|
.multi-cursor__label {
|
||||||
|
position: absolute;
|
||||||
|
top: -1.4em;
|
||||||
|
left: -1px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: normal;
|
||||||
|
padding: 0.05rem 0.25rem;
|
||||||
|
border-radius: 3px 3px 3px 0;
|
||||||
|
background: #2b6cb0;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline highlight for a multi-cursor RANGE (from < to). */
|
||||||
|
.multi-cursor__selection {
|
||||||
|
background: rgba(43, 108, 176, 0.28);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes multi-cursor-blink {
|
||||||
|
0%,
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
51%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import { EventName } from '../../common/events/event.contants';
|
|||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin snapshot of a page node carried inside domain events so the WebSocket
|
* Thin snapshot of a page node carried inside domain events so the WebSocket
|
||||||
@@ -112,48 +111,24 @@ export class PageListener {
|
|||||||
private readonly logger = new Logger(PageListener.name);
|
private readonly logger = new Logger(PageListener.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly environmentService: EnvironmentService,
|
|
||||||
@InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue,
|
|
||||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@OnEvent(EventName.PAGE_CREATED)
|
@OnEvent(EventName.PAGE_CREATED)
|
||||||
async handlePageCreated(event: PageEvent) {
|
async handlePageCreated(event: PageEvent) {
|
||||||
const { pageIds, workspaceId } = event;
|
const { pageIds, workspaceId } = event;
|
||||||
if (this.isTypesense()) {
|
|
||||||
await this.searchQueue.add(QueueJob.PAGE_CREATED, {
|
|
||||||
pageIds,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.PAGE_CREATED, { pageIds, workspaceId });
|
await this.aiQueue.add(QueueJob.PAGE_CREATED, { pageIds, workspaceId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent(EventName.PAGE_UPDATED)
|
|
||||||
async handlePageUpdated(event: PageEvent) {
|
|
||||||
const { pageIds } = event;
|
|
||||||
|
|
||||||
await this.searchQueue.add(QueueJob.PAGE_UPDATED, { pageIds });
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnEvent(EventName.PAGE_DELETED)
|
@OnEvent(EventName.PAGE_DELETED)
|
||||||
async handlePageDeleted(event: PageEvent) {
|
async handlePageDeleted(event: PageEvent) {
|
||||||
const { pageIds, workspaceId } = event;
|
const { pageIds, workspaceId } = event;
|
||||||
if (this.isTypesense()) {
|
|
||||||
await this.searchQueue.add(QueueJob.PAGE_DELETED, { pageIds });
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.PAGE_DELETED, { pageIds, workspaceId });
|
await this.aiQueue.add(QueueJob.PAGE_DELETED, { pageIds, workspaceId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent(EventName.PAGE_SOFT_DELETED)
|
@OnEvent(EventName.PAGE_SOFT_DELETED)
|
||||||
async handlePageSoftDeleted(event: PageEvent) {
|
async handlePageSoftDeleted(event: PageEvent) {
|
||||||
const { pageIds, workspaceId } = event;
|
const { pageIds, workspaceId } = event;
|
||||||
|
|
||||||
if (this.isTypesense()) {
|
|
||||||
await this.searchQueue.add(QueueJob.PAGE_SOFT_DELETED, { pageIds });
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.PAGE_SOFT_DELETED, {
|
await this.aiQueue.add(QueueJob.PAGE_SOFT_DELETED, {
|
||||||
pageIds,
|
pageIds,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -163,14 +138,6 @@ export class PageListener {
|
|||||||
@OnEvent(EventName.PAGE_RESTORED)
|
@OnEvent(EventName.PAGE_RESTORED)
|
||||||
async handlePageRestored(event: PageEvent) {
|
async handlePageRestored(event: PageEvent) {
|
||||||
const { pageIds, workspaceId } = event;
|
const { pageIds, workspaceId } = event;
|
||||||
if (this.isTypesense()) {
|
|
||||||
await this.searchQueue.add(QueueJob.PAGE_RESTORED, { pageIds });
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.PAGE_RESTORED, { pageIds, workspaceId });
|
await this.aiQueue.add(QueueJob.PAGE_RESTORED, { pageIds, workspaceId });
|
||||||
}
|
}
|
||||||
|
|
||||||
isTypesense(): boolean {
|
|
||||||
return this.environmentService.getSearchDriver() === 'typesense';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { EventName } from '../../common/events/event.contants';
|
|||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
|
||||||
|
|
||||||
export class SpaceEvent {
|
export class SpaceEvent {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -15,22 +14,12 @@ export class SpaceListener {
|
|||||||
private readonly logger = new Logger(SpaceListener.name);
|
private readonly logger = new Logger(SpaceListener.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly environmentService: EnvironmentService,
|
|
||||||
@InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue,
|
|
||||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@OnEvent(EventName.SPACE_DELETED)
|
@OnEvent(EventName.SPACE_DELETED)
|
||||||
async handleSpaceDeleted(event: SpaceEvent) {
|
async handleSpaceDeleted(event: SpaceEvent) {
|
||||||
const { spaceId } = event;
|
const { spaceId } = event;
|
||||||
if (this.isTypesense()) {
|
|
||||||
await this.searchQueue.add(QueueJob.SPACE_DELETED, { spaceId });
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.SPACE_DELETED, { spaceId });
|
await this.aiQueue.add(QueueJob.SPACE_DELETED, { spaceId });
|
||||||
}
|
}
|
||||||
|
|
||||||
isTypesense(): boolean {
|
|
||||||
return this.environmentService.getSearchDriver() === 'typesense';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { EventName } from '../../common/events/event.contants';
|
|||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
|
||||||
|
|
||||||
export class WorkspaceEvent {
|
export class WorkspaceEvent {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -15,22 +14,12 @@ export class WorkspaceListener {
|
|||||||
private readonly logger = new Logger(WorkspaceListener.name);
|
private readonly logger = new Logger(WorkspaceListener.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly environmentService: EnvironmentService,
|
|
||||||
@InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue,
|
|
||||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@OnEvent(EventName.WORKSPACE_DELETED)
|
@OnEvent(EventName.WORKSPACE_DELETED)
|
||||||
async handlePageDeleted(event: WorkspaceEvent) {
|
async handlePageDeleted(event: WorkspaceEvent) {
|
||||||
const { workspaceId } = event;
|
const { workspaceId } = event;
|
||||||
if (this.isTypesense()) {
|
|
||||||
await this.searchQueue.add(QueueJob.WORKSPACE_DELETED, { workspaceId });
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.WORKSPACE_DELETED, { workspaceId });
|
await this.aiQueue.add(QueueJob.WORKSPACE_DELETED, { workspaceId });
|
||||||
}
|
}
|
||||||
|
|
||||||
isTypesense(): boolean {
|
|
||||||
return this.environmentService.getSearchDriver() === 'typesense';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,36 @@ import { FastifyReply, FastifyRequest } from 'fastify';
|
|||||||
import { isStreamingResponse } from './metrics.constants';
|
import { isStreamingResponse } from './metrics.constants';
|
||||||
import { observeHttp } from './metrics.registry';
|
import { observeHttp } from './metrics.registry';
|
||||||
|
|
||||||
|
// URL path prefixes served by @fastify/static (client build output under
|
||||||
|
// client/dist). `/assets/` holds the content-hashed bundle (index-*.js,
|
||||||
|
// chunk-*.js) — a NEW set of names every deploy, i.e. an UNBOUNDED label set
|
||||||
|
// (#362); the others (/vad/, /brand/, /locales/, /icons/ — copied verbatim from
|
||||||
|
// public/) have stable names, so they are merely repetitive per-file labels
|
||||||
|
// rather than unbounded. Either way none of these belong in the API-route
|
||||||
|
// histogram: collapse them all to one bounded `static` label. (Edge latency for
|
||||||
|
// static is already measured by Traefik's traefik_router_request_duration_*.)
|
||||||
|
const STATIC_PATH_PREFIXES = [
|
||||||
|
'/assets/',
|
||||||
|
'/vad/',
|
||||||
|
'/brand/',
|
||||||
|
'/locales/',
|
||||||
|
'/icons/',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the BOUNDED route label for an HTTP response.
|
* Resolve the BOUNDED route label for an HTTP response.
|
||||||
*
|
*
|
||||||
* HARD REQUIREMENT (#355): use the ROUTE TEMPLATE (`/pages/:id`), NEVER the raw
|
* HARD REQUIREMENT (#355): use the ROUTE TEMPLATE (`/pages/:id`), NEVER a raw
|
||||||
* URL (`/pages/abc-123`), so label cardinality stays finite. Fastify exposes the
|
* URL (`/pages/abc-123` or `/assets/index-CAbxDtto.js`), so label cardinality
|
||||||
* matched template on `req.routeOptions.url`. On 404s (no route matched) that is
|
* stays finite. Fastify exposes the matched template on `req.routeOptions.url`,
|
||||||
* missing → collapse to the literal `unknown`.
|
* BUT @fastify/static serves each file through a route whose matched url is the
|
||||||
|
* raw (hashed) file path — so for static assets that value is itself unbounded.
|
||||||
|
* Detect static requests by their path prefix FIRST and collapse to `static`;
|
||||||
|
* otherwise use the route template; on a 404 (no route matched) → `unknown`.
|
||||||
*/
|
*/
|
||||||
export function resolveRouteLabel(req: FastifyRequest): string {
|
export function resolveRouteLabel(req: FastifyRequest): string {
|
||||||
|
const path = (req.url ?? '').split('?', 1)[0];
|
||||||
|
if (STATIC_PATH_PREFIXES.some((p) => path.startsWith(p))) return 'static';
|
||||||
const url = req.routeOptions?.url;
|
const url = req.routeOptions?.url;
|
||||||
return typeof url === 'string' && url.length > 0 ? url : 'unknown';
|
return typeof url === 'string' && url.length > 0 ? url : 'unknown';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export class MetricsBullService implements OnModuleInit, OnModuleDestroy {
|
|||||||
@InjectQueue(QueueName.GENERAL_QUEUE) generalQueue: Queue,
|
@InjectQueue(QueueName.GENERAL_QUEUE) generalQueue: Queue,
|
||||||
@InjectQueue(QueueName.BILLING_QUEUE) billingQueue: Queue,
|
@InjectQueue(QueueName.BILLING_QUEUE) billingQueue: Queue,
|
||||||
@InjectQueue(QueueName.FILE_TASK_QUEUE) fileTaskQueue: Queue,
|
@InjectQueue(QueueName.FILE_TASK_QUEUE) fileTaskQueue: Queue,
|
||||||
@InjectQueue(QueueName.SEARCH_QUEUE) searchQueue: Queue,
|
|
||||||
@InjectQueue(QueueName.AI_QUEUE) aiQueue: Queue,
|
@InjectQueue(QueueName.AI_QUEUE) aiQueue: Queue,
|
||||||
@InjectQueue(QueueName.HISTORY_QUEUE) historyQueue: Queue,
|
@InjectQueue(QueueName.HISTORY_QUEUE) historyQueue: Queue,
|
||||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE) notificationQueue: Queue,
|
@InjectQueue(QueueName.NOTIFICATION_QUEUE) notificationQueue: Queue,
|
||||||
@@ -58,7 +57,6 @@ export class MetricsBullService implements OnModuleInit, OnModuleDestroy {
|
|||||||
{ label: 'general', queue: generalQueue },
|
{ label: 'general', queue: generalQueue },
|
||||||
{ label: 'billing', queue: billingQueue },
|
{ label: 'billing', queue: billingQueue },
|
||||||
{ label: 'file-task', queue: fileTaskQueue },
|
{ label: 'file-task', queue: fileTaskQueue },
|
||||||
{ label: 'search', queue: searchQueue },
|
|
||||||
{ label: 'ai', queue: aiQueue },
|
{ label: 'ai', queue: aiQueue },
|
||||||
{ label: 'history', queue: historyQueue },
|
{ label: 'history', queue: historyQueue },
|
||||||
{ label: 'notification', queue: notificationQueue },
|
{ label: 'notification', queue: notificationQueue },
|
||||||
|
|||||||
@@ -25,6 +25,61 @@ describe('resolveRouteLabel (histogram route label)', () => {
|
|||||||
const req = { url: '/x' } as unknown as FastifyRequest;
|
const req = { url: '/x' } as unknown as FastifyRequest;
|
||||||
expect(resolveRouteLabel(req)).toBe('unknown');
|
expect(resolveRouteLabel(req)).toBe('unknown');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'/assets/index-CAbxDtto.js',
|
||||||
|
'/assets/chunk-3OPIFGDE-CJOt9nr5.js',
|
||||||
|
'/assets/excalidraw-menu-DpsI0kFW.js',
|
||||||
|
'/vad/silero_vad_v5.onnx',
|
||||||
|
'/brand/logo.svg',
|
||||||
|
'/locales/en.json',
|
||||||
|
'/icons/app-icon-192x192.png',
|
||||||
|
])('collapses hashed/static asset %p to "static" (#362 cardinality)', (url) => {
|
||||||
|
// @fastify/static serves each file through a route whose matched url is the
|
||||||
|
// raw (hashed) file path, so routeOptions.url is itself unbounded here.
|
||||||
|
const req = {
|
||||||
|
url,
|
||||||
|
routeOptions: { url },
|
||||||
|
} as unknown as FastifyRequest;
|
||||||
|
const label = resolveRouteLabel(req);
|
||||||
|
expect(label).toBe('static');
|
||||||
|
expect(label).not.toContain('.js');
|
||||||
|
expect(label).not.toContain('index-');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips the query string before the static-prefix check', () => {
|
||||||
|
const req = {
|
||||||
|
url: '/assets/index-CAbxDtto.js?v=2',
|
||||||
|
routeOptions: { url: '/assets/index-CAbxDtto.js' },
|
||||||
|
} as unknown as FastifyRequest;
|
||||||
|
expect(resolveRouteLabel(req)).toBe('static');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT collapse a real API route that merely mentions assets', () => {
|
||||||
|
// A templated API route is kept as-is; only the static path PREFIXES collapse.
|
||||||
|
const req = {
|
||||||
|
url: '/api/pages/assets-guide',
|
||||||
|
routeOptions: { url: '/api/pages/:id' },
|
||||||
|
} as unknown as FastifyRequest;
|
||||||
|
expect(resolveRouteLabel(req)).toBe('/api/pages/:id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
// The TRAILING SLASH on the prefix is the anti-false-collapse guard: a path
|
||||||
|
// that is the prefix WITHOUT its slash, or merely shares the prefix as a
|
||||||
|
// substring of a longer segment, must NOT collapse. These would collapse
|
||||||
|
// under a buggy `includes('/assets/')` / slashless-prefix impl.
|
||||||
|
'/assets',
|
||||||
|
'/assetsx/foo.js',
|
||||||
|
'/iconset/x.png',
|
||||||
|
])('does NOT collapse the prefix-boundary case %p', (url) => {
|
||||||
|
const req = {
|
||||||
|
url,
|
||||||
|
routeOptions: { url: '/some/:route' },
|
||||||
|
} as unknown as FastifyRequest;
|
||||||
|
expect(resolveRouteLabel(req)).not.toBe('static');
|
||||||
|
expect(resolveRouteLabel(req)).toBe('/some/:route');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isStreamingResponse (SSE exclusion)', () => {
|
describe('isStreamingResponse (SSE exclusion)', () => {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ export enum QueueName {
|
|||||||
GENERAL_QUEUE = '{general-queue}',
|
GENERAL_QUEUE = '{general-queue}',
|
||||||
BILLING_QUEUE = '{billing-queue}',
|
BILLING_QUEUE = '{billing-queue}',
|
||||||
FILE_TASK_QUEUE = '{file-task-queue}',
|
FILE_TASK_QUEUE = '{file-task-queue}',
|
||||||
SEARCH_QUEUE = '{search-queue}',
|
|
||||||
AI_QUEUE = '{ai-queue}',
|
AI_QUEUE = '{ai-queue}',
|
||||||
HISTORY_QUEUE = '{history-queue}',
|
HISTORY_QUEUE = '{history-queue}',
|
||||||
NOTIFICATION_QUEUE = '{notification-queue}',
|
NOTIFICATION_QUEUE = '{notification-queue}',
|
||||||
@@ -32,12 +31,6 @@ export enum QueueJob {
|
|||||||
IMPORT_TASK = 'import-task',
|
IMPORT_TASK = 'import-task',
|
||||||
EXPORT_TASK = 'export-task',
|
EXPORT_TASK = 'export-task',
|
||||||
|
|
||||||
SEARCH_INDEX_PAGE = 'search-index-page',
|
|
||||||
SEARCH_INDEX_PAGES = 'search-index-pages',
|
|
||||||
SEARCH_INDEX_COMMENT = 'search-index-comment',
|
|
||||||
SEARCH_INDEX_COMMENTS = 'search-index-comments',
|
|
||||||
SEARCH_INDEX_ATTACHMENT = 'search-index-attachment',
|
|
||||||
SEARCH_INDEX_ATTACHMENTS = 'search-index-attachments',
|
|
||||||
SEARCH_REMOVE_PAGE = 'search-remove-page',
|
SEARCH_REMOVE_PAGE = 'search-remove-page',
|
||||||
SEARCH_REMOVE_ASSET = 'search-remove-attachment',
|
SEARCH_REMOVE_ASSET = 'search-remove-attachment',
|
||||||
SEARCH_REMOVE_FACE = 'search-remove-comment',
|
SEARCH_REMOVE_FACE = 'search-remove-comment',
|
||||||
|
|||||||
@@ -57,14 +57,6 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
|
|||||||
attempts: 1,
|
attempts: 1,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
BullModule.registerQueue({
|
|
||||||
name: QueueName.SEARCH_QUEUE,
|
|
||||||
defaultJobOptions: {
|
|
||||||
removeOnComplete: true,
|
|
||||||
removeOnFail: true,
|
|
||||||
attempts: 2,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
name: QueueName.AI_QUEUE,
|
name: QueueName.AI_QUEUE,
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
|
|||||||
@@ -38,6 +38,24 @@ export const TEST_DATABASE_URL =
|
|||||||
process.env.TEST_DATABASE_URL ??
|
process.env.TEST_DATABASE_URL ??
|
||||||
'postgresql://docmost:docmost_dev_pw@localhost:5432/docmost_test';
|
'postgresql://docmost:docmost_dev_pw@localhost:5432/docmost_test';
|
||||||
|
|
||||||
|
// Build the raw postgres.js client (mirrors database.module.ts: max pool,
|
||||||
|
// silenced notices, bigint-as-number parsing). Kept separate so the singleton
|
||||||
|
// can hold a reference to bound its shutdown in destroyTestDb.
|
||||||
|
function buildTestSql(url: string = TEST_DATABASE_URL) {
|
||||||
|
return postgres(url, {
|
||||||
|
max: 5,
|
||||||
|
onnotice: () => {},
|
||||||
|
types: {
|
||||||
|
bigint: {
|
||||||
|
to: 20,
|
||||||
|
from: [20, 1700],
|
||||||
|
serialize: (value: number) => value.toString(),
|
||||||
|
parse: (value: string) => Number.parseInt(value),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a Kysely instance that MIRRORS the app's setup in database.module.ts:
|
* Build a Kysely instance that MIRRORS the app's setup in database.module.ts:
|
||||||
* PostgresJSDialect over postgres(), CamelCasePlugin, and the bigint type
|
* PostgresJSDialect over postgres(), CamelCasePlugin, and the bigint type
|
||||||
@@ -47,38 +65,40 @@ export const TEST_DATABASE_URL =
|
|||||||
*/
|
*/
|
||||||
export function buildTestDb(url: string = TEST_DATABASE_URL): Kysely<any> {
|
export function buildTestDb(url: string = TEST_DATABASE_URL): Kysely<any> {
|
||||||
return new Kysely<any>({
|
return new Kysely<any>({
|
||||||
dialect: new PostgresJSDialect({
|
dialect: new PostgresJSDialect({ postgres: buildTestSql(url) }),
|
||||||
postgres: postgres(url, {
|
|
||||||
max: 5,
|
|
||||||
onnotice: () => {},
|
|
||||||
types: {
|
|
||||||
bigint: {
|
|
||||||
to: 20,
|
|
||||||
from: [20, 1700],
|
|
||||||
serialize: (value: number) => value.toString(),
|
|
||||||
parse: (value: string) => Number.parseInt(value),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
plugins: [new CamelCasePlugin()],
|
plugins: [new CamelCasePlugin()],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let singleton: Kysely<any> | undefined;
|
let singleton: Kysely<any> | undefined;
|
||||||
|
let singletonSql: ReturnType<typeof buildTestSql> | undefined;
|
||||||
|
|
||||||
/** Lazily-built shared Kysely for the test suite (one per worker; maxWorkers=1). */
|
/** Lazily-built shared Kysely for the test suite (one per worker; maxWorkers=1). */
|
||||||
export function getTestDb(): Kysely<any> {
|
export function getTestDb(): Kysely<any> {
|
||||||
if (!singleton) {
|
if (!singleton) {
|
||||||
singleton = buildTestDb();
|
singletonSql = buildTestSql();
|
||||||
|
singleton = new Kysely<any>({
|
||||||
|
dialect: new PostgresJSDialect({ postgres: singletonSql }),
|
||||||
|
plugins: [new CamelCasePlugin()],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return singleton;
|
return singleton;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function destroyTestDb(): Promise<void> {
|
export async function destroyTestDb(): Promise<void> {
|
||||||
if (singleton) {
|
if (!singleton) return;
|
||||||
await singleton.destroy();
|
const sql = singletonSql;
|
||||||
singleton = undefined;
|
// Clear the refs first so a hung end() cannot leave a half-closed singleton.
|
||||||
|
singleton = undefined;
|
||||||
|
singletonSql = undefined;
|
||||||
|
// postgres.js .end() waits indefinitely for in-flight queries by default; a
|
||||||
|
// leaked/stuck pooled connection would hang the afterAll hook (a 60s hook
|
||||||
|
// timeout in CI). Bound the shutdown: the { timeout } grace period lets
|
||||||
|
// active queries drain, then force-closes lingering sockets so teardown
|
||||||
|
// always completes. We close the pool directly instead of Kysely.destroy()
|
||||||
|
// (which would call sql.end() again with no timeout).
|
||||||
|
if (sql) {
|
||||||
|
await sql.end({ timeout: 5 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,14 @@
|
|||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
"testRegex": ".e2e-spec.ts$",
|
"testRegex": ".e2e-spec.ts$",
|
||||||
"transform": {
|
"transform": {
|
||||||
|
"prosemirror-markdown/build/.+\\.js$": [
|
||||||
|
"babel-jest",
|
||||||
|
{ "presets": [["@babel/preset-env", { "targets": { "node": "current" } }]] }
|
||||||
|
],
|
||||||
"^.+\\.(t|j)sx?$": ["ts-jest", { "tsconfig": { "allowJs": true } }]
|
"^.+\\.(t|j)sx?$": ["ts-jest", { "tsconfig": { "allowJs": true } }]
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0|@sindresorhus[+/][a-z0-9-]+|escape-string-regexp|p-limit|yocto-queue)(@|/))"
|
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0|@sindresorhus[+/][a-z0-9-]+|escape-string-regexp|p-limit|yocto-queue|@docmost/prosemirror-markdown)(@|/))"
|
||||||
],
|
],
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
"^@docmost/db/(.*)$": "<rootDir>/../src/database/$1",
|
"^@docmost/db/(.*)$": "<rootDir>/../src/database/$1",
|
||||||
|
|||||||
@@ -4,14 +4,19 @@
|
|||||||
"testRegex": ".*\\.int-spec\\.ts$",
|
"testRegex": ".*\\.int-spec\\.ts$",
|
||||||
"testPathIgnorePatterns": ["/node_modules/"],
|
"testPathIgnorePatterns": ["/node_modules/"],
|
||||||
"transform": {
|
"transform": {
|
||||||
|
"prosemirror-markdown/build/.+\\.js$": [
|
||||||
|
"babel-jest",
|
||||||
|
{ "presets": [["@babel/preset-env", { "targets": { "node": "current" } }]] }
|
||||||
|
],
|
||||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0)(@|/))"
|
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0|@docmost/prosemirror-markdown)(@|/))"
|
||||||
],
|
],
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
"testTimeout": 60000,
|
"testTimeout": 60000,
|
||||||
"maxWorkers": 1,
|
"maxWorkers": 1,
|
||||||
|
"forceExit": true,
|
||||||
"globalSetup": "<rootDir>/test/integration/global-setup.ts",
|
"globalSetup": "<rootDir>/test/integration/global-setup.ts",
|
||||||
"globalTeardown": "<rootDir>/test/integration/global-teardown.ts",
|
"globalTeardown": "<rootDir>/test/integration/global-teardown.ts",
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export * from "./lib/html-embed/html-embed";
|
|||||||
export * from "./lib/mention";
|
export * from "./lib/mention";
|
||||||
export * from "./lib/markdown";
|
export * from "./lib/markdown";
|
||||||
export * from "./lib/search-and-replace";
|
export * from "./lib/search-and-replace";
|
||||||
|
export * from "./lib/multi-cursor";
|
||||||
export * from "./lib/embed-provider";
|
export * from "./lib/embed-provider";
|
||||||
export * from "./lib/subpages";
|
export * from "./lib/subpages";
|
||||||
export * from "./lib/transclusion";
|
export * from "./lib/transclusion";
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { MultiCursor } from "./multi-cursor";
|
||||||
|
export * from "./multi-cursor";
|
||||||
|
export default MultiCursor;
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import { Document } from "@tiptap/extension-document";
|
||||||
|
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||||
|
import { Text } from "@tiptap/extension-text";
|
||||||
|
import { Bold } from "@tiptap/extension-bold";
|
||||||
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
|
import { MultiCursor, multiCursorPluginKey, MAX_CURSORS } from "./multi-cursor";
|
||||||
|
import { findOccurrences } from "../search-and-replace/find-occurrences";
|
||||||
|
|
||||||
|
const extensions = [Document, Paragraph, Text, Bold, MultiCursor];
|
||||||
|
|
||||||
|
function makeEditor(content?: any) {
|
||||||
|
return new Editor({
|
||||||
|
extensions,
|
||||||
|
content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function doc(...paragraphs: string[]) {
|
||||||
|
return {
|
||||||
|
type: "doc",
|
||||||
|
content: paragraphs.map((text) => ({
|
||||||
|
type: "paragraph",
|
||||||
|
content: text ? [{ type: "text", text }] : [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function paraTexts(d: PMNode): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
d.forEach((node) => {
|
||||||
|
if (node.type.name === "paragraph") out.push(node.textContent);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cursors(editor: Editor) {
|
||||||
|
return multiCursorPluginKey.getState(editor.state)!.cursors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate typing a character through the real handleTextInput routing (the
|
||||||
|
// browser path). someMethod-equivalent: dispatch a DOM-ish text input by calling
|
||||||
|
// the view's input handler directly.
|
||||||
|
function typeText(editor: Editor, text: string) {
|
||||||
|
const { from, to } = editor.state.selection;
|
||||||
|
// props.handleTextInput is what ProseMirror calls on beforeinput/keypress.
|
||||||
|
const handled = editor.view.someProp(
|
||||||
|
"handleTextInput",
|
||||||
|
(fn) => fn(editor.view, from, to, text) || false,
|
||||||
|
);
|
||||||
|
if (!handled) {
|
||||||
|
// Fall back to a normal insertion (no active multi-cursor set).
|
||||||
|
editor.view.dispatch(editor.state.tr.insertText(text, from, to));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pressKey(editor: Editor, key: string) {
|
||||||
|
editor.view.someProp("handleKeyDown", (fn) =>
|
||||||
|
fn(editor.view, new KeyboardEvent("keydown", { key })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("multi-cursor: selectAllOccurrences", () => {
|
||||||
|
it("finds EVERY occurrence of a repeated word under the cursor", () => {
|
||||||
|
const editor = makeEditor(doc("foo bar foo baz foo"));
|
||||||
|
// Cursor inside the first "foo".
|
||||||
|
editor.commands.setTextSelection(2);
|
||||||
|
expect(editor.commands.selectAllOccurrences()).toBe(true);
|
||||||
|
|
||||||
|
const cs = cursors(editor);
|
||||||
|
expect(cs.length).toBe(3);
|
||||||
|
// Every cursor spans a "foo".
|
||||||
|
for (const c of cs) {
|
||||||
|
expect(editor.state.doc.textBetween(c.from, c.to)).toBe("foo");
|
||||||
|
}
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the current non-empty selection as the term", () => {
|
||||||
|
const editor = makeEditor(doc("ab abc ab abcd ab"));
|
||||||
|
// Select the first "ab".
|
||||||
|
editor.commands.setTextSelection({ from: 1, to: 3 });
|
||||||
|
expect(editor.state.doc.textBetween(1, 3)).toBe("ab");
|
||||||
|
editor.commands.selectAllOccurrences();
|
||||||
|
// Literal substring match (selection is not whole-word), so every "ab"
|
||||||
|
// including those inside "abc"/"abcd" is matched: 5 total.
|
||||||
|
const cs = cursors(editor);
|
||||||
|
expect(cs.length).toBe(5);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("whole-word matching from a word cursor does not match substrings", () => {
|
||||||
|
const editor = makeEditor(doc("cat category cat scatter cat"));
|
||||||
|
editor.commands.setTextSelection(2); // inside first "cat"
|
||||||
|
editor.commands.selectAllOccurrences();
|
||||||
|
// Only the three standalone "cat" words, not "category"/"scatter".
|
||||||
|
expect(cursors(editor).length).toBe(3);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multi-cursor: mass typing (single transaction)", () => {
|
||||||
|
it("types text into N carets at once", () => {
|
||||||
|
const editor = makeEditor(doc("foo foo foo"));
|
||||||
|
editor.commands.setTextSelection(2);
|
||||||
|
editor.commands.selectAllOccurrences();
|
||||||
|
expect(cursors(editor).length).toBe(3);
|
||||||
|
|
||||||
|
// Typing replaces each selected "foo" with "X".
|
||||||
|
typeText(editor, "X");
|
||||||
|
expect(paraTexts(editor.state.doc)).toEqual(["X X X"]);
|
||||||
|
|
||||||
|
// The cursors are now carets right after each inserted "X".
|
||||||
|
const cs = cursors(editor);
|
||||||
|
expect(cs.length).toBe(3);
|
||||||
|
for (const c of cs) expect(c.from).toBe(c.to);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues typing at the resulting carets (append semantics)", () => {
|
||||||
|
const editor = makeEditor(doc("a a a"));
|
||||||
|
editor.commands.setTextSelection(1);
|
||||||
|
editor.commands.selectAllOccurrences();
|
||||||
|
typeText(editor, "b"); // each "a" -> "b"
|
||||||
|
typeText(editor, "c"); // append at each caret -> "bc"
|
||||||
|
expect(paraTexts(editor.state.doc)).toEqual(["bc bc bc"]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the whole multi-edit in a SINGLE transaction (one undo step)", () => {
|
||||||
|
// "One Cmd/Ctrl+Z undoes the whole multi-edit" holds iff the N edits land in
|
||||||
|
// ONE transaction (history groups by transaction). @tiptap/extension-history
|
||||||
|
// is not a dependency here, so rather than exercise undo we assert the
|
||||||
|
// property that guarantees it: typing into N cursors is exactly ONE dispatch.
|
||||||
|
const editor = makeEditor(doc("foo foo foo"));
|
||||||
|
editor.commands.setTextSelection(2);
|
||||||
|
editor.commands.selectAllOccurrences();
|
||||||
|
expect(cursors(editor).length).toBe(3);
|
||||||
|
|
||||||
|
const orig = editor.view.dispatch.bind(editor.view);
|
||||||
|
let dispatches = 0;
|
||||||
|
editor.view.dispatch = (tr) => {
|
||||||
|
dispatches += 1;
|
||||||
|
return orig(tr);
|
||||||
|
};
|
||||||
|
typeText(editor, "Z");
|
||||||
|
editor.view.dispatch = orig;
|
||||||
|
|
||||||
|
expect(dispatches).toBe(1); // all three edits share one transaction
|
||||||
|
expect(paraTexts(editor.state.doc)).toEqual(["Z Z Z"]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("off-by-one guard: reverse-order iteration keeps every position valid", () => {
|
||||||
|
// If the mass edit iterated FORWARD, inserting at an earlier cursor would
|
||||||
|
// shift every later cursor and corrupt the result. Different-length
|
||||||
|
// replacement makes such a bug visible.
|
||||||
|
const editor = makeEditor(doc("x x x x"));
|
||||||
|
editor.commands.setTextSelection(1);
|
||||||
|
editor.commands.selectAllOccurrences();
|
||||||
|
expect(cursors(editor).length).toBe(4);
|
||||||
|
typeText(editor, "LONG");
|
||||||
|
expect(paraTexts(editor.state.doc)).toEqual(["LONG LONG LONG LONG"]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multi-cursor: mass Backspace / Delete", () => {
|
||||||
|
it("Backspace removes one char before each caret", () => {
|
||||||
|
const editor = makeEditor(doc("foo foo foo"));
|
||||||
|
editor.commands.setTextSelection(2);
|
||||||
|
editor.commands.selectAllOccurrences();
|
||||||
|
// Collapse selections to carets at the END of each "foo" by typing then
|
||||||
|
// removing is complex; instead type to convert ranges into carets first.
|
||||||
|
typeText(editor, "ab"); // each "foo" -> "ab", carets after "ab"
|
||||||
|
expect(paraTexts(editor.state.doc)).toEqual(["ab ab ab"]);
|
||||||
|
pressKey(editor, "Backspace"); // remove the trailing "b" at each caret
|
||||||
|
expect(paraTexts(editor.state.doc)).toEqual(["a a a"]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Delete removes one char after each caret", () => {
|
||||||
|
const editor = makeEditor(doc("fooX fooX"));
|
||||||
|
// Literal (selection) match of "foo" -> both occurrences inside "fooX".
|
||||||
|
editor.commands.setTextSelection({ from: 1, to: 4 }); // first "foo"
|
||||||
|
editor.commands.selectAllOccurrences();
|
||||||
|
expect(cursors(editor).length).toBe(2);
|
||||||
|
typeText(editor, "foo"); // rewrite "foo", carets now sit before each "X"
|
||||||
|
expect(paraTexts(editor.state.doc)).toEqual(["fooX fooX"]);
|
||||||
|
pressKey(editor, "Delete"); // remove the "X" after each caret
|
||||||
|
expect(paraTexts(editor.state.doc)).toEqual(["foo foo"]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Backspace at a block-start caret is a no-op for that cursor", () => {
|
||||||
|
const editor = makeEditor(doc("ab", "ab"));
|
||||||
|
// Select both "ab" then convert to carets at start by replacing with "".
|
||||||
|
editor.commands.setTextSelection({ from: 1, to: 3 }); // first "ab"
|
||||||
|
editor.commands.selectAllOccurrences();
|
||||||
|
// Move carets to block start: type "" is not possible; instead delete range.
|
||||||
|
pressKey(editor, "Backspace"); // deletes each selected "ab"
|
||||||
|
expect(paraTexts(editor.state.doc)).toEqual(["", ""]);
|
||||||
|
// Carets are now at each block start; another Backspace must not throw and
|
||||||
|
// must not merge blocks (still two empty paragraphs).
|
||||||
|
pressKey(editor, "Backspace");
|
||||||
|
expect(paraTexts(editor.state.doc)).toEqual(["", ""]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multi-cursor: addNextOccurrence (Cmd/Ctrl+D)", () => {
|
||||||
|
it("first press selects the current word, next press adds the next", () => {
|
||||||
|
const editor = makeEditor(doc("go go go"));
|
||||||
|
editor.commands.setTextSelection(2); // inside first "go"
|
||||||
|
editor.commands.addNextOccurrence();
|
||||||
|
expect(cursors(editor).length).toBe(1);
|
||||||
|
editor.commands.addNextOccurrence();
|
||||||
|
expect(cursors(editor).length).toBe(2);
|
||||||
|
editor.commands.addNextOccurrence();
|
||||||
|
expect(cursors(editor).length).toBe(3);
|
||||||
|
// Nothing left to add — stays at 3.
|
||||||
|
editor.commands.addNextOccurrence();
|
||||||
|
expect(cursors(editor).length).toBe(3);
|
||||||
|
for (const c of cursors(editor)) {
|
||||||
|
expect(editor.state.doc.textBetween(c.from, c.to)).toBe("go");
|
||||||
|
}
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multi-cursor: position remapping", () => {
|
||||||
|
it("remaps cursors after a LOCAL edit before them", () => {
|
||||||
|
const editor = makeEditor(doc("foo foo"));
|
||||||
|
editor.commands.setTextSelection(2);
|
||||||
|
editor.commands.selectAllOccurrences();
|
||||||
|
const before = cursors(editor).map((c) => ({ ...c }));
|
||||||
|
|
||||||
|
// Insert unrelated text at the very start (pos 1), shifting everything +5.
|
||||||
|
editor.view.dispatch(editor.state.tr.insertText("HELLO", 1));
|
||||||
|
|
||||||
|
const after = cursors(editor);
|
||||||
|
expect(after.length).toBe(before.length);
|
||||||
|
for (let i = 0; i < after.length; i += 1) {
|
||||||
|
expect(after[i].from).toBe(before[i].from + 5);
|
||||||
|
expect(after[i].to).toBe(before[i].to + 5);
|
||||||
|
// And they still point at "foo".
|
||||||
|
expect(editor.state.doc.textBetween(after[i].from, after[i].to)).toBe(
|
||||||
|
"foo",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remaps cursors after a simulated REMOTE edit (ordinary transaction)", () => {
|
||||||
|
const editor = makeEditor(doc("foo bar foo"));
|
||||||
|
editor.commands.setTextSelection(2);
|
||||||
|
editor.commands.selectAllOccurrences();
|
||||||
|
const before = cursors(editor).map((c) => ({ ...c }));
|
||||||
|
expect(before.length).toBe(2);
|
||||||
|
|
||||||
|
// y-prosemirror applies remote changes as ordinary transactions. Emulate a
|
||||||
|
// remote insertion between the two "foo"s (inside "bar", pos 6) with a tr
|
||||||
|
// that carries NO multi-cursor meta — exactly like a collaborator's edit.
|
||||||
|
const tr = editor.state.tr.insertText("ZZ", 6);
|
||||||
|
editor.view.dispatch(tr);
|
||||||
|
|
||||||
|
const after = cursors(editor);
|
||||||
|
// The first "foo" (before the insertion) is unchanged; the second shifts +2.
|
||||||
|
expect(after[0].from).toBe(before[0].from);
|
||||||
|
expect(after[1].from).toBe(before[1].from + 2);
|
||||||
|
for (const c of after) {
|
||||||
|
expect(editor.state.doc.textBetween(c.from, c.to)).toBe("foo");
|
||||||
|
}
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a REMOTE delete UNDER a cursor collapses it to a caret (not drop), leaving others intact", () => {
|
||||||
|
// The riskiest remap path: a collaborator deletes the very text one cursor
|
||||||
|
// spans. Both edges map with assoc +1 and there is no drop logic, so the
|
||||||
|
// deleted-over cursor CONTRACT is: it collapses to a zero-width caret at the
|
||||||
|
// deletion point (from === to) and STAYS in the set — it is not removed.
|
||||||
|
// Untouched cursors keep spanning their occurrence. Pinning this makes the
|
||||||
|
// collapse-not-drop choice explicit (review #372 F2).
|
||||||
|
const editor = makeEditor(doc("foo bar foo"));
|
||||||
|
editor.commands.setTextSelection(2);
|
||||||
|
editor.commands.selectAllOccurrences();
|
||||||
|
const before = cursors(editor).map((c) => ({ ...c }));
|
||||||
|
expect(before.length).toBe(2);
|
||||||
|
|
||||||
|
// Remote (no multi-cursor meta) delete of the FIRST "foo" range.
|
||||||
|
const tr = editor.state.tr.delete(before[0].from, before[0].to);
|
||||||
|
editor.view.dispatch(tr);
|
||||||
|
|
||||||
|
const after = cursors(editor);
|
||||||
|
// Still two cursors — the deleted-over one is NOT dropped.
|
||||||
|
expect(after.length).toBe(2);
|
||||||
|
// The first collapsed to a caret at the deletion point.
|
||||||
|
expect(after[0].from).toBe(after[0].to);
|
||||||
|
expect(after[0].from).toBe(before[0].from);
|
||||||
|
// The second still spans "foo" (shifted left by the 3 removed chars).
|
||||||
|
expect(after[1].from).toBe(before[1].from - 3);
|
||||||
|
expect(editor.state.doc.textBetween(after[1].from, after[1].to)).toBe("foo");
|
||||||
|
// Sanity: the document now reads " bar foo".
|
||||||
|
expect(paraTexts(editor.state.doc)).toEqual([" bar foo"]);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multi-cursor: collapse / exit", () => {
|
||||||
|
it("exitMultiCursor clears the set", () => {
|
||||||
|
const editor = makeEditor(doc("foo foo"));
|
||||||
|
editor.commands.setTextSelection(2);
|
||||||
|
editor.commands.selectAllOccurrences();
|
||||||
|
expect(cursors(editor).length).toBe(2);
|
||||||
|
editor.commands.exitMultiCursor();
|
||||||
|
expect(cursors(editor).length).toBe(0);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an arrow key collapses the set", () => {
|
||||||
|
const editor = makeEditor(doc("foo foo"));
|
||||||
|
editor.commands.setTextSelection(2);
|
||||||
|
editor.commands.selectAllOccurrences();
|
||||||
|
expect(cursors(editor).length).toBe(2);
|
||||||
|
pressKey(editor, "ArrowRight");
|
||||||
|
expect(cursors(editor).length).toBe(0);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multi-cursor: collapse on composition / mousedown", () => {
|
||||||
|
// Invoke a plugin handleDOMEvents handler through the real prop plumbing.
|
||||||
|
function fireDOM(editor: Editor, name: string): void {
|
||||||
|
editor.view.someProp("handleDOMEvents", (handlers: any) => {
|
||||||
|
const h = handlers && handlers[name];
|
||||||
|
if (h) h(editor.view, new Event(name));
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("collapses the set on compositionstart (IME) — MVP does not multi-IME", () => {
|
||||||
|
const editor = makeEditor(doc("foo foo"));
|
||||||
|
editor.commands.setTextSelection(2);
|
||||||
|
editor.commands.selectAllOccurrences();
|
||||||
|
expect(cursors(editor).length).toBe(2);
|
||||||
|
fireDOM(editor, "compositionstart");
|
||||||
|
expect(cursors(editor).length).toBe(0);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses the set on a plain mousedown (VS Code behaviour)", () => {
|
||||||
|
const editor = makeEditor(doc("foo foo"));
|
||||||
|
editor.commands.setTextSelection(2);
|
||||||
|
editor.commands.selectAllOccurrences();
|
||||||
|
expect(cursors(editor).length).toBe(2);
|
||||||
|
fireDOM(editor, "mousedown");
|
||||||
|
expect(cursors(editor).length).toBe(0);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multi-cursor: hard cap", () => {
|
||||||
|
it("never activates more than MAX_CURSORS cursors", () => {
|
||||||
|
const many = new Array(MAX_CURSORS + 20).fill("w").join(" ");
|
||||||
|
const editor = makeEditor(doc(many));
|
||||||
|
editor.commands.setTextSelection(2);
|
||||||
|
editor.commands.selectAllOccurrences();
|
||||||
|
expect(cursors(editor).length).toBe(MAX_CURSORS);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multi-cursor: marks are carried across a mass edit", () => {
|
||||||
|
it("preserves marks spanning each replaced range", () => {
|
||||||
|
const editor = makeEditor({
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "a " },
|
||||||
|
{ type: "text", marks: [{ type: "bold" }], text: "key" },
|
||||||
|
{ type: "text", text: " b " },
|
||||||
|
{ type: "text", marks: [{ type: "bold" }], text: "key" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
editor.commands.setTextSelection(3); // inside first bold "key"
|
||||||
|
editor.commands.selectAllOccurrences();
|
||||||
|
expect(cursors(editor).length).toBe(2);
|
||||||
|
typeText(editor, "NEW");
|
||||||
|
|
||||||
|
// Both replacements keep the bold mark.
|
||||||
|
let boldRuns = 0;
|
||||||
|
editor.state.doc.descendants((node) => {
|
||||||
|
if (
|
||||||
|
node.isText &&
|
||||||
|
node.text === "NEW" &&
|
||||||
|
node.marks.some((m) => m.type.name === "bold")
|
||||||
|
) {
|
||||||
|
boldRuns += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(boldRuns).toBe(2);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The extracted find-occurrences util must return the SAME occurrences that the
|
||||||
|
// old inline walk produced (and that search-and-replace still relies on).
|
||||||
|
describe("find-occurrences util", () => {
|
||||||
|
it("finds all matches of a literal regex across text nodes", () => {
|
||||||
|
const editor = makeEditor(doc("foo foofoo foo"));
|
||||||
|
const results = findOccurrences(editor.state.doc, /foo/gu);
|
||||||
|
// 4 occurrences: two standalone + two inside "foofoo".
|
||||||
|
expect(results.length).toBe(4);
|
||||||
|
for (const r of results) {
|
||||||
|
expect(editor.state.doc.textBetween(r.from, r.to)).toBe("foo");
|
||||||
|
}
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores whitespace-only matches and empty regex", () => {
|
||||||
|
const editor = makeEditor(doc("a b c"));
|
||||||
|
expect(findOccurrences(editor.state.doc, null as any).length).toBe(0);
|
||||||
|
// A whitespace regex yields no results (matches are trimmed away).
|
||||||
|
expect(findOccurrences(editor.state.doc, /\s/gu).length).toBe(0);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds a match spanning two differently-marked contiguous text nodes", () => {
|
||||||
|
const editor = makeEditor({
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "wo" },
|
||||||
|
{ type: "text", marks: [{ type: "bold" }], text: "rd" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const results = findOccurrences(editor.state.doc, /word/gu);
|
||||||
|
expect(results.length).toBe(1);
|
||||||
|
expect(editor.state.doc.textBetween(results[0].from, results[0].to)).toBe(
|
||||||
|
"word",
|
||||||
|
);
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,545 @@
|
|||||||
|
import { Extension, Range } from "@tiptap/core";
|
||||||
|
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
|
||||||
|
import {
|
||||||
|
Plugin,
|
||||||
|
PluginKey,
|
||||||
|
TextSelection,
|
||||||
|
type EditorState,
|
||||||
|
} from "@tiptap/pm/state";
|
||||||
|
import { Mark } from "@tiptap/pm/model";
|
||||||
|
import { findOccurrences } from "../search-and-replace/find-occurrences";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-cursor editing — MVP (issue #196, "Variant A").
|
||||||
|
*
|
||||||
|
* VS Code-style multi-cursor limited to "select all occurrences of a word (or
|
||||||
|
* the current selection) and type into all of them at once", built ON TOP OF
|
||||||
|
* the search-and-replace mass-transaction machinery:
|
||||||
|
*
|
||||||
|
* - Cmd/Ctrl+Shift+L (selectAllOccurrences): the word under the cursor (or the
|
||||||
|
* current non-empty selection) -> ALL its occurrences become active cursors.
|
||||||
|
* - Cmd/Ctrl+D (addNextOccurrence): add the NEXT occurrence of the term.
|
||||||
|
* - Typing / Backspace / Delete apply to EVERY active cursor in ONE
|
||||||
|
* transaction (so a single Cmd/Ctrl+Z undoes the whole multi-edit).
|
||||||
|
* - Esc (exitMultiCursor): collapse back to a single cursor.
|
||||||
|
*
|
||||||
|
* The single-transaction, reverse-order edit mechanic mirrors `replaceAll` in
|
||||||
|
* search-and-replace.ts: we iterate cursors from the END of the document to the
|
||||||
|
* START so an earlier edit never invalidates a later position, carrying the
|
||||||
|
* marks that span each range.
|
||||||
|
*
|
||||||
|
* CONSCIOUS v1 OUT-OF-SCOPE BOUNDARIES (these are "Variant B", deliberately NOT
|
||||||
|
* built here):
|
||||||
|
* - Alt+Click arbitrary carets and Alt+drag column selection.
|
||||||
|
* - Cmd/Ctrl+Alt+Up/Down "add cursor on the adjacent line".
|
||||||
|
* - Simultaneous IME / composition input into multiple positions — on
|
||||||
|
* `compositionstart` we collapse back to a single cursor.
|
||||||
|
* - Cursors spanning different schema nodes in one edit.
|
||||||
|
*
|
||||||
|
* NOT out of scope, but worth stating precisely: there is NO schema-aware or
|
||||||
|
* structural cursor. Occurrences are found by a plain text-node walk
|
||||||
|
* (`findOccurrences`), so a term that appears inside a table cell, code block or
|
||||||
|
* callout DOES get a cursor there and IS edited — as plain text, exactly like
|
||||||
|
* `replaceAll`. There is no special table/code handling; the per-cursor try/catch
|
||||||
|
* only SKIPS a cursor whose edit would violate the schema (never applied
|
||||||
|
* half-way), it does not exclude those node types from matching.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface MultiCursorState {
|
||||||
|
// Each active cursor: a caret when from === to, a range when from < to.
|
||||||
|
cursors: Range[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const multiCursorPluginKey = new PluginKey<MultiCursorState>(
|
||||||
|
"multiCursor",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hard safety cap on simultaneously-active cursors — stop adding past it.
|
||||||
|
export const MAX_CURSORS = 100;
|
||||||
|
|
||||||
|
export interface MultiCursorStorage {
|
||||||
|
// Whether the active term matches whole words only. Set to true when the set
|
||||||
|
// was seeded from a bare cursor (word under caret), false when seeded from an
|
||||||
|
// explicit selection (literal substring match, like VS Code). Remembered so
|
||||||
|
// addNextOccurrence keeps matching the same way as selectAllOccurrences.
|
||||||
|
wholeWord: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Storage {
|
||||||
|
multiCursor: MultiCursorStorage;
|
||||||
|
}
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
multiCursor: {
|
||||||
|
/** Select all occurrences of the word/selection as active cursors. */
|
||||||
|
selectAllOccurrences: () => ReturnType;
|
||||||
|
/** Add the next occurrence of the current term to the cursor set. */
|
||||||
|
addNextOccurrence: () => ReturnType;
|
||||||
|
/** Collapse the multi-cursor set back to a single cursor. */
|
||||||
|
exitMultiCursor: () => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Term helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function escapeRegExp(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A "word" is a run of letters/numbers/underscore; those get whole-word
|
||||||
|
// matching (\b…\b) so a term never matches inside a larger word. Anything else
|
||||||
|
// (punctuation, phrases) is matched literally. Case-sensitive, like VS Code.
|
||||||
|
function isWordTerm(s: string): boolean {
|
||||||
|
return /^[\p{L}\p{N}_]+$/u.test(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// wholeWord uses \b…\b so the term never matches inside a larger word; it only
|
||||||
|
// applies to word-like terms (a term containing punctuation cannot be
|
||||||
|
// whole-word-bounded meaningfully). Otherwise the term is matched literally.
|
||||||
|
function buildTermRegex(term: string, wholeWord: boolean): RegExp {
|
||||||
|
const esc = escapeRegExp(term);
|
||||||
|
return wholeWord && isWordTerm(term)
|
||||||
|
? new RegExp(`\\b${esc}\\b`, "gu")
|
||||||
|
: new RegExp(esc, "gu");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word under a position: returns the exact { from, to } range and its text, or
|
||||||
|
// null if the position is not inside a word in a textblock.
|
||||||
|
function getWordAt(
|
||||||
|
state: EditorState,
|
||||||
|
pos: number,
|
||||||
|
): { from: number; to: number; text: string } | null {
|
||||||
|
const $pos = state.doc.resolve(pos);
|
||||||
|
const parent = $pos.parent;
|
||||||
|
if (!parent.isTextblock) return null;
|
||||||
|
|
||||||
|
const text = parent.textContent;
|
||||||
|
const offset = $pos.parentOffset;
|
||||||
|
const start = $pos.start();
|
||||||
|
const wordRe = /[\p{L}\p{N}_]+/gu;
|
||||||
|
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = wordRe.exec(text)) !== null) {
|
||||||
|
const s = m.index;
|
||||||
|
const e = m.index + m[0].length;
|
||||||
|
if (offset >= s && offset <= e) {
|
||||||
|
return { from: start + s, to: start + e, text: m[0] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin-state access
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getCursors(state: EditorState): Range[] {
|
||||||
|
const st = multiCursorPluginKey.getState(state);
|
||||||
|
return st ? st.cursors : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCursors(view: EditorView, cursors: Range[]): void {
|
||||||
|
view.dispatch(view.state.tr.setMeta(multiCursorPluginKey, cursors));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapse(view: EditorView): void {
|
||||||
|
setCursors(view, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The single-transaction, reverse-order mass edit (mirrors replaceAll)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface EditOp {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
// Text to insert at `from` after deleting [from, to); "" for a pure delete.
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply one edit per cursor in ONE transaction. Ops are processed from the END
|
||||||
|
* of the document to the START so an earlier edit never shifts a later position
|
||||||
|
* (mirrors `replaceAll`). Each cursor is wrapped independently: a schema
|
||||||
|
* violation SKIPS that one cursor instead of throwing away the whole
|
||||||
|
* transaction, so the document is never left half-applied.
|
||||||
|
*
|
||||||
|
* After building the transaction the new cursor positions are recomputed by
|
||||||
|
* mapping each op's original anchor through `tr.mapping` (which also remaps any
|
||||||
|
* concurrent changes), so carets land right after their inserted text.
|
||||||
|
*/
|
||||||
|
function dispatchMassEdit(view: EditorView, ops: EditOp[]): boolean {
|
||||||
|
if (!ops.length) return false;
|
||||||
|
|
||||||
|
const { state } = view;
|
||||||
|
const tr = state.tr;
|
||||||
|
const schema = state.schema;
|
||||||
|
|
||||||
|
// Ascending by `from`; iterate reverse so earlier positions stay valid.
|
||||||
|
const sorted = [...ops].sort((a, b) => a.from - b.from);
|
||||||
|
const appliedLen: number[] = new Array(sorted.length).fill(0);
|
||||||
|
|
||||||
|
for (let i = sorted.length - 1; i >= 0; i -= 1) {
|
||||||
|
const { from, to, text } = sorted[i];
|
||||||
|
try {
|
||||||
|
let marks: readonly Mark[] = [];
|
||||||
|
if (text) {
|
||||||
|
if (to > from) {
|
||||||
|
// Carry all marks spanning the replaced range.
|
||||||
|
const set = new Set<Mark>();
|
||||||
|
tr.doc.nodesBetween(from, to, (node) => {
|
||||||
|
if (node.isText && node.marks) {
|
||||||
|
node.marks.forEach((mk) => set.add(mk));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
marks = Array.from(set);
|
||||||
|
} else {
|
||||||
|
// Caret: continue the marks active at the insertion point.
|
||||||
|
marks = state.storedMarks || state.doc.resolve(from).marks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ONE atomic step per cursor: replaceWith covers both insert (from === to)
|
||||||
|
// and replace (to > from); a pure delete (empty text) uses delete. This
|
||||||
|
// can never leave a cursor half-applied (deleted but not re-inserted) the
|
||||||
|
// way a separate delete-then-insert pair could if the insert step threw.
|
||||||
|
if (text) {
|
||||||
|
tr.replaceWith(from, to, schema.text(text, marks as Mark[]));
|
||||||
|
} else if (to > from) {
|
||||||
|
tr.delete(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
appliedLen[i] = text.length;
|
||||||
|
} catch {
|
||||||
|
// Per-cursor backstop (text-only MVP): drop this cursor's edit, keep the
|
||||||
|
// rest of the transaction intact.
|
||||||
|
appliedLen[i] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tr.docChanged) return false;
|
||||||
|
|
||||||
|
// Recompute cursor carets from the ORIGINAL op anchors through the full map.
|
||||||
|
const newCursors: Range[] = sorted.map((op, i) => {
|
||||||
|
const start = tr.mapping.map(op.from, -1);
|
||||||
|
const caret = start + appliedLen[i];
|
||||||
|
return { from: caret, to: caret };
|
||||||
|
});
|
||||||
|
|
||||||
|
tr.setMeta(multiCursorPluginKey, newCursors);
|
||||||
|
|
||||||
|
// Park the native selection on the last caret so the browser draws exactly
|
||||||
|
// one real caret; the rest are our decoration widgets.
|
||||||
|
const last = newCursors[newCursors.length - 1];
|
||||||
|
tr.setSelection(TextSelection.create(tr.doc, last.from));
|
||||||
|
|
||||||
|
view.dispatch(tr);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDeleteOps(
|
||||||
|
state: EditorState,
|
||||||
|
cursors: Range[],
|
||||||
|
forward: boolean,
|
||||||
|
): EditOp[] {
|
||||||
|
return cursors.map((c) => {
|
||||||
|
// A selected range: Backspace/Delete removes the whole range.
|
||||||
|
if (c.to > c.from) return { from: c.from, to: c.to, text: "" };
|
||||||
|
|
||||||
|
const $pos = state.doc.resolve(c.from);
|
||||||
|
if (forward) {
|
||||||
|
// Delete: at the end of a textblock there is nothing to remove (a no-op;
|
||||||
|
// MVP does not merge blocks across a multi-cursor set).
|
||||||
|
if ($pos.parentOffset >= $pos.parent.content.size) {
|
||||||
|
return { from: c.from, to: c.from, text: "" };
|
||||||
|
}
|
||||||
|
return { from: c.from, to: c.from + 1, text: "" };
|
||||||
|
}
|
||||||
|
// Backspace: at the start of a textblock there is nothing to remove.
|
||||||
|
if ($pos.parentOffset <= 0) {
|
||||||
|
return { from: c.from, to: c.from, text: "" };
|
||||||
|
}
|
||||||
|
return { from: c.from - 1, to: c.from, text: "" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Extension
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const MultiCursor = Extension.create<unknown, MultiCursorStorage>({
|
||||||
|
name: "multiCursor",
|
||||||
|
|
||||||
|
addStorage() {
|
||||||
|
return { wholeWord: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
selectAllOccurrences:
|
||||||
|
() =>
|
||||||
|
({ editor, state, tr, dispatch }) => {
|
||||||
|
let term: string;
|
||||||
|
// A bare cursor expands to the whole word; an explicit selection is
|
||||||
|
// matched literally (VS Code semantics).
|
||||||
|
const wholeWord = state.selection.empty;
|
||||||
|
if (wholeWord) {
|
||||||
|
const word = getWordAt(state, state.selection.from);
|
||||||
|
if (!word) return false;
|
||||||
|
term = word.text;
|
||||||
|
} else {
|
||||||
|
term = state.doc.textBetween(
|
||||||
|
state.selection.from,
|
||||||
|
state.selection.to,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!term.trim()) return false;
|
||||||
|
editor.storage.multiCursor.wholeWord = wholeWord;
|
||||||
|
|
||||||
|
const results = findOccurrences(
|
||||||
|
state.doc,
|
||||||
|
buildTermRegex(term, wholeWord),
|
||||||
|
).slice(0, MAX_CURSORS);
|
||||||
|
if (!results.length) return false;
|
||||||
|
|
||||||
|
if (dispatch) {
|
||||||
|
tr.setMeta(multiCursorPluginKey, results);
|
||||||
|
const last = results[results.length - 1];
|
||||||
|
tr.setSelection(TextSelection.create(tr.doc, last.from, last.to));
|
||||||
|
dispatch(tr);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
addNextOccurrence:
|
||||||
|
() =>
|
||||||
|
({ editor, state, tr, dispatch }) => {
|
||||||
|
const existing = getCursors(state);
|
||||||
|
let cursors: Range[];
|
||||||
|
|
||||||
|
if (!existing.length) {
|
||||||
|
// First press: turn the current word/selection into the one cursor.
|
||||||
|
let range: Range;
|
||||||
|
const wholeWord = state.selection.empty;
|
||||||
|
if (wholeWord) {
|
||||||
|
const word = getWordAt(state, state.selection.from);
|
||||||
|
if (!word) return false;
|
||||||
|
range = { from: word.from, to: word.to };
|
||||||
|
} else {
|
||||||
|
range = { from: state.selection.from, to: state.selection.to };
|
||||||
|
}
|
||||||
|
editor.storage.multiCursor.wholeWord = wholeWord;
|
||||||
|
cursors = [range];
|
||||||
|
} else {
|
||||||
|
// Subsequent press: add the next unselected occurrence of the term,
|
||||||
|
// matched the SAME way (whole-word vs literal) the set was seeded.
|
||||||
|
if (existing.length >= MAX_CURSORS) return true;
|
||||||
|
|
||||||
|
const first = existing[0];
|
||||||
|
const term = state.doc.textBetween(first.from, first.to);
|
||||||
|
if (!term.trim()) return false;
|
||||||
|
|
||||||
|
const results = findOccurrences(
|
||||||
|
state.doc,
|
||||||
|
buildTermRegex(term, editor.storage.multiCursor.wholeWord),
|
||||||
|
);
|
||||||
|
const keys = new Set(existing.map((c) => `${c.from}:${c.to}`));
|
||||||
|
const notSelected = results.filter(
|
||||||
|
(r) => !keys.has(`${r.from}:${r.to}`),
|
||||||
|
);
|
||||||
|
if (!notSelected.length) return true; // all occurrences selected
|
||||||
|
|
||||||
|
const maxTo = Math.max(...existing.map((c) => c.to));
|
||||||
|
const next =
|
||||||
|
notSelected.find((r) => r.from >= maxTo) || notSelected[0];
|
||||||
|
cursors = [...existing, next];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dispatch) {
|
||||||
|
tr.setMeta(multiCursorPluginKey, cursors);
|
||||||
|
const last = cursors[cursors.length - 1];
|
||||||
|
tr.setSelection(TextSelection.create(tr.doc, last.from, last.to));
|
||||||
|
dispatch(tr);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
exitMultiCursor:
|
||||||
|
() =>
|
||||||
|
({ tr, dispatch }) => {
|
||||||
|
if (dispatch) {
|
||||||
|
tr.setMeta(multiCursorPluginKey, []);
|
||||||
|
dispatch(tr);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
"Mod-Shift-l": () => {
|
||||||
|
this.editor.commands.selectAllOccurrences();
|
||||||
|
// Always consume so the browser's default is prevented.
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
"Mod-d": () => {
|
||||||
|
this.editor.commands.addNextOccurrence();
|
||||||
|
// Consume unconditionally to prevent the browser's Cmd/Ctrl+D bookmark.
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
Escape: () => {
|
||||||
|
// Only swallow Escape while a multi-cursor set is active; otherwise let
|
||||||
|
// Escape keep its other behaviours (e.g. closing dialogs).
|
||||||
|
if (!getCursors(this.editor.state).length) return false;
|
||||||
|
return this.editor.commands.exitMultiCursor();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin<MultiCursorState>({
|
||||||
|
key: multiCursorPluginKey,
|
||||||
|
|
||||||
|
state: {
|
||||||
|
init: () => ({ cursors: [] }),
|
||||||
|
apply(tr, value): MultiCursorState {
|
||||||
|
// A command (or a mass edit) can set/clear the cursor set directly.
|
||||||
|
// Its cursors are already in the post-transaction coordinate space,
|
||||||
|
// so they take priority over remapping.
|
||||||
|
const meta = tr.getMeta(multiCursorPluginKey) as
|
||||||
|
| Range[]
|
||||||
|
| undefined;
|
||||||
|
if (meta !== undefined) {
|
||||||
|
return { cursors: meta.slice(0, MAX_CURSORS) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value.cursors.length) return value;
|
||||||
|
|
||||||
|
// Remap surviving cursors across ANY doc change — this covers both
|
||||||
|
// local edits and REMOTE Yjs edits (y-prosemirror applies remote
|
||||||
|
// changes as ordinary transactions, so mapping them here keeps every
|
||||||
|
// multi-cursor correctly positioned without special-casing collab).
|
||||||
|
if (tr.docChanged) {
|
||||||
|
// Map both edges with the SAME association (+1) so content
|
||||||
|
// inserted at a boundary shifts the whole cursor right and a caret
|
||||||
|
// (from === to) can never invert into a range.
|
||||||
|
const cursors = value.cursors.map((c) => ({
|
||||||
|
from: tr.mapping.map(c.from, 1),
|
||||||
|
to: tr.mapping.map(c.to, 1),
|
||||||
|
}));
|
||||||
|
return { cursors };
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
decorations(state) {
|
||||||
|
const st = multiCursorPluginKey.getState(state);
|
||||||
|
if (!st || !st.cursors.length) return DecorationSet.empty;
|
||||||
|
|
||||||
|
const decorations: Decoration[] = [];
|
||||||
|
st.cursors.forEach((c, i) => {
|
||||||
|
if (c.from === c.to) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.widget(
|
||||||
|
c.from,
|
||||||
|
() => {
|
||||||
|
const el = document.createElement("span");
|
||||||
|
el.className = "multi-cursor__caret";
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
{ side: 0, key: `mc-caret-${i}` },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.inline(c.from, c.to, {
|
||||||
|
class: "multi-cursor__selection",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return DecorationSet.create(state.doc, decorations);
|
||||||
|
},
|
||||||
|
|
||||||
|
handleTextInput(view, _from, _to, text) {
|
||||||
|
const cursors = getCursors(view.state);
|
||||||
|
if (!cursors.length) return false;
|
||||||
|
|
||||||
|
// Insert `text` at EVERY cursor in one transaction. Returning true
|
||||||
|
// prevents ProseMirror's own single-position insert at the native
|
||||||
|
// selection, so there is no double-insert there.
|
||||||
|
const ops = cursors.map((c) => ({
|
||||||
|
from: c.from,
|
||||||
|
to: c.to,
|
||||||
|
text,
|
||||||
|
}));
|
||||||
|
return dispatchMassEdit(view, ops);
|
||||||
|
},
|
||||||
|
|
||||||
|
handleKeyDown(view, event) {
|
||||||
|
const cursors = getCursors(view.state);
|
||||||
|
if (!cursors.length) return false;
|
||||||
|
|
||||||
|
if (event.key === "Backspace") {
|
||||||
|
dispatchMassEdit(view, buildDeleteOps(view.state, cursors, false));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.key === "Delete") {
|
||||||
|
dispatchMassEdit(view, buildDeleteOps(view.state, cursors, true));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let modifier combinations (our own shortcuts, copy, etc.) through
|
||||||
|
// WITHOUT collapsing the set.
|
||||||
|
if (event.metaKey || event.ctrlKey || event.altKey) return false;
|
||||||
|
|
||||||
|
// Navigation / block keys collapse back to a single cursor, then let
|
||||||
|
// ProseMirror handle the movement on the native selection.
|
||||||
|
const COLLAPSE_KEYS = [
|
||||||
|
"ArrowLeft",
|
||||||
|
"ArrowRight",
|
||||||
|
"ArrowUp",
|
||||||
|
"ArrowDown",
|
||||||
|
"Home",
|
||||||
|
"End",
|
||||||
|
"PageUp",
|
||||||
|
"PageDown",
|
||||||
|
"Enter",
|
||||||
|
"Tab",
|
||||||
|
];
|
||||||
|
if (COLLAPSE_KEYS.includes(event.key)) {
|
||||||
|
collapse(view);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
handleDOMEvents: {
|
||||||
|
// A plain click exits multi-cursor (VS Code behaviour).
|
||||||
|
mousedown: (view) => {
|
||||||
|
if (getCursors(view.state).length) collapse(view);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
// MVP does not drive multi-position IME — collapse on composition.
|
||||||
|
compositionstart: (view) => {
|
||||||
|
if (getCursors(view.state).length) collapse(view);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default MultiCursor;
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Range } from "@tiptap/core";
|
||||||
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
|
|
||||||
|
interface TextNodesWithPosition {
|
||||||
|
text: string;
|
||||||
|
pos: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared "find all occurrences of a term in the doc" primitive.
|
||||||
|
*
|
||||||
|
* Walks every text node of the document and returns each regex match as a
|
||||||
|
* `{ from, to }` range. Contiguous text nodes (which may differ only by marks)
|
||||||
|
* are concatenated into a single run, so a match that spans e.g. "wo" + bold
|
||||||
|
* "rd" is still found; runs are split by any non-text node, so a match never
|
||||||
|
* crosses a node boundary. Whitespace-only matches are ignored.
|
||||||
|
*
|
||||||
|
* This is used by BOTH search-and-replace (highlight/replace) and multi-cursor
|
||||||
|
* (turn occurrences into active cursors) so the two stay behaviourally in sync.
|
||||||
|
* Extracted verbatim from the original `processSearches` walk.
|
||||||
|
*/
|
||||||
|
export function findOccurrences(doc: PMNode, searchTerm: RegExp): Range[] {
|
||||||
|
const results: Range[] = [];
|
||||||
|
|
||||||
|
if (!searchTerm) return results;
|
||||||
|
|
||||||
|
let textNodesWithPosition: TextNodesWithPosition[] = [];
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
doc?.descendants((node, pos) => {
|
||||||
|
if (node.isText) {
|
||||||
|
if (textNodesWithPosition[index]) {
|
||||||
|
textNodesWithPosition[index] = {
|
||||||
|
text: textNodesWithPosition[index].text + node.text,
|
||||||
|
pos: textNodesWithPosition[index].pos,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
textNodesWithPosition[index] = {
|
||||||
|
text: `${node.text}`,
|
||||||
|
pos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
|
||||||
|
|
||||||
|
for (const element of textNodesWithPosition) {
|
||||||
|
const { text, pos } = element;
|
||||||
|
const matches = Array.from(text.matchAll(searchTerm)).filter(
|
||||||
|
([matchText]) => matchText.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const m of matches) {
|
||||||
|
if (m[0] === "") break;
|
||||||
|
|
||||||
|
if (m.index !== undefined) {
|
||||||
|
results.push({
|
||||||
|
from: pos + m.index,
|
||||||
|
to: pos + m.index + m[0].length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
import { SearchAndReplace } from './search-and-replace'
|
import { SearchAndReplace } from './search-and-replace'
|
||||||
export * from './search-and-replace'
|
export * from './search-and-replace'
|
||||||
|
export * from './find-occurrences'
|
||||||
export default SearchAndReplace
|
export default SearchAndReplace
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
type Transaction,
|
type Transaction,
|
||||||
} from "@tiptap/pm/state";
|
} from "@tiptap/pm/state";
|
||||||
import { Node as PMNode, Mark } from "@tiptap/pm/model";
|
import { Node as PMNode, Mark } from "@tiptap/pm/model";
|
||||||
|
import { findOccurrences } from "./find-occurrences";
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
interface Storage {
|
interface Storage {
|
||||||
@@ -76,11 +77,6 @@ declare module "@tiptap/core" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TextNodesWithPosition {
|
|
||||||
text: string;
|
|
||||||
pos: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRegex = (
|
const getRegex = (
|
||||||
s: string,
|
s: string,
|
||||||
disableRegex: boolean,
|
disableRegex: boolean,
|
||||||
@@ -104,10 +100,6 @@ function processSearches(
|
|||||||
resultIndex: number,
|
resultIndex: number,
|
||||||
): ProcessedSearches {
|
): ProcessedSearches {
|
||||||
const decorations: Decoration[] = [];
|
const decorations: Decoration[] = [];
|
||||||
const results: Range[] = [];
|
|
||||||
|
|
||||||
let textNodesWithPosition: TextNodesWithPosition[] = [];
|
|
||||||
let index = 0;
|
|
||||||
|
|
||||||
if (!searchTerm) {
|
if (!searchTerm) {
|
||||||
return {
|
return {
|
||||||
@@ -116,43 +108,8 @@ function processSearches(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
doc?.descendants((node, pos) => {
|
// Shared find-all-occurrences primitive (also used by multi-cursor).
|
||||||
if (node.isText) {
|
const results: Range[] = findOccurrences(doc, searchTerm);
|
||||||
if (textNodesWithPosition[index]) {
|
|
||||||
textNodesWithPosition[index] = {
|
|
||||||
text: textNodesWithPosition[index].text + node.text,
|
|
||||||
pos: textNodesWithPosition[index].pos,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
textNodesWithPosition[index] = {
|
|
||||||
text: `${node.text}`,
|
|
||||||
pos,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
|
|
||||||
|
|
||||||
for (const element of textNodesWithPosition) {
|
|
||||||
const { text, pos } = element;
|
|
||||||
const matches = Array.from(text.matchAll(searchTerm)).filter(
|
|
||||||
([matchText]) => matchText.trim(),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const m of matches) {
|
|
||||||
if (m[0] === "") break;
|
|
||||||
|
|
||||||
if (m.index !== undefined) {
|
|
||||||
results.push({
|
|
||||||
from: pos + m.index,
|
|
||||||
to: pos + m.index + m[0].length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < results.length; i += 1) {
|
for (let i = 0; i < results.length; i += 1) {
|
||||||
const r = results[i];
|
const r = results[i];
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
// Import DIRECTLY from src (NOT the docmost-client barrel, which pulls in
|
||||||
|
// collaboration.ts and mutates global DOM at import time).
|
||||||
|
import { convertProseMirrorToMarkdown } from "../src/lib/markdown-converter.js";
|
||||||
|
import { markdownToProseMirror } from "../src/lib/markdown-to-prosemirror.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gitmost #377 (round-1 review, finding #1) — proof, against the REAL
|
||||||
|
* converter, that the transcript-insert boundary defense survives git-sync.
|
||||||
|
*
|
||||||
|
* The web bridge (apps/client .../gitmost/gitmost-recording.ts,
|
||||||
|
* `gitmostInsertTranscriptIntoEditor`) appends each transcript line as a
|
||||||
|
* PARAGRAPH text node. The paragraph serializer here (`case "paragraph"`) emits
|
||||||
|
* that text VERBATIM with no block-escape, so a line whose text begins with a
|
||||||
|
* col-0 markdown block trigger would, on the doc -> markdown -> doc git-sync
|
||||||
|
* cycle, silently re-parse into a heading / list / quote / callout / code block.
|
||||||
|
* That missing block-escape is the pre-existing root cause; the bridge's
|
||||||
|
* boundary defense prepends an invisible zero-width space (U+200B) to a line
|
||||||
|
* that begins with such a trigger, shifting it off column 0.
|
||||||
|
*
|
||||||
|
* This test keeps a COPY of the bridge's trigger regex (the bridge is in a
|
||||||
|
* different package and can't be imported here) and asserts:
|
||||||
|
* 1. bare trigger lines DO corrupt (documents the root cause), and
|
||||||
|
* 2. the ZWSP-neutralized form round-trips as a single PARAGRAPH with the
|
||||||
|
* text byte-preserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ZWSP = ""; // U+200B
|
||||||
|
|
||||||
|
// MUST stay in sync with GITMOST_MD_BLOCK_TRIGGER_RE in the client bridge.
|
||||||
|
const MD_BLOCK_TRIGGER_RE =
|
||||||
|
/^(?:#{1,6}(?:\s|$)|[-*+](?:\s|$)|>|\d+[.)](?:\s|$)|```|~~~|\||([-*_])(?:\s*\1){2,}\s*$)/;
|
||||||
|
|
||||||
|
const doc = (...nodes: any[]) => ({ type: "doc", content: nodes });
|
||||||
|
const para = (t: string) => ({
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: t }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const roundtrip = async (text: string) => {
|
||||||
|
const md = convertProseMirrorToMarkdown(doc(para(text)));
|
||||||
|
const back = await markdownToProseMirror(md);
|
||||||
|
return back.content as any[];
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("gitmost transcript neutralization (git-sync round-trip)", () => {
|
||||||
|
// Lines that, at column 0, the serializer's missing block-escape would let
|
||||||
|
// git-sync re-parse into a non-paragraph block.
|
||||||
|
const triggerLines = [
|
||||||
|
"- dash",
|
||||||
|
"* star",
|
||||||
|
"+ plus",
|
||||||
|
"> quote",
|
||||||
|
"# hash",
|
||||||
|
"1. one",
|
||||||
|
"1) one",
|
||||||
|
"> [!info] note",
|
||||||
|
"```js",
|
||||||
|
"~~~",
|
||||||
|
// Solid + spaced thematic breaks — these re-parse into a `horizontalRule`,
|
||||||
|
// which carries NO text, so a bare separator line LOSES its text entirely
|
||||||
|
// (round-2 finding). `_` also only forms a block via this construct.
|
||||||
|
"---",
|
||||||
|
"***",
|
||||||
|
"___",
|
||||||
|
"- - -", // spaced dash break (solid form is caught by [-*+]\s too, but this is the break)
|
||||||
|
"_ _ _",
|
||||||
|
];
|
||||||
|
|
||||||
|
it("BARE trigger lines corrupt into non-paragraph blocks (root cause)", async () => {
|
||||||
|
for (const line of triggerLines) {
|
||||||
|
const blocks = await roundtrip(line);
|
||||||
|
// At least one produced block is NOT a paragraph — i.e. corruption.
|
||||||
|
const allParagraphs = blocks.every((b) => b.type === "paragraph");
|
||||||
|
expect(
|
||||||
|
allParagraphs,
|
||||||
|
`expected "${line}" to corrupt when inserted bare`,
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("BARE solid thematic breaks corrupt into a text-LOSING horizontalRule", async () => {
|
||||||
|
// The severe case: no text node survives. Documents why neutralization
|
||||||
|
// matters more here than for list/quote (where the text survived).
|
||||||
|
for (const line of ["---", "***", "___"]) {
|
||||||
|
const blocks = await roundtrip(line);
|
||||||
|
expect(blocks.map((b) => b.type)).toContain("horizontalRule");
|
||||||
|
// No block carries the original text anywhere.
|
||||||
|
const flat = JSON.stringify(blocks);
|
||||||
|
expect(flat).not.toContain(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ZWSP-neutralized trigger lines round-trip as a single paragraph, text preserved", async () => {
|
||||||
|
for (const line of triggerLines) {
|
||||||
|
// The regex must actually classify each as a trigger.
|
||||||
|
expect(MD_BLOCK_TRIGGER_RE.test(line), `regex missed "${line}"`).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const neutralized = ZWSP + line;
|
||||||
|
const blocks = await roundtrip(neutralized);
|
||||||
|
|
||||||
|
expect(blocks).toHaveLength(1);
|
||||||
|
expect(blocks[0].type).toBe("paragraph");
|
||||||
|
// Text is byte-preserved (ZWSP + original line), so the display is the
|
||||||
|
// original line with only an invisible leading character.
|
||||||
|
expect(blocks[0].content[0].text).toBe(neutralized);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normal host-prefixed lines never match the trigger regex and round-trip byte-exact", async () => {
|
||||||
|
for (const line of [
|
||||||
|
"You: hello there",
|
||||||
|
"Speaker 1: - and then a dash mid-line",
|
||||||
|
"Speaker 2: 1. not a list",
|
||||||
|
]) {
|
||||||
|
expect(MD_BLOCK_TRIGGER_RE.test(line)).toBe(false);
|
||||||
|
const blocks = await roundtrip(line);
|
||||||
|
expect(blocks).toHaveLength(1);
|
||||||
|
expect(blocks[0].type).toBe("paragraph");
|
||||||
|
expect(blocks[0].content[0].text).toBe(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user