Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e4bfbcabaa | |||
| b8cce4f814 | |||
| a325ddbabd |
@@ -1373,6 +1373,39 @@
|
||||
"The role catalog is unavailable": "The role catalog is unavailable",
|
||||
"Please try again later.": "Please try again later.",
|
||||
"No bundles available": "No bundles available",
|
||||
"Content": "Content",
|
||||
"Content language of the roles": "Content language of the roles",
|
||||
"{{count}} updates available in {{bundles}} bundles": "{{count}} updates available in {{bundles}} bundles",
|
||||
"Update all ({{count}})": "Update all ({{count}})",
|
||||
"Updating {{current}}/{{total}}…": "Updating {{current}}/{{total}}…",
|
||||
"{{count}} roles are installed in another language. A different language installs separately and appears as new.": "{{count}} roles are installed in another language. A different language installs separately and appears as new.",
|
||||
"{{count}} roles": "{{count}} roles",
|
||||
"{{count}} new — none installed": "{{count}} new — none installed",
|
||||
"All installed · up to date": "All installed · up to date",
|
||||
"{{count}} updates · {{installed}} up to date": "{{count}} updates · {{installed}} up to date",
|
||||
"{{count}} new": "{{count}} new",
|
||||
"{{count}} installed": "{{count}} installed",
|
||||
"{{count}} updates": "{{count}} updates",
|
||||
"Install bundle": "Install bundle",
|
||||
"Install {{count}} selected": "Install {{count}} selected",
|
||||
"Install bundle ({{count}})": "Install bundle ({{count}})",
|
||||
"{{selected}} of {{total}} selected": "{{selected}} of {{total}} selected",
|
||||
"Select all": "Select all",
|
||||
"Deselect all": "Deselect all",
|
||||
"Skipped": "Skipped",
|
||||
"v{{version}}": "v{{version}}",
|
||||
"{{count}} roles installed": "{{count}} roles installed",
|
||||
"{{count}} roles installed · {{renamed}} renamed": "{{count}} roles installed · {{renamed}} renamed",
|
||||
"{{count}} roles updated": "{{count}} roles updated",
|
||||
"Installed {{installed}} · {{skipped}} skipped": "Installed {{installed}} · {{skipped}} skipped",
|
||||
"A role named \"{{name}}\" already exists in this workspace.": "A role named \"{{name}}\" already exists in this workspace.",
|
||||
"\"{{name}}\" is already installed.": "\"{{name}}\" is already installed.",
|
||||
"Rename & install": "Rename & install",
|
||||
"Couldn’t load the catalog": "Couldn’t load the catalog",
|
||||
"Check your connection and try again. Installed roles are not affected.": "Check your connection and try again. Installed roles are not affected.",
|
||||
"Retry": "Retry",
|
||||
"The catalog is empty": "The catalog is empty",
|
||||
"No role bundles are published for this language yet. Try switching the content language.": "No role bundles are published for this language yet. Try switching the content language.",
|
||||
"Already up to date": "Already up to date",
|
||||
"Updated to the latest version": "Updated to the latest version",
|
||||
"This role is no longer in the catalog": "This role is no longer in the catalog",
|
||||
|
||||
@@ -1235,6 +1235,39 @@
|
||||
"The role catalog is unavailable": "Каталог ролей недоступен",
|
||||
"Please try again later.": "Попробуйте позже.",
|
||||
"No bundles available": "Наборы недоступны",
|
||||
"Content": "Язык контента",
|
||||
"Content language of the roles": "Язык контента ролей",
|
||||
"{{count}} updates available in {{bundles}} bundles": "Доступно обновлений: {{count}} в наборах: {{bundles}}",
|
||||
"Update all ({{count}})": "Обновить все ({{count}})",
|
||||
"Updating {{current}}/{{total}}…": "Обновление {{current}}/{{total}}…",
|
||||
"{{count}} roles are installed in another language. A different language installs separately and appears as new.": "Ролей установлено на другом языке: {{count}}. Другой язык устанавливается отдельно и отображается как новый.",
|
||||
"{{count}} roles": "ролей: {{count}}",
|
||||
"{{count}} new — none installed": "новых: {{count}} — ничего не установлено",
|
||||
"All installed · up to date": "Все установлены · актуальны",
|
||||
"{{count}} updates · {{installed}} up to date": "обновлений: {{count}} · актуальны: {{installed}}",
|
||||
"{{count}} new": "новых: {{count}}",
|
||||
"{{count}} installed": "установлено: {{count}}",
|
||||
"{{count}} updates": "обновлений: {{count}}",
|
||||
"Install bundle": "Установить набор",
|
||||
"Install {{count}} selected": "Установить выбранные ({{count}})",
|
||||
"Install bundle ({{count}})": "Установить набор ({{count}})",
|
||||
"{{selected}} of {{total}} selected": "выбрано {{selected}} из {{total}}",
|
||||
"Select all": "Выбрать все",
|
||||
"Deselect all": "Снять выбор",
|
||||
"Skipped": "Пропущено",
|
||||
"v{{version}}": "v{{version}}",
|
||||
"{{count}} roles installed": "Установлено ролей: {{count}}",
|
||||
"{{count}} roles installed · {{renamed}} renamed": "Установлено ролей: {{count}} · переименовано: {{renamed}}",
|
||||
"{{count}} roles updated": "Обновлено ролей: {{count}}",
|
||||
"Installed {{installed}} · {{skipped}} skipped": "Установлено: {{installed}} · пропущено: {{skipped}}",
|
||||
"A role named \"{{name}}\" already exists in this workspace.": "Роль с именем «{{name}}» уже существует в этом рабочем пространстве.",
|
||||
"\"{{name}}\" is already installed.": "«{{name}}» уже установлена.",
|
||||
"Rename & install": "Переименовать и установить",
|
||||
"Couldn’t load the catalog": "Не удалось загрузить каталог",
|
||||
"Check your connection and try again. Installed roles are not affected.": "Проверьте подключение и попробуйте снова. Установленные роли не затронуты.",
|
||||
"Retry": "Повторить",
|
||||
"The catalog is empty": "Каталог пуст",
|
||||
"No role bundles are published for this language yet. Try switching the content language.": "Для этого языка ещё не опубликовано ни одного набора ролей. Попробуйте сменить язык контента.",
|
||||
"No roles configured": "Роли не настроены",
|
||||
"Already up to date": "Уже актуальна",
|
||||
"Updated to the latest version": "Обновлено до последней версии",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQueries,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
@@ -307,6 +308,29 @@ export function useAiRoleCatalogBundleQuery(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Eagerly open EVERY listed bundle's content in parallel for one language. The
|
||||
* redesigned catalog shows each bundle's status summary in its COLLAPSED header,
|
||||
* which needs every role's install state up front — so contents can no longer be
|
||||
* lazy-loaded on expand. The catalog is small, so a fan-out of `useQueries` (one
|
||||
* cached read per bundle, sharing the same cache keys as
|
||||
* `useAiRoleCatalogBundleQuery`) is cheap. Gated by `enabled` (modal open + a
|
||||
* resolved language) so nothing fetches while the modal is closed.
|
||||
*/
|
||||
export function useAiRoleCatalogBundlesQueries(
|
||||
bundleIds: string[],
|
||||
language: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useQueries({
|
||||
queries: bundleIds.map((bundleId) => ({
|
||||
queryKey: AI_ROLE_CATALOG_BUNDLE_RQ_KEY(bundleId, language),
|
||||
queryFn: () => getAiRoleCatalogBundle(bundleId, language),
|
||||
enabled: enabled && !!language,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export function useImportAiRolesFromCatalogMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -77,7 +77,14 @@ describe("useImportAiRolesFromCatalogMutation — success notifications", () =>
|
||||
});
|
||||
|
||||
it("errors:[] -> only the summary notification (counts interpolated)", async () => {
|
||||
await runMutation({ created: 3, renamed: 1, skipped: 2, errors: [] });
|
||||
await runMutation({
|
||||
created: 3,
|
||||
renamed: 1,
|
||||
skipped: 2,
|
||||
errors: [],
|
||||
createdRoles: [],
|
||||
skippedRoles: [],
|
||||
});
|
||||
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||
message: "Imported 3, renamed 1, skipped 2",
|
||||
@@ -93,6 +100,8 @@ describe("useImportAiRolesFromCatalogMutation — success notifications", () =>
|
||||
{ slug: "a", message: "name taken" },
|
||||
{ slug: "b", message: "name taken" },
|
||||
],
|
||||
createdRoles: [{ slug: "ok", name: "Ok" }],
|
||||
skippedRoles: [],
|
||||
});
|
||||
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
|
||||
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
|
||||
|
||||
@@ -108,12 +108,25 @@ export interface IAiRoleImportPayload {
|
||||
conflict: "skip" | "rename";
|
||||
}
|
||||
|
||||
/** Import result counts (mirrors `importFromCatalog()`). */
|
||||
/**
|
||||
* Import result (mirrors `importFromCatalog()`). The counters (`created`,
|
||||
* `skipped`, `renamed`) drive the summary notification; the per-role lists
|
||||
* (`createdRoles`, `skippedRoles`) drive the redesigned catalog modal's inline
|
||||
* result plaque — which roles were installed (and any rename) and which were
|
||||
* skipped and why (so the plaque can name the conflicting role and offer
|
||||
* "Rename & install").
|
||||
*/
|
||||
export interface IAiRoleImportResult {
|
||||
created: number;
|
||||
skipped: number;
|
||||
renamed: number;
|
||||
errors: { slug: string; message: string }[];
|
||||
createdRoles: { slug: string; name: string; renamedTo?: string }[];
|
||||
skippedRoles: {
|
||||
slug: string;
|
||||
name: string;
|
||||
reason: "name-conflict" | "already-installed";
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
bundleCounts,
|
||||
bundlePhase,
|
||||
installedLangForRole,
|
||||
mapBundleRolesToView,
|
||||
mapCatalogRoleToView,
|
||||
nameConflictSlugs,
|
||||
partialOffersRename,
|
||||
type CatalogViewRole,
|
||||
} from "./catalog-bundle-model.ts";
|
||||
import type {
|
||||
IAiRole,
|
||||
IAiRoleCatalogRole,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
function installedRole(
|
||||
source: { slug: string; language: string; version: number },
|
||||
overrides: Partial<IAiRole> = {},
|
||||
): IAiRole {
|
||||
return {
|
||||
id: `role-${source.slug}-${source.language}`,
|
||||
name: source.slug,
|
||||
emoji: null,
|
||||
description: null,
|
||||
enabled: true,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
source,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function catalogRole(
|
||||
overrides: Partial<IAiRoleCatalogRole> = {},
|
||||
): IAiRoleCatalogRole {
|
||||
return {
|
||||
slug: "writer",
|
||||
emoji: "✍️",
|
||||
name: "Writer",
|
||||
description: "Drafts copy.",
|
||||
instructions: "be a writer",
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
version: 3,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Build a minimal view role for bundlePhase tests.
|
||||
function viewRole(status: CatalogViewRole["status"]): CatalogViewRole {
|
||||
return { slug: `s-${status}`, name: status, description: "", version: 1, status };
|
||||
}
|
||||
|
||||
describe("bundlePhase", () => {
|
||||
it("empty bundle -> empty", () => {
|
||||
expect(bundlePhase([])).toBe("empty");
|
||||
});
|
||||
|
||||
it("all importable, none installed -> allNew", () => {
|
||||
expect(bundlePhase([viewRole("import"), viewRole("import")])).toBe(
|
||||
"allNew",
|
||||
);
|
||||
});
|
||||
|
||||
it("nothing to import or update -> allInstalled", () => {
|
||||
expect(bundlePhase([viewRole("installed"), viewRole("installed")])).toBe(
|
||||
"allInstalled",
|
||||
);
|
||||
});
|
||||
|
||||
it("updates present, nothing to import -> updates", () => {
|
||||
expect(bundlePhase([viewRole("update"), viewRole("installed")])).toBe(
|
||||
"updates",
|
||||
);
|
||||
});
|
||||
|
||||
it("import + installed (no updates) -> mixed", () => {
|
||||
expect(bundlePhase([viewRole("import"), viewRole("installed")])).toBe(
|
||||
"mixed",
|
||||
);
|
||||
});
|
||||
|
||||
it("import + update -> mixed", () => {
|
||||
expect(bundlePhase([viewRole("import"), viewRole("update")])).toBe("mixed");
|
||||
});
|
||||
|
||||
it("a skipped role with nothing installed -> mixed (NOT allInstalled)", () => {
|
||||
// F1: a bundle whose only non-installed role was skipped has 0 installed for
|
||||
// it, so the collapsed 'All installed · up to date' header would contradict
|
||||
// the open 'Installed 0 · 1 skipped' plaque. It must be mixed until resolved.
|
||||
expect(bundlePhase([viewRole("skipped")])).toBe("mixed");
|
||||
});
|
||||
|
||||
it("installed + a skipped role -> mixed (partial success is not allInstalled)", () => {
|
||||
expect(bundlePhase([viewRole("installed"), viewRole("skipped")])).toBe(
|
||||
"mixed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundleCounts", () => {
|
||||
it("tallies each status once", () => {
|
||||
expect(
|
||||
bundleCounts([
|
||||
viewRole("import"),
|
||||
viewRole("import"),
|
||||
viewRole("installed"),
|
||||
viewRole("update"),
|
||||
viewRole("skipped"),
|
||||
]),
|
||||
).toEqual({ importable: 2, installed: 1, update: 1, skipped: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("nameConflictSlugs / partialOffersRename (reason -> action)", () => {
|
||||
it("only name-conflict skips become the transient overlay / offer rename", () => {
|
||||
const skipped = [
|
||||
{ slug: "writer", name: "Writer", reason: "name-conflict" as const },
|
||||
{ slug: "editor", name: "Editor", reason: "already-installed" as const },
|
||||
];
|
||||
expect(nameConflictSlugs(skipped)).toEqual(["writer"]);
|
||||
expect(partialOffersRename(skipped)).toBe(true);
|
||||
});
|
||||
|
||||
it("an already-installed-only skip is informational: no overlay, no rename", () => {
|
||||
const skipped = [
|
||||
{ slug: "editor", name: "Editor", reason: "already-installed" as const },
|
||||
];
|
||||
expect(nameConflictSlugs(skipped)).toEqual([]);
|
||||
expect(partialOffersRename(skipped)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("installedLangForRole", () => {
|
||||
it("returns the other language when the same slug is installed elsewhere", () => {
|
||||
const roles = [installedRole({ slug: "writer", language: "ru", version: 2 })];
|
||||
expect(installedLangForRole("writer", roles, "en")).toBe("ru");
|
||||
});
|
||||
|
||||
it("returns undefined when the same slug is installed in the SAME language", () => {
|
||||
const roles = [installedRole({ slug: "writer", language: "en", version: 2 })];
|
||||
expect(installedLangForRole("writer", roles, "en")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when no install of the slug exists", () => {
|
||||
expect(installedLangForRole("writer", [], "en")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores manually-created roles (no source)", () => {
|
||||
const roles = [
|
||||
installedRole({ slug: "writer", language: "ru", version: 2 }, {
|
||||
source: null,
|
||||
}),
|
||||
];
|
||||
expect(installedLangForRole("writer", roles, "en")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapCatalogRoleToView", () => {
|
||||
it("no install -> import status, catalog version, emoji preserved", () => {
|
||||
const view = mapCatalogRoleToView(catalogRole(), [], "en");
|
||||
expect(view).toMatchObject({
|
||||
slug: "writer",
|
||||
emoji: "✍️",
|
||||
name: "Writer",
|
||||
description: "Drafts copy.",
|
||||
status: "import",
|
||||
version: 3,
|
||||
});
|
||||
expect(view.installedRoleId).toBeUndefined();
|
||||
expect(view.installedLang).toBeUndefined();
|
||||
});
|
||||
|
||||
it("import with the slug installed in another language -> installedLang set", () => {
|
||||
const roles = [installedRole({ slug: "writer", language: "ru", version: 9 })];
|
||||
const view = mapCatalogRoleToView(catalogRole(), roles, "en");
|
||||
expect(view.status).toBe("import");
|
||||
expect(view.installedLang).toBe("ru");
|
||||
});
|
||||
|
||||
it("installed (up to date) -> installed status, catalog version, installedRoleId", () => {
|
||||
const installed = installedRole({
|
||||
slug: "writer",
|
||||
language: "en",
|
||||
version: 3,
|
||||
});
|
||||
const view = mapCatalogRoleToView(catalogRole(), [installed], "en");
|
||||
expect(view).toMatchObject({
|
||||
status: "installed",
|
||||
version: 3,
|
||||
installedRoleId: installed.id,
|
||||
});
|
||||
});
|
||||
|
||||
it("update -> version=from, newVersion=to, installedRoleId", () => {
|
||||
const installed = installedRole({
|
||||
slug: "writer",
|
||||
language: "en",
|
||||
version: 1,
|
||||
});
|
||||
const view = mapCatalogRoleToView(catalogRole(), [installed], "en");
|
||||
expect(view).toMatchObject({
|
||||
status: "update",
|
||||
version: 1,
|
||||
newVersion: 3,
|
||||
installedRoleId: installed.id,
|
||||
});
|
||||
});
|
||||
|
||||
it("missing emoji -> emoji undefined; null description -> empty string", () => {
|
||||
const view = mapCatalogRoleToView(
|
||||
catalogRole({ emoji: null, description: null }),
|
||||
[],
|
||||
"en",
|
||||
);
|
||||
expect(view.emoji).toBeUndefined();
|
||||
expect(view.description).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapBundleRolesToView", () => {
|
||||
it("maps a bundle's roles preserving order", () => {
|
||||
const roles = [
|
||||
catalogRole({ slug: "a", name: "A", version: 1 }),
|
||||
catalogRole({ slug: "b", name: "B", version: 1 }),
|
||||
];
|
||||
const installed = [installedRole({ slug: "a", language: "en", version: 1 })];
|
||||
const view = mapBundleRolesToView(roles, installed, "en");
|
||||
expect(view.map((r) => r.slug)).toEqual(["a", "b"]);
|
||||
expect(view[0].status).toBe("installed");
|
||||
expect(view[1].status).toBe("import");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import type {
|
||||
IAiRole,
|
||||
IAiRoleCatalogRole,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { catalogRoleInstallState } from "@/features/ai-chat/utils/catalog-role-install-state.ts";
|
||||
|
||||
/**
|
||||
* The redesigned catalog modal renders bundles as cards with a summary status
|
||||
* (readable without expanding) and a single primary action. The per-role and
|
||||
* per-bundle view model that drives that UI is derived here as PURE functions so
|
||||
* the mapping, the "installed in another language" hint, and the bundle-phase
|
||||
* computation are unit-testable without mounting the component (mirrors the
|
||||
* `catalogRoleInstallState` precedent).
|
||||
*/
|
||||
|
||||
/**
|
||||
* A role's status in the catalog view model.
|
||||
* - `import` — not installed in the current content language.
|
||||
* - `installed` — installed and up to date.
|
||||
* - `update` — installed, but the catalog ships a newer version.
|
||||
* - `skipped` — TRANSIENT client-only status set after a conflicted import
|
||||
* (a name collision under `conflict:'skip'`); never from the
|
||||
* backend.
|
||||
*/
|
||||
export type RoleStatus = "import" | "installed" | "update" | "skipped";
|
||||
|
||||
/** A catalog role mapped into the modal's view model. */
|
||||
export interface CatalogViewRole {
|
||||
// Slug is the stable identity within a bundle; used as the row key and as the
|
||||
// `slugs[]` payload for import.
|
||||
slug: string;
|
||||
// Optional in the catalog — the row reserves space and renders nothing when
|
||||
// absent.
|
||||
emoji?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
// For `installed`/`import`: the catalog version. For `update`: the installed
|
||||
// (from) version, with `newVersion` holding the catalog (to) version.
|
||||
version: number;
|
||||
newVersion?: number;
|
||||
status: RoleStatus;
|
||||
// The language a same-slug role is installed under, when it differs from the
|
||||
// current content language (drives the Р5 hint). Only set for `import` roles.
|
||||
installedLang?: string;
|
||||
// The workspace role id, present for `installed`/`update` — needed to call the
|
||||
// update-from-catalog mutation.
|
||||
installedRoleId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The summary phase of a bundle, derived from its roles' statuses. Determines
|
||||
* the collapsed-header summary and the bundle's single primary action.
|
||||
* - `empty` — the bundle has no roles.
|
||||
* - `allNew` — everything is importable, nothing installed.
|
||||
* - `allInstalled` — everything installed & up to date; nothing else pending.
|
||||
* - `updates` — updates available and nothing left to import.
|
||||
* - `mixed` — any other combination.
|
||||
*/
|
||||
export type BundlePhase =
|
||||
| "empty"
|
||||
| "allNew"
|
||||
| "allInstalled"
|
||||
| "updates"
|
||||
| "mixed";
|
||||
|
||||
/** Per-status tallies for a bundle's roles (the single source of truth). */
|
||||
export interface BundleCounts {
|
||||
importable: number;
|
||||
installed: number;
|
||||
update: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count a bundle's roles by status ONCE. Both `bundlePhase` and the panel derive
|
||||
* from this, so the tally logic lives in exactly one place (no rescans / drift).
|
||||
*/
|
||||
export function bundleCounts(roles: CatalogViewRole[]): BundleCounts {
|
||||
const counts: BundleCounts = {
|
||||
importable: 0,
|
||||
installed: 0,
|
||||
update: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
for (const r of roles) {
|
||||
if (r.status === "import") counts.importable += 1;
|
||||
else if (r.status === "installed") counts.installed += 1;
|
||||
else if (r.status === "update") counts.update += 1;
|
||||
else if (r.status === "skipped") counts.skipped += 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function bundlePhase(roles: CatalogViewRole[]): BundlePhase {
|
||||
if (roles.length === 0) return "empty";
|
||||
const { importable, installed, update, skipped } = bundleCounts(roles);
|
||||
// A `skipped` role is a pending post-import conflict (0 installed for it), so a
|
||||
// bundle that has ANY skipped role is NOT "all installed & up to date" — that
|
||||
// would make the collapsed green "up to date" header contradict the open
|
||||
// panel's "Installed 0 · 1 skipped" plaque. It is `mixed` until resolved.
|
||||
if (importable === 0 && update === 0 && skipped === 0) return "allInstalled";
|
||||
if (update > 0 && importable === 0 && skipped === 0) return "updates";
|
||||
if (importable > 0 && installed === 0 && update === 0 && skipped === 0)
|
||||
return "allNew";
|
||||
return "mixed";
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of a skip result that should be shown as a TRANSIENT `skipped`
|
||||
* overlay in the bundle (so the row offers a re-import path). Only NAME-CONFLICT
|
||||
* skips qualify: an `already-installed` skip (a concurrent-import race) has
|
||||
* nothing to act on — re-importing the same slug would just skip again — so it
|
||||
* must NOT be overlaid (else the row shows a misleading "Rename & install" that
|
||||
* self-heals into a false "installed"). Pure so both reason branches are tested.
|
||||
*/
|
||||
export function nameConflictSlugs(
|
||||
skipped: { slug: string; reason: "name-conflict" | "already-installed" }[],
|
||||
): string[] {
|
||||
return skipped
|
||||
.filter((s) => s.reason === "name-conflict")
|
||||
.map((s) => s.slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a partial-import result should offer the "Rename & install" action:
|
||||
* only when at least one skip is a name conflict (renameable). An
|
||||
* `already-installed`-only partial is informational.
|
||||
*/
|
||||
export function partialOffersRename(
|
||||
skipped: { reason: "name-conflict" | "already-installed" }[],
|
||||
): boolean {
|
||||
return skipped.some((s) => s.reason === "name-conflict");
|
||||
}
|
||||
|
||||
/**
|
||||
* For a role NOT installed in the current `language`, find a workspace role with
|
||||
* the same catalog `slug` installed under a DIFFERENT language, and return that
|
||||
* language. Drives the "installed in another language" hint (Р5): a different
|
||||
* language of the same slug is a separate install and appears as `import`.
|
||||
*/
|
||||
export function installedLangForRole(
|
||||
slug: string,
|
||||
workspaceRoles: IAiRole[],
|
||||
language: string,
|
||||
): string | undefined {
|
||||
const other = workspaceRoles.find(
|
||||
(r) =>
|
||||
r.source?.slug === slug &&
|
||||
!!r.source?.language &&
|
||||
r.source.language !== language,
|
||||
);
|
||||
return other?.source?.language;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one catalog role to the view model, computing its install status against
|
||||
* the workspace roles (via `catalogRoleInstallState`) and, for importable roles,
|
||||
* the other-language hint.
|
||||
*/
|
||||
export function mapCatalogRoleToView(
|
||||
role: IAiRoleCatalogRole,
|
||||
workspaceRoles: IAiRole[],
|
||||
language: string,
|
||||
): CatalogViewRole {
|
||||
const state = catalogRoleInstallState(role, workspaceRoles, language);
|
||||
const base = {
|
||||
slug: role.slug,
|
||||
emoji: role.emoji ?? undefined,
|
||||
name: role.name,
|
||||
description: role.description ?? "",
|
||||
};
|
||||
if (state.state === "update") {
|
||||
return {
|
||||
...base,
|
||||
status: "update",
|
||||
version: state.fromVersion,
|
||||
newVersion: state.toVersion,
|
||||
installedRoleId: state.installed.id,
|
||||
};
|
||||
}
|
||||
if (state.state === "installed") {
|
||||
return {
|
||||
...base,
|
||||
status: "installed",
|
||||
version: role.version,
|
||||
installedRoleId: state.installed.id,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
status: "import",
|
||||
version: role.version,
|
||||
installedLang: installedLangForRole(role.slug, workspaceRoles, language),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a whole bundle's catalog roles to the view model, preserving order.
|
||||
*/
|
||||
export function mapBundleRolesToView(
|
||||
roles: IAiRoleCatalogRole[],
|
||||
workspaceRoles: IAiRole[],
|
||||
language: string,
|
||||
): CatalogViewRole[] {
|
||||
return roles.map((r) => mapCatalogRoleToView(r, workspaceRoles, language));
|
||||
}
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
TiptapPdf,
|
||||
PageBreak,
|
||||
SearchAndReplace,
|
||||
MultiCursor,
|
||||
Mention,
|
||||
TableDndExtension,
|
||||
TableHandleCommandsExtension,
|
||||
@@ -448,10 +447,6 @@ export const mainExtensions = [
|
||||
};
|
||||
},
|
||||
}).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,
|
||||
Column,
|
||||
AutoJoiner.configure({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
@import "./core.css";
|
||||
@import "./collaboration.css";
|
||||
@import "./multi-cursor.css";
|
||||
@import "./task-list.css";
|
||||
@import "./placeholder.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;
|
||||
}
|
||||
}
|
||||
+976
-275
File diff suppressed because it is too large
Load Diff
@@ -610,6 +610,63 @@ describe('AiAgentRolesService guards', () => {
|
||||
expect(repo.insert.mock.calls[0][0].name).toBe('Researcher (2)');
|
||||
});
|
||||
|
||||
it('createdRoles lists the installed role (no renamedTo when not renamed)', async () => {
|
||||
const { service } = makeImportService({});
|
||||
const res = await service.importFromCatalog('ws-1', 'u1', dto());
|
||||
expect(res.createdRoles).toEqual([
|
||||
{ slug: 'researcher', name: 'Researcher' },
|
||||
]);
|
||||
expect(res.skippedRoles).toEqual([]);
|
||||
});
|
||||
|
||||
it('createdRoles carries renamedTo on a rename', async () => {
|
||||
const existing = [makeRow({ id: 'r-x', name: 'Researcher' })];
|
||||
const { service } = makeImportService({ existing });
|
||||
const res = await service.importFromCatalog(
|
||||
'ws-1',
|
||||
'u1',
|
||||
dto({ conflict: 'rename' }),
|
||||
);
|
||||
expect(res.createdRoles).toEqual([
|
||||
{ slug: 'researcher', name: 'Researcher', renamedTo: 'Researcher (2)' },
|
||||
]);
|
||||
expect(res.skippedRoles).toEqual([]);
|
||||
});
|
||||
|
||||
it('skippedRoles: already-installed slug carries reason "already-installed"', async () => {
|
||||
const existing = [
|
||||
makeRow({
|
||||
id: 'r-existing',
|
||||
name: 'Old researcher',
|
||||
source: { slug: 'researcher', language: 'en', version: 1 } as never,
|
||||
}),
|
||||
];
|
||||
const { service } = makeImportService({ existing });
|
||||
const res = await service.importFromCatalog('ws-1', 'u1', dto());
|
||||
expect(res.skippedRoles).toEqual([
|
||||
{
|
||||
slug: 'researcher',
|
||||
name: 'Researcher',
|
||||
reason: 'already-installed',
|
||||
},
|
||||
]);
|
||||
expect(res.createdRoles).toEqual([]);
|
||||
});
|
||||
|
||||
it('skippedRoles: a name collision under conflict:skip carries reason "name-conflict"', async () => {
|
||||
const existing = [makeRow({ id: 'r-x', name: 'Researcher' })];
|
||||
const { service } = makeImportService({ existing });
|
||||
const res = await service.importFromCatalog(
|
||||
'ws-1',
|
||||
'u1',
|
||||
dto({ conflict: 'skip' }),
|
||||
);
|
||||
expect(res.skippedRoles).toEqual([
|
||||
{ slug: 'researcher', name: 'Researcher', reason: 'name-conflict' },
|
||||
]);
|
||||
expect(res.createdRoles).toEqual([]);
|
||||
});
|
||||
|
||||
it('dto.slugs filters; an unknown slug becomes an error entry', async () => {
|
||||
const { service, repo } = makeImportService({
|
||||
bundleRoles: [catalogRole()],
|
||||
@@ -677,6 +734,15 @@ describe('AiAgentRolesService guards', () => {
|
||||
// 'a' converged on the concurrent install (skip); 'b' imported; no errors.
|
||||
expect(res).toMatchObject({ created: 1, skipped: 1, renamed: 0 });
|
||||
expect(res.errors).toEqual([]);
|
||||
// The per-role list records 'a' as an already-installed skip (the UI reads
|
||||
// skippedRoles, not the counter, to render its plaque — assert the array,
|
||||
// not just the count).
|
||||
expect(res.skippedRoles).toContainEqual({
|
||||
slug: 'a',
|
||||
name: 'A',
|
||||
reason: 'already-installed',
|
||||
});
|
||||
expect(res.createdRoles.map((r) => r.slug)).toEqual(['b']);
|
||||
// Both inserts were attempted (the batch did not abort on the 23505).
|
||||
expect(repo.insert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -305,6 +305,16 @@ export class AiAgentRolesService {
|
||||
skipped: number;
|
||||
renamed: number;
|
||||
errors: { slug: string; message: string }[];
|
||||
// Per-role lists alongside the counters (kept for back-compat). The redesigned
|
||||
// catalog UI needs the actual roles — which were created (and any rename) and
|
||||
// which were skipped and why — to render an inline result plaque with the
|
||||
// conflicting role's name and a "Rename & install" affordance.
|
||||
createdRoles: { slug: string; name: string; renamedTo?: string }[];
|
||||
skippedRoles: {
|
||||
slug: string;
|
||||
name: string;
|
||||
reason: 'name-conflict' | 'already-installed';
|
||||
}[];
|
||||
}> {
|
||||
const { file, versions } = await this.loadBundleById(
|
||||
dto.bundleId,
|
||||
@@ -312,6 +322,13 @@ export class AiAgentRolesService {
|
||||
);
|
||||
|
||||
const errors: { slug: string; message: string }[] = [];
|
||||
const createdRoles: { slug: string; name: string; renamedTo?: string }[] =
|
||||
[];
|
||||
const skippedRoles: {
|
||||
slug: string;
|
||||
name: string;
|
||||
reason: 'name-conflict' | 'already-installed';
|
||||
}[] = [];
|
||||
|
||||
// Resolve the selected catalog roles (honor dto.slugs; flag unknown ones).
|
||||
let selected = file.roles;
|
||||
@@ -351,16 +368,27 @@ export class AiAgentRolesService {
|
||||
// Already installed from the catalog in THIS language => skip (use
|
||||
// update-from-catalog). A different language of the same slug still imports.
|
||||
const installKey = `${role.slug}:${dto.language}`;
|
||||
const originalName = role.name.trim();
|
||||
if (installedKeys.has(installKey)) {
|
||||
skipped++;
|
||||
skippedRoles.push({
|
||||
slug: role.slug,
|
||||
name: originalName,
|
||||
reason: 'already-installed',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = role.name.trim();
|
||||
let name = originalName;
|
||||
let didRename = false;
|
||||
if (takenNames.has(name.toLowerCase())) {
|
||||
if (dto.conflict === 'skip') {
|
||||
skipped++;
|
||||
skippedRoles.push({
|
||||
slug: role.slug,
|
||||
name: originalName,
|
||||
reason: 'name-conflict',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// conflict === 'rename': find a free " (N)" suffix.
|
||||
@@ -380,6 +408,11 @@ export class AiAgentRolesService {
|
||||
});
|
||||
created++;
|
||||
if (didRename) renamed++;
|
||||
createdRoles.push({
|
||||
slug: role.slug,
|
||||
name: originalName,
|
||||
...(didRename ? { renamedTo: name } : {}),
|
||||
});
|
||||
takenNames.add(name.toLowerCase());
|
||||
installedKeys.add(installKey);
|
||||
} catch (err) {
|
||||
@@ -391,6 +424,11 @@ export class AiAgentRolesService {
|
||||
// skipped (already installed) and continue; do NOT abort or error.
|
||||
if (isSourceUniqueViolation(err)) {
|
||||
skipped++;
|
||||
skippedRoles.push({
|
||||
slug: role.slug,
|
||||
name: originalName,
|
||||
reason: 'already-installed',
|
||||
});
|
||||
installedKeys.add(installKey);
|
||||
continue;
|
||||
}
|
||||
@@ -407,7 +445,7 @@ export class AiAgentRolesService {
|
||||
}
|
||||
}
|
||||
|
||||
return { created, skipped, renamed, errors };
|
||||
return { created, skipped, renamed, errors, createdRoles, skippedRoles };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,7 +20,6 @@ export * from "./lib/html-embed/html-embed";
|
||||
export * from "./lib/mention";
|
||||
export * from "./lib/markdown";
|
||||
export * from "./lib/search-and-replace";
|
||||
export * from "./lib/multi-cursor";
|
||||
export * from "./lib/embed-provider";
|
||||
export * from "./lib/subpages";
|
||||
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'
|
||||
export * from './search-and-replace'
|
||||
export * from './find-occurrences'
|
||||
export default SearchAndReplace
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
type Transaction,
|
||||
} from "@tiptap/pm/state";
|
||||
import { Node as PMNode, Mark } from "@tiptap/pm/model";
|
||||
import { findOccurrences } from "./find-occurrences";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Storage {
|
||||
@@ -77,6 +76,11 @@ declare module "@tiptap/core" {
|
||||
}
|
||||
}
|
||||
|
||||
interface TextNodesWithPosition {
|
||||
text: string;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
const getRegex = (
|
||||
s: string,
|
||||
disableRegex: boolean,
|
||||
@@ -100,6 +104,10 @@ function processSearches(
|
||||
resultIndex: number,
|
||||
): ProcessedSearches {
|
||||
const decorations: Decoration[] = [];
|
||||
const results: Range[] = [];
|
||||
|
||||
let textNodesWithPosition: TextNodesWithPosition[] = [];
|
||||
let index = 0;
|
||||
|
||||
if (!searchTerm) {
|
||||
return {
|
||||
@@ -108,8 +116,43 @@ function processSearches(
|
||||
};
|
||||
}
|
||||
|
||||
// Shared find-all-occurrences primitive (also used by multi-cursor).
|
||||
const results: Range[] = findOccurrences(doc, searchTerm);
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < results.length; i += 1) {
|
||||
const r = results[i];
|
||||
|
||||
Reference in New Issue
Block a user