Compare commits

..

2 Commits

Author SHA1 Message Date
agent_coder babc42c2ff fix(#346 review F1-F4): no 206-compress + Vary + precompress VAD + cache test
- F1 [HIGH — data corruption]: @fastify/compress was compressing 206/Range
  attachment responses while Content-Range still described the RAW offsets, so a
  resuming client (curl -C -, download managers) appended encoded bytes as raw →
  corrupted file. sendFileResponse now sets the request header `x-no-compression`
  (the documented @fastify/compress opt-out — its onSend skips when the request
  carries it; the reviewer's `Content-Encoding: identity` does NOT work because
  compress explicitly excludes `identity` and overwrites it). This opts the whole
  download route (both 200 full-file and 206 range) out of on-the-fly compression
  — correct, since attachment bytes are final and mostly binary.
- F2: static responses now emit `Vary: Accept-Encoding` (the preCompressed
  content-negotiated /assets/* were `immutable` without Vary → shared-cache could
  serve a brotli variant to an identity/gzip-only client).
- F3: vite compression `include` extended to .wasm/.onnx so the VAD binaries
  (~26MB .wasm, ~2.3MB .onnx under public/vad) are precompressed at build (.br
  emitted) instead of runtime-brotli'd on every request. (include REPLACES the
  plugin default, so the default js/css/json/html set is re-listed.)
- F4: extracted the cache classification into a pure `resolveStaticAssetHeaders`
  + static.module.spec.ts (3 tests: /assets/* immutable+Vary, index.html
  no-store, non-hashed not-immutable).

Gate: server tsc 0 (deps present), static.module.spec 3/3, client build emits
.wasm.br/.onnx.br, frozen install 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 04:13:20 +03:00
agent_coder 6ee814b7f3 perf(delivery): pre-compress static + cache headers + compress API responses (#346)
Cold load served ALL static + API responses uncompressed and without cache
headers (~3.7MB over the wire). Delivery only — feature behavior unchanged; no
DB/API-contract/MCP changes.

- apps/client/vite.config.ts: vite-plugin-compression2 emits .br + .gz next to
  each built asset (excludes index.html, which the server rewrites at boot with
  window.CONFIG — a precompressed copy would go stale). Build emits 187 .br /
  175 .gz under dist/assets.
- static.module.ts: @fastify/static `preCompressed: true` serves the .br/.gz
  neighbour; `setHeaders` sets `immutable` ONLY for content-hashed /assets/*,
  `no-cache` for index.html, and leaves non-hashed files (locales, vad, icons,
  manifest) on default etag/last-modified revalidation.
- main.ts: @fastify/compress (threshold 1024) compresses dynamic API JSON + the
  rewritten share-SEO HTML. SSE is safe on two counts: `text/event-stream` is not
  mime-db-compressible (allowlist skips it) AND the AI-chat stream hijacks the raw
  socket (pipeUIMessageStreamToResponse -> res.raw), bypassing the Fastify onSend
  lifecycle entirely. No double-compression with preCompressed static (compress
  skips already-Content-Encoding'd responses).
- docker-compose.yml: comment recommending an optional HTTP/2 + brotli reverse
  proxy (not required).

Deps: apps/client vite-plugin-compression2 2.5.3 (dev), apps/server
@fastify/compress 9.0.0 (matches fastify 5.8.5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 04:13:20 +03:00
19 changed files with 309 additions and 1144 deletions
+1
View File
@@ -98,6 +98,7 @@
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.57.1", "typescript-eslint": "8.57.1",
"vite": "8.0.5", "vite": "8.0.5",
"vite-plugin-compression2": "2.5.3",
"vitest": "4.1.6" "vitest": "4.1.6"
} }
} }
@@ -45,7 +45,6 @@ import {
TiptapPdf, TiptapPdf,
PageBreak, PageBreak,
SearchAndReplace, SearchAndReplace,
MultiCursor,
Mention, Mention,
TableDndExtension, TableDndExtension,
TableHandleCommandsExtension, TableHandleCommandsExtension,
@@ -448,10 +447,6 @@ 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({
@@ -1,6 +1,5 @@
@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";
@@ -1,60 +0,0 @@
/*
* 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;
}
}
+20 -1
View File
@@ -1,5 +1,6 @@
import { defineConfig, loadEnv } from "vite"; import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { compression } from "vite-plugin-compression2";
import * as path from "path"; import * as path from "path";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
@@ -53,7 +54,25 @@ export default defineConfig(({ mode }) => {
}, },
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)), APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
}, },
plugins: [react()], plugins: [
react(),
// Emit .br and .gz next to every built asset so the server can serve the
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
compression({
algorithms: ["brotliCompress", "gzip"],
// vite-plugin-compression2's default `include` only covers text-ish
// bundle output (js/mjs/json/css/html/svg/…). Extend it with the large
// VAD binaries copied from public/vad (.wasm ~26MB, .onnx ~2.3MB) so
// they are brotli/gzip'd once at build time and served via
// @fastify/static preCompressed — otherwise @fastify/compress would
// re-brotli them on EVERY request. The default types are repeated here
// because setting `include` replaces (does not extend) the default.
include: /\.(html|xml|css|json|js|mjs|svg|yaml|yml|toml|wasm|onnx)$/,
// index.html is rewritten at server boot (window.CONFIG injection); a
// precompressed copy would go stale — NEVER precompress it.
exclude: [/index\.html$/],
}),
],
build: { build: {
rolldownOptions: { rolldownOptions: {
output: { output: {
+1
View File
@@ -44,6 +44,7 @@
"@docmost/mcp": "workspace:*", "@docmost/mcp": "workspace:*",
"@docmost/pdf-inspector": "1.9.6", "@docmost/pdf-inspector": "1.9.6",
"@docmost/prosemirror-markdown": "workspace:*", "@docmost/prosemirror-markdown": "workspace:*",
"@fastify/compress": "^9.0.0",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^10.0.0", "@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3", "@fastify/static": "^9.1.3",
@@ -474,6 +474,19 @@ export class AttachmentController {
const fileSize = Number(attachment.fileSize); const fileSize = Number(attachment.fileSize);
const rangeHeader = req.headers.range; const rangeHeader = req.headers.range;
// Opt this download route out of the global @fastify/compress hook.
// Attachment bytes are final and mostly binary, so on-the-fly compression
// only burns CPU — and on the 206/Range branch it is actively corrupting:
// compress decides purely by Content-Type, so for a compressible mime
// (application/octet-stream fallback, image/svg+xml, text/*) it would gzip
// the byte slice and drop Content-Length while Content-Range still
// describes the RAW offsets and the status stays 206. A resuming client
// (`curl -C -`, download managers) then appends the encoded bytes as if
// raw and ends up with a broken file. @fastify/compress skips whenever the
// request carries `x-no-compression` (see its onSend hook), so setting it
// here covers both the 200 (full file) and 206 (range) responses.
req.headers['x-no-compression'] = 'true';
res.header('Accept-Ranges', 'bytes'); res.header('Accept-Ranges', 'bytes');
res.header( res.header(
'Content-Security-Policy', 'Content-Security-Policy',
@@ -0,0 +1,35 @@
import { resolveStaticAssetHeaders } from './static.module';
// Unit tests for the static-asset cache classifier extracted from the
// @fastify/static setHeaders callback (precedent: sandbox.controller.spec.ts).
describe('resolveStaticAssetHeaders', () => {
it('marks a content-hashed /assets/ file immutable and sets Vary', () => {
const headers = resolveStaticAssetHeaders(
'/app/apps/client/dist/assets/index-a1b2c3.js',
);
expect(headers['cache-control']).toBe(
'public, max-age=31536000, immutable',
);
expect(headers['vary']).toBe('Accept-Encoding');
});
it('makes index.html always revalidate (never immutable)', () => {
const headers = resolveStaticAssetHeaders(
'/app/apps/client/dist/index.html',
);
expect(headers['cache-control']).toBe(
'no-cache, no-store, must-revalidate',
);
expect(headers['vary']).toBe('Accept-Encoding');
});
it('does NOT mark a non-hashed asset immutable but still sets Vary', () => {
const headers = resolveStaticAssetHeaders(
'/app/apps/client/dist/locales/en.json',
);
// No immutable cache-control — this path keeps @fastify/static's default
// etag/last-modified revalidation.
expect(headers['cache-control']).toBeUndefined();
expect(headers['vary']).toBe('Accept-Encoding');
});
});
@@ -5,6 +5,46 @@ import * as fs from 'node:fs';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
import { EnvironmentService } from '../environment/environment.service'; import { EnvironmentService } from '../environment/environment.service';
/**
* Resolve the response headers for a statically served client asset.
*
* Extracted from the @fastify/static `setHeaders` callback so the cache
* classification stays a pure, unit-testable function (see
* static.module.spec.ts).
*
* `Vary: Accept-Encoding` is emitted for every static response because
* @fastify/static negotiates a precompressed .br/.gz neighbour by the client's
* Accept-Encoding but does NOT set Vary itself. Without it a shared/proxy cache
* keyed on the URL alone could store the brotli variant and later serve it to a
* client that only sent `Accept-Encoding: identity`/gzip → an undecodable body.
* This matters most for the immutable /assets/ files, which proxies may keep
* for a year.
*/
export function resolveStaticAssetHeaders(
filePath: string,
): Record<string, string> {
const headers: Record<string, string> = { vary: 'Accept-Encoding' };
// Content-hashed files under /assets/ never change for a given URL, so they
// can be cached forever and skip revalidation entirely.
if (filePath.includes('/assets/')) {
headers['cache-control'] = 'public, max-age=31536000, immutable';
return headers;
}
// index.html is rewritten at boot (window.CONFIG injection) and on every
// deploy — it must be revalidated on every load.
if (filePath.endsWith('index.html')) {
headers['cache-control'] = 'no-cache, no-store, must-revalidate';
return headers;
}
// Everything else (locales, vad, icons, manifest) is NOT content-hashed and
// changes between deploys, so it keeps @fastify/static's default
// etag/last-modified revalidation — do NOT mark it immutable.
return headers;
}
@Module({}) @Module({})
export class StaticModule implements OnModuleInit { export class StaticModule implements OnModuleInit {
constructor( constructor(
@@ -72,6 +112,16 @@ export class StaticModule implements OnModuleInit {
await app.register(fastifyStatic, { await app.register(fastifyStatic, {
root: clientDistPath, root: clientDistPath,
wildcard: false, wildcard: false,
// Serve the build-time .br/.gz neighbour when the client accepts it
// (see vite-plugin-compression2 in apps/client/vite.config.ts).
preCompressed: true,
setHeaders: (res, filePath) => {
for (const [name, value] of Object.entries(
resolveStaticAssetHeaders(filePath),
)) {
res.setHeader(name, value);
}
},
}); });
app.get(RENDER_PATH, (req: any, res: any) => { app.get(RENDER_PATH, (req: any, res: any) => {
+12
View File
@@ -10,6 +10,7 @@ import { TransformHttpResponseInterceptor } from './common/interceptors/http-res
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter'; import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
import fastifyMultipart from '@fastify/multipart'; import fastifyMultipart from '@fastify/multipart';
import fastifyCookie from '@fastify/cookie'; import fastifyCookie from '@fastify/cookie';
import fastifyCompress from '@fastify/compress';
import fastifyIp from 'fastify-ip'; import fastifyIp from 'fastify-ip';
import { InternalLogFilter } from './common/logger/internal-log-filter'; import { InternalLogFilter } from './common/logger/internal-log-filter';
import { EnvironmentService } from './integrations/environment/environment.service'; import { EnvironmentService } from './integrations/environment/environment.service';
@@ -63,6 +64,17 @@ async function bootstrap() {
await app.register(fastifyIp); await app.register(fastifyIp);
await app.register(fastifyMultipart); await app.register(fastifyMultipart);
await app.register(fastifyCookie); await app.register(fastifyCookie);
// Compress dynamic responses (API JSON, the rewritten share-SEO HTML) when the
// client accepts br/gzip. @fastify/compress only compresses content-types that
// mime-db flags `compressible` (application/json, text/html, …); `text/event-stream`
// is not in mime-db, so SSE is never compressed by the allowlist. The AI-chat
// stream additionally hijacks the raw socket (pipeUIMessageStreamToResponse ->
// res.raw in ai-chat.service.ts), bypassing Fastify's reply/onSend lifecycle
// entirely, so this hook can never buffer that stream.
await app.register(fastifyCompress, {
// Skip tiny payloads where compression overhead outweighs the savings.
threshold: 1024,
});
const environmentService = app.get(EnvironmentService); const environmentService = app.get(EnvironmentService);
const frameHeader = resolveFrameHeader( const frameHeader = resolveFrameHeader(
+5
View File
@@ -12,6 +12,11 @@ services:
ports: ports:
- "3000:3000" - "3000:3000"
restart: unless-stopped restart: unless-stopped
# The app already serves precompressed (brotli/gzip) static assets with
# long-lived cache headers and gzips dynamic API responses. For the best
# cold-load latency you can OPTIONALLY put a reverse proxy (caddy / nginx /
# traefik) in front with HTTP/2 (or HTTP/3) and brotli enabled — none is
# required for compression to work.
volumes: volumes:
- docmost:/app/data/storage - docmost:/app/data/storage
-1
View File
@@ -20,7 +20,6 @@ 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";
@@ -1,3 +0,0 @@
import { MultiCursor } from "./multi-cursor";
export * from "./multi-cursor";
export default MultiCursor;
@@ -1,453 +0,0 @@
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();
});
});
@@ -1,545 +0,0 @@
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;
@@ -1,69 +0,0 @@
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,4 +1,3 @@
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,7 +29,6 @@ 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 {
@@ -77,6 +76,11 @@ declare module "@tiptap/core" {
} }
} }
interface TextNodesWithPosition {
text: string;
pos: number;
}
const getRegex = ( const getRegex = (
s: string, s: string,
disableRegex: boolean, disableRegex: boolean,
@@ -100,6 +104,10 @@ 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 {
@@ -108,8 +116,43 @@ function processSearches(
}; };
} }
// Shared find-all-occurrences primitive (also used by multi-cursor). doc?.descendants((node, pos) => {
const results: Range[] = findOccurrences(doc, searchTerm); 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,
});
}
}
}
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];
+126 -2
View File
@@ -507,6 +507,9 @@ importers:
vite: vite:
specifier: 8.0.5 specifier: 8.0.5
version: 8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3) version: 8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)
vite-plugin-compression2:
specifier: 2.5.3
version: 2.5.3
vitest: vitest:
specifier: 4.1.6 specifier: 4.1.6
version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
@@ -549,6 +552,9 @@ importers:
'@docmost/prosemirror-markdown': '@docmost/prosemirror-markdown':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/prosemirror-markdown version: link:../../packages/prosemirror-markdown
'@fastify/compress':
specifier: ^9.0.0
version: 9.0.0
'@fastify/cookie': '@fastify/cookie':
specifier: ^11.0.2 specifier: ^11.0.2
version: 11.0.2 version: 11.0.2
@@ -2706,6 +2712,9 @@ packages:
'@fastify/busboy@3.1.1': '@fastify/busboy@3.1.1':
resolution: {integrity: sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==} resolution: {integrity: sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==}
'@fastify/compress@9.0.0':
resolution: {integrity: sha512-PZRg+ut5xd/ubsGPWfoPNryoCOtEdHboIWpDieTUHov1gKdLitF8mRmT3JbqNnRbelQXSNXUsIpakAEKR6AcTQ==}
'@fastify/cookie@11.0.2': '@fastify/cookie@11.0.2':
resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==}
@@ -4499,6 +4508,15 @@ packages:
'@rolldown/pluginutils@1.0.0-rc.7': '@rolldown/pluginutils@1.0.0-rc.7':
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
'@rollup/pluginutils@5.4.0':
resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==}
engines: {node: '>=14.0.0'}
peerDependencies:
rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
peerDependenciesMeta:
rollup:
optional: true
'@selderee/plugin-htmlparser2@0.11.0': '@selderee/plugin-htmlparser2@0.11.0':
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
@@ -5688,6 +5706,10 @@ packages:
resolution: {integrity: sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==} resolution: {integrity: sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
abstract-logging@2.0.1: abstract-logging@2.0.1:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
@@ -6070,6 +6092,9 @@ packages:
buffer@5.7.1: buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
bullmq@5.76.10: bullmq@5.76.10:
resolution: {integrity: sha512-LWve7SpQjYSpCP2GEsWmoyzTz2H37L8HRmSTu3YihYsTOr5kJxrfEX6aEV7m6eskEMWXSHZYTMZepX6qNaH6CQ==} resolution: {integrity: sha512-LWve7SpQjYSpCP2GEsWmoyzTz2H37L8HRmSTu3YihYsTOr5kJxrfEX6aEV7m6eskEMWXSHZYTMZepX6qNaH6CQ==}
engines: {node: '>=12.22.0'} engines: {node: '>=12.22.0'}
@@ -6825,6 +6850,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
duplexify@3.7.1:
resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==}
ecdsa-sig-formatter@1.0.11: ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
@@ -7068,6 +7096,9 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3: estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
@@ -7079,6 +7110,10 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventemitter2@6.4.9: eventemitter2@6.4.9:
resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==}
@@ -9088,6 +9123,9 @@ packages:
peberminta@0.9.0: peberminta@0.9.0:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
peek-stream@1.1.3:
resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==}
pend@1.2.0: pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
@@ -9333,6 +9371,10 @@ packages:
process-warning@5.0.0: process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
prom-client@15.1.3: prom-client@15.1.3:
resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==} resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==}
engines: {node: ^16 || ^18 || >=20} engines: {node: ^16 || ^18 || >=20}
@@ -9628,6 +9670,10 @@ packages:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
readable-stream@4.7.0:
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
readdirp@3.6.0: readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'} engines: {node: '>=8.10.0'}
@@ -10016,6 +10062,9 @@ packages:
stream-browserify@3.0.0: stream-browserify@3.0.0:
resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==}
stream-shift@1.0.3:
resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==}
strict-event-emitter-types@2.0.0: strict-event-emitter-types@2.0.0:
resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==} resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==}
@@ -10156,6 +10205,9 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'} engines: {node: '>=6'}
tar-mini@0.2.0:
resolution: {integrity: sha512-+qfUHz700DWnRutdUsxRRVZ38G1Qr27OetwaMYTdg8hcPxf46U0S1Zf76dQMWRBmusOt2ZCK5kbIaiLkoGO7WQ==}
tar-stream@2.2.0: tar-stream@2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -10199,6 +10251,9 @@ packages:
resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==}
engines: {node: '>=18'} engines: {node: '>=18'}
through2@2.0.5:
resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==}
tiny-invariant@1.3.3: tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -10604,6 +10659,9 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
vite-plugin-compression2@2.5.3:
resolution: {integrity: sha512-ItPgqQWkcnBbVw7is9OKwiZ8v6+ju9rYROl5Lp6QfQDEx/d55AwJQb/KLpsQqsU9HoigYBsZ8tK6I02UwJNvEw==}
vite@8.0.5: vite@8.0.5:
resolution: {integrity: sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==} resolution: {integrity: sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -12943,6 +13001,15 @@ snapshots:
'@fastify/busboy@3.1.1': {} '@fastify/busboy@3.1.1': {}
'@fastify/compress@9.0.0':
dependencies:
'@fastify/accept-negotiator': 2.0.1
fastify-plugin: 5.1.0
mime-db: 1.54.0
minipass: 7.1.3
peek-stream: 1.1.3
readable-stream: 4.7.0
'@fastify/cookie@11.0.2': '@fastify/cookie@11.0.2':
dependencies: dependencies:
cookie: 1.1.1 cookie: 1.1.1
@@ -14958,6 +15025,12 @@ snapshots:
'@rolldown/pluginutils@1.0.0-rc.7': {} '@rolldown/pluginutils@1.0.0-rc.7': {}
'@rollup/pluginutils@5.4.0':
dependencies:
'@types/estree': 1.0.9
estree-walker: 2.0.2
picomatch: 4.0.4
'@selderee/plugin-htmlparser2@0.11.0': '@selderee/plugin-htmlparser2@0.11.0':
dependencies: dependencies:
domhandler: 5.0.3 domhandler: 5.0.3
@@ -16331,6 +16404,10 @@ snapshots:
abbrev@5.0.0: {} abbrev@5.0.0: {}
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
abstract-logging@2.0.1: {} abstract-logging@2.0.1: {}
accepts@1.3.8: accepts@1.3.8:
@@ -16779,6 +16856,11 @@ snapshots:
base64-js: 1.5.1 base64-js: 1.5.1
ieee754: 1.2.1 ieee754: 1.2.1
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
bullmq@5.76.10: bullmq@5.76.10:
dependencies: dependencies:
cron-parser: 4.9.0 cron-parser: 4.9.0
@@ -17532,6 +17614,13 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
gopd: 1.2.0 gopd: 1.2.0
duplexify@3.7.1:
dependencies:
end-of-stream: 1.4.4
inherits: 2.0.4
readable-stream: 2.3.8
stream-shift: 1.0.3
ecdsa-sig-formatter@1.0.11: ecdsa-sig-formatter@1.0.11:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
@@ -17958,6 +18047,8 @@ snapshots:
estraverse@5.3.0: {} estraverse@5.3.0: {}
estree-walker@2.0.2: {}
estree-walker@3.0.3: estree-walker@3.0.3:
dependencies: dependencies:
'@types/estree': 1.0.9 '@types/estree': 1.0.9
@@ -17966,6 +18057,8 @@ snapshots:
etag@1.8.1: {} etag@1.8.1: {}
event-target-shim@5.0.1: {}
eventemitter2@6.4.9: {} eventemitter2@6.4.9: {}
eventemitter3@4.0.7: {} eventemitter3@4.0.7: {}
@@ -20229,6 +20322,12 @@ snapshots:
peberminta@0.9.0: {} peberminta@0.9.0: {}
peek-stream@1.1.3:
dependencies:
buffer-from: 1.1.2
duplexify: 3.7.1
through2: 2.0.5
pend@1.2.0: {} pend@1.2.0: {}
perfect-freehand@1.2.0: {} perfect-freehand@1.2.0: {}
@@ -20500,6 +20599,8 @@ snapshots:
process-warning@5.0.0: {} process-warning@5.0.0: {}
process@0.11.10: {}
prom-client@15.1.3: prom-client@15.1.3:
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
@@ -20921,6 +21022,14 @@ snapshots:
string_decoder: 1.3.0 string_decoder: 1.3.0
util-deprecate: 1.0.2 util-deprecate: 1.0.2
readable-stream@4.7.0:
dependencies:
abort-controller: 3.0.0
buffer: 6.0.3
events: 3.3.0
process: 0.11.10
string_decoder: 1.3.0
readdirp@3.6.0: readdirp@3.6.0:
dependencies: dependencies:
picomatch: 2.3.2 picomatch: 2.3.2
@@ -21374,6 +21483,8 @@ snapshots:
inherits: 2.0.4 inherits: 2.0.4
readable-stream: 3.6.2 readable-stream: 3.6.2
stream-shift@1.0.3: {}
strict-event-emitter-types@2.0.0: {} strict-event-emitter-types@2.0.0: {}
string-length@4.0.2: string-length@4.0.2:
@@ -21534,6 +21645,8 @@ snapshots:
tapable@2.3.0: {} tapable@2.3.0: {}
tar-mini@0.2.0: {}
tar-stream@2.2.0: tar-stream@2.2.0:
dependencies: dependencies:
bl: 4.1.0 bl: 4.1.0
@@ -21583,6 +21696,11 @@ snapshots:
throttleit@2.1.0: {} throttleit@2.1.0: {}
through2@2.0.5:
dependencies:
readable-stream: 2.3.8
xtend: 4.0.2
tiny-invariant@1.3.3: {} tiny-invariant@1.3.3: {}
tinybench@2.9.0: {} tinybench@2.9.0: {}
@@ -21987,6 +22105,13 @@ snapshots:
vary@1.1.2: {} vary@1.1.2: {}
vite-plugin-compression2@2.5.3:
dependencies:
'@rollup/pluginutils': 5.4.0
tar-mini: 0.2.0
transitivePeerDependencies:
- rollup
vite@8.0.5(@types/node@20.19.43)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3): vite@8.0.5(@types/node@20.19.43)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3):
dependencies: dependencies:
lightningcss: 1.32.0 lightningcss: 1.32.0
@@ -22367,8 +22492,7 @@ snapshots:
xpath@0.0.34: {} xpath@0.0.34: {}
xtend@4.0.2: xtend@4.0.2: {}
optional: true
y-indexeddb@9.0.12(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)): y-indexeddb@9.0.12(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)):
dependencies: dependencies: