Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| babc42c2ff | |||
| 6ee814b7f3 |
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
Generated
+126
-2
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user