From 8e768fc355b25bf93fdf2556dddf02e604b06664 Mon Sep 17 00:00:00 2001 From: aorlov Date: Wed, 27 May 2026 23:10:25 +0200 Subject: [PATCH 01/11] feat(sdk): add LLM tools preset registry (SD-3128) - Added validation for the MCP_PRESET environment variable in `server.ts` to ensure only supported presets are accepted at startup, preventing silent misconfigurations. - Introduced a new preset registry in `presets.ts`, allowing for the management of LLM tool presets, with the initial implementation supporting only the 'legacy' preset. - Updated the Node SDK to expose preset-related functions (`getPreset`, `listPresets`, `DEFAULT_PRESET`) for easier access to preset information. - Created tests for preset validation and registry functionality, ensuring that unknown presets trigger appropriate errors and that the legacy preset behaves as expected. - Added corresponding Python SDK support for the preset registry, mirroring the Node implementation for consistency across languages. --- .../src/__tests__/server-preset-env.test.ts | 59 +++ apps/mcp/src/server.ts | 12 + .../src/__tests__/cross-lang-parity.test.ts | 39 +- .../langs/node/src/__tests__/presets.test.ts | 146 ++++++ packages/sdk/langs/node/src/index.ts | 4 + packages/sdk/langs/node/src/presets.ts | 158 +++++++ packages/sdk/langs/node/src/presets/legacy.ts | 353 ++++++++++++++ packages/sdk/langs/node/src/tools.ts | 443 ++++-------------- packages/sdk/langs/python/pyproject.toml | 1 + .../sdk/langs/python/superdoc/__init__.py | 4 + .../langs/python/superdoc/presets/__init__.py | 102 ++++ .../langs/python/superdoc/presets/legacy.py | 278 +++++++++++ .../python/superdoc/test_parity_helper.py | 7 + .../sdk/langs/python/superdoc/tools_api.py | 247 +++------- .../sdk/langs/python/tests/test_presets.py | 137 ++++++ 15 files changed, 1460 insertions(+), 530 deletions(-) create mode 100644 apps/mcp/src/__tests__/server-preset-env.test.ts create mode 100644 packages/sdk/langs/node/src/__tests__/presets.test.ts create mode 100644 packages/sdk/langs/node/src/presets.ts create mode 100644 packages/sdk/langs/node/src/presets/legacy.ts create mode 100644 packages/sdk/langs/python/superdoc/presets/__init__.py create mode 100644 packages/sdk/langs/python/superdoc/presets/legacy.py create mode 100644 packages/sdk/langs/python/tests/test_presets.py diff --git a/apps/mcp/src/__tests__/server-preset-env.test.ts b/apps/mcp/src/__tests__/server-preset-env.test.ts new file mode 100644 index 0000000000..456bae7f7c --- /dev/null +++ b/apps/mcp/src/__tests__/server-preset-env.test.ts @@ -0,0 +1,59 @@ +/** + * MCP_PRESET env var selects which LLM-tools preset the server registers. + * Currently only 'legacy' is supported. Unknown preset ids must fail fast at + * startup so misconfiguration is visible instead of silently falling back to + * the default. + */ + +import { describe, expect, test } from 'bun:test'; +import { spawn } from 'node:child_process'; +import path from 'node:path'; + +const REPO_ROOT = path.resolve(import.meta.dir, '../../../..'); +const MCP_ENTRY = path.join(REPO_ROOT, 'apps/mcp/src/server.ts'); + +type RunResult = { code: number | null; stderr: string }; + +function runServer(env: NodeJS.ProcessEnv, timeoutMs = 2000): Promise { + return new Promise((resolve) => { + const proc = spawn('bun', ['run', MCP_ENTRY], { + cwd: REPO_ROOT, + env: { ...process.env, ...env }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stderr = ''; + proc.stderr.on('data', (chunk) => { + stderr += chunk; + }); + + // The MCP server runs forever waiting on stdio. We only care about whether + // it exits fast (rejecting bad preset id) or stays alive (accepting preset). + // For the success case we kill after a short window. + const timer = setTimeout(() => { + proc.kill('SIGTERM'); + }, timeoutMs); + + proc.on('close', (code) => { + clearTimeout(timer); + resolve({ code, stderr }); + }); + }); +} + +describe('MCP_PRESET env var', () => { + test('unknown preset id fails fast with exit code 2', async () => { + const result = await runServer({ MCP_PRESET: 'definitely-not-a-preset' }); + expect(result.code).toBe(2); + expect(result.stderr).toContain('unknown preset'); + expect(result.stderr).toContain('definitely-not-a-preset'); + expect(result.stderr).toContain('legacy'); + }); + + test('explicit MCP_PRESET=legacy is accepted (server stays alive)', async () => { + const result = await runServer({ MCP_PRESET: 'legacy' }); + // Server should still be running when we kill it (SIGTERM → code is null + // or signal-derived non-2). Either way, it must NOT exit with 2. + expect(result.code).not.toBe(2); + }); +}); diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts index d771ac6111..b2e6376971 100644 --- a/apps/mcp/src/server.ts +++ b/apps/mcp/src/server.ts @@ -9,6 +9,18 @@ import { registerAllTools } from './tools/index.js'; const require = createRequire(import.meta.url); const { version } = require('../package.json'); +// Validate MCP_PRESET at startup so misconfiguration fails fast instead of +// silently falling back to 'legacy'. Tool registration is wired to legacy via +// the static MCP_TOOL_CATALOG + dispatchIntentTool imports in tools/intent.ts; +// the resolved id is not plumbed further yet. When a non-legacy preset lands, +// pass the id into registerAllTools() so it can route through the registry. +const PRESETS_SUPPORTED = new Set(['legacy']); +const requestedPreset = process.env.MCP_PRESET ?? 'legacy'; +if (!PRESETS_SUPPORTED.has(requestedPreset)) { + console.error(`SuperDoc MCP: unknown preset "${requestedPreset}". Supported: ${[...PRESETS_SUPPORTED].join(', ')}.`); + process.exit(2); +} + const server = new McpServer( { name: 'superdoc', diff --git a/packages/sdk/codegen/src/__tests__/cross-lang-parity.test.ts b/packages/sdk/codegen/src/__tests__/cross-lang-parity.test.ts index 203d7ef5dc..dcd13528ae 100644 --- a/packages/sdk/codegen/src/__tests__/cross-lang-parity.test.ts +++ b/packages/sdk/codegen/src/__tests__/cross-lang-parity.test.ts @@ -54,7 +54,7 @@ function callPython(command: Record): Promise { /** Import Node SDK chooseTools (cached). */ let _nodeTools: typeof import('../../../langs/node/src/tools.js') | null = null; -async function nodeTools() { +async function nodeTools(): Promise { if (!_nodeTools) { _nodeTools = await import(path.join(REPO_ROOT, 'packages/sdk/langs/node/src/tools.ts')); } @@ -78,6 +78,43 @@ describe('chooseTools parity', () => { expect(pyResult.meta.toolCount).toBe(nodeResult.meta.toolCount); expect(nodeResult.meta.toolCount).toBeGreaterThan(0); }); + + test('returns same tool count when preset: legacy is explicit (parity)', async () => { + const input = { provider: 'generic' as const, preset: 'legacy' }; + + const { chooseTools } = await nodeTools(); + const nodeResult = await chooseTools(input); + + const pyResult = (await callPython({ action: 'chooseTools', input })) as ChooseResult & { + meta: { preset?: string }; + }; + + expect(pyResult.meta.provider).toBe(nodeResult.meta.provider); + expect(pyResult.meta.toolCount).toBe(nodeResult.meta.toolCount); + expect(pyResult.meta.preset).toBe('legacy'); + expect(nodeResult.meta.preset).toBe('legacy'); + }); +}); + +// -------------------------------------------------------------------------- +// Preset registry parity +// -------------------------------------------------------------------------- + +describe('Preset registry parity', () => { + test('Node and Python expose the same DEFAULT_PRESET and registered ids', async () => { + const { DEFAULT_PRESET: nodeDefault, listPresets: nodeList } = await nodeTools(); + const nodePresets = nodeList(); + + const pyResult = (await callPython({ action: 'listPresets' })) as { + defaultPreset: string; + presets: string[]; + }; + + expect(pyResult.defaultPreset).toBe(nodeDefault); + expect(nodeDefault).toBe('legacy'); + // Both runtimes register the same preset set (order-agnostic). + expect([...pyResult.presets].sort()).toEqual([...nodePresets].sort()); + }); }); // -------------------------------------------------------------------------- diff --git a/packages/sdk/langs/node/src/__tests__/presets.test.ts b/packages/sdk/langs/node/src/__tests__/presets.test.ts new file mode 100644 index 0000000000..b789e14738 --- /dev/null +++ b/packages/sdk/langs/node/src/__tests__/presets.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from 'bun:test'; +import { + chooseTools, + DEFAULT_PRESET, + getPreset, + getMcpPrompt, + getSystemPrompt, + getSystemPromptForProvider, + getToolCatalog, + listPresets, + listTools, +} from '../tools.ts'; +import { SuperDocCliError } from '../runtime/errors.js'; + +const PROVIDERS = ['openai', 'anthropic', 'vercel', 'generic'] as const; + +describe('preset registry', () => { + test('DEFAULT_PRESET is "legacy"', () => { + expect(DEFAULT_PRESET).toBe('legacy'); + }); + + test('listPresets() includes "legacy"', () => { + const presets = listPresets(); + expect(presets).toContain('legacy'); + }); + + test('getPreset() (no arg) returns the legacy preset', () => { + const preset = getPreset(); + expect(preset.id).toBe('legacy'); + }); + + test('getPreset("legacy") returns the legacy preset', () => { + const preset = getPreset('legacy'); + expect(preset.id).toBe('legacy'); + expect(preset.description).toBeDefined(); + expect(preset.supportsCacheControl).toBe(true); + }); + + test('getPreset("nonexistent") throws PRESET_NOT_FOUND', () => { + try { + getPreset('nonexistent-preset'); + throw new Error('Expected getPreset to throw.'); + } catch (error) { + expect(error).toBeInstanceOf(SuperDocCliError); + const cliError = error as SuperDocCliError; + expect(cliError.code).toBe('PRESET_NOT_FOUND'); + expect(cliError.message).toContain('nonexistent-preset'); + const details = cliError.details as { id: string; availablePresets: string[] }; + expect(details.id).toBe('nonexistent-preset'); + expect(details.availablePresets).toContain('legacy'); + } + }); +}); + +describe('chooseTools — default preset equivalence', () => { + for (const provider of PROVIDERS) { + test(`omitting preset equals preset: 'legacy' (${provider})`, async () => { + const implicit = await chooseTools({ provider }); + const explicit = await chooseTools({ provider, preset: 'legacy' }); + // Tools content identical + expect(implicit.tools).toEqual(explicit.tools); + // Same tool count + expect(implicit.meta.toolCount).toBe(explicit.meta.toolCount); + // Same provider, same cache strategy + expect(implicit.meta.provider).toBe(explicit.meta.provider); + expect(implicit.meta.cacheStrategy).toBe(explicit.meta.cacheStrategy); + // Both echo legacy as resolved preset + expect(implicit.meta.preset).toBe('legacy'); + expect(explicit.meta.preset).toBe('legacy'); + }); + } + + test(`chooseTools(provider, preset: 'nonexistent') throws PRESET_NOT_FOUND`, async () => { + await expect(chooseTools({ provider: 'openai', preset: 'nonexistent-preset' })).rejects.toMatchObject({ + code: 'PRESET_NOT_FOUND', + }); + }); + + test('meta.preset field is included', async () => { + const { meta } = await chooseTools({ provider: 'openai' }); + expect(meta.preset).toBe('legacy'); + }); +}); + +describe('catalog + listings — default preset equivalence', () => { + test(`getToolCatalog() equals getToolCatalog('legacy')`, async () => { + const implicit = await getToolCatalog(); + const explicit = await getToolCatalog('legacy'); + expect(implicit).toEqual(explicit); + }); + + for (const provider of PROVIDERS) { + test(`listTools(${provider}) equals listTools(${provider}, 'legacy')`, async () => { + const implicit = await listTools(provider); + const explicit = await listTools(provider, 'legacy'); + expect(implicit).toEqual(explicit); + }); + } + + test(`getToolCatalog('nonexistent') throws PRESET_NOT_FOUND`, async () => { + await expect(getToolCatalog('nonexistent-preset')).rejects.toMatchObject({ + code: 'PRESET_NOT_FOUND', + }); + }); +}); + +describe('system prompts — default preset equivalence', () => { + test(`getSystemPrompt() equals getSystemPrompt('legacy')`, async () => { + const implicit = await getSystemPrompt(); + const explicit = await getSystemPrompt('legacy'); + expect(implicit).toBe(explicit); + }); + + test(`getMcpPrompt() equals getMcpPrompt('legacy')`, async () => { + const implicit = await getMcpPrompt(); + const explicit = await getMcpPrompt('legacy'); + expect(implicit).toBe(explicit); + }); + + test(`getSystemPromptForProvider({provider}) equals preset: 'legacy'`, async () => { + const implicit = await getSystemPromptForProvider({ provider: 'anthropic', cache: true }); + const explicit = await getSystemPromptForProvider({ + provider: 'anthropic', + preset: 'legacy', + cache: true, + }); + expect(implicit).toEqual(explicit); + }); +}); + +describe('legacy preset direct access', () => { + test('getPreset("legacy").getCatalog() matches getToolCatalog()', async () => { + const direct = await getPreset('legacy').getCatalog(); + const viaTopLevel = await getToolCatalog(); + expect(direct).toEqual(viaTopLevel); + }); + + for (const provider of PROVIDERS) { + test(`getPreset("legacy").getTools(${provider}) matches chooseTools({provider}).tools`, async () => { + const direct = await getPreset('legacy').getTools(provider); + const viaTopLevel = await chooseTools({ provider }); + expect(direct.tools).toEqual(viaTopLevel.tools); + expect(direct.cacheStrategy).toBe(viaTopLevel.meta.cacheStrategy); + }); + } +}); diff --git a/packages/sdk/langs/node/src/index.ts b/packages/sdk/langs/node/src/index.ts index b3e1e8b25a..68b33aee19 100644 --- a/packages/sdk/langs/node/src/index.ts +++ b/packages/sdk/langs/node/src/index.ts @@ -253,11 +253,15 @@ export { getSystemPromptForProvider, getToolCatalog, listTools, + DEFAULT_PRESET, + getPreset, + listPresets, } from './tools.js'; export type { AnthropicSystemPrompt, CacheStrategy, SystemPromptForProviderResult, + ToolCatalog, ToolChooserInput, ToolProvider, } from './tools.js'; diff --git a/packages/sdk/langs/node/src/presets.ts b/packages/sdk/langs/node/src/presets.ts new file mode 100644 index 0000000000..b3633dbfb3 --- /dev/null +++ b/packages/sdk/langs/node/src/presets.ts @@ -0,0 +1,158 @@ +/** + * Preset registry for SuperDoc LLM tools. + * + * A preset is a self-contained collection of LLM tools — provider catalogs + * (openai / anthropic / vercel / generic), a system prompt, and a dispatcher. + * Multiple presets can coexist in the SDK; consumers select one at runtime via + * `chooseTools({ preset })`. + * + * const { tools, meta } = await chooseTools({ provider: 'vercel', preset: 'legacy' }); + * + * v1 ships a single preset: `'legacy'` — a thin wrapper around today's + * codegen-emitted intent tools. When callers omit `preset`, `legacy` is used. + * The default may move once a replacement preset reaches parity; bumping it is + * a coordinated change in this file alone. + * + * Presets are NOT versioned. The preset id encodes the variant; a new shape + * ships as a new id, not a new version of an existing one. + * + * @internal + */ + +import type { BoundDocApi } from './generated/client.js'; +import type { InvokeOptions } from './runtime/process.js'; +import { SuperDocCliError } from './runtime/errors.js'; +import { legacyPreset } from './presets/legacy.js'; + +/** + * Wire format the tools are emitted in. + * + * - `openai` — OpenAI Chat Completions / Responses + * - `anthropic` — Anthropic Messages API + * - `vercel` — Vercel AI SDK (provider-agnostic adapter) + * - `generic` — vendor-neutral JSON Schema shape + */ +export type ToolProvider = 'openai' | 'anthropic' | 'vercel' | 'generic'; + +/** + * Prompt-cache strategy returned by `chooseTools.meta.cacheStrategy`. + * + * - `explicit` — preset emitted provider-specific cache markers (Anthropic `cache_control`) + * - `automatic` — provider caches automatically (OpenAI ≥ 1024 prompt tokens) + * - `unsupported` — pass-through; caching depends on the underlying model (vercel/generic) + * - `disabled` — caller passed `cache: false` or omitted the flag + */ +export type CacheStrategy = 'explicit' | 'automatic' | 'unsupported' | 'disabled'; + +/** + * Full tool catalog shape. The legacy preset returns the existing codegen + * catalog with `contractVersion`, `generatedAt`, `toolCount`, `tools`. + */ +export type ToolCatalog = { + contractVersion: string; + generatedAt: string | null; + toolCount: number; + tools: unknown[]; +}; + +export interface GetToolsOptions { + /** + * When `true`, the preset applies provider-specific prompt-cache markers + * (Anthropic `cache_control: { type: "ephemeral" }` on the last tool, + * for example). When omitted or `false`, no markers are added. + */ + cache?: boolean; +} + +export interface GetToolsResult { + tools: unknown[]; + cacheStrategy: CacheStrategy; +} + +/** + * Self-contained preset of LLM tools. + * + * Each preset owns: + * - its tool catalogs per provider format + * - its system prompt (and MCP-flavored variant) + * - its dispatcher (how a named tool call routes against a doc handle) + * + * Presets are stateless; the same descriptor handles every call. + * + * @internal + */ +export interface PresetDescriptor { + /** Stable identifier — used as the preset's only "version" reference. */ + readonly id: string; + + /** Human-readable description shown by `listPresets()`. */ + readonly description: string; + + /** + * Whether this preset's provider adapters emit Anthropic prompt-cache + * markers when called with `cache: true`. Informational; per-provider + * behavior is reported via `GetToolsResult.cacheStrategy`. + */ + readonly supportsCacheControl: boolean; + + /** Tool definitions for the requested provider format. */ + getTools(provider: ToolProvider, options?: GetToolsOptions): Promise; + + /** Full tool catalog with metadata (contract version, tool count, etc.). */ + getCatalog(): Promise; + + /** System prompt for embedded LLM usage (OpenAI/Anthropic/Vercel APIs). */ + getSystemPrompt(): Promise; + + /** System prompt for MCP server `instructions`. */ + getMcpPrompt(): Promise; + + /** + * Dispatch a tool call against a bound document handle. + * + * The handle injects session targeting; `args` must NOT carry `doc` or + * `sessionId`. Returns whatever the underlying operation produces. + */ + dispatch( + documentHandle: BoundDocApi, + toolName: string, + args: Record, + invokeOptions?: InvokeOptions, + ): Promise; +} + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +/** + * The default preset returned when callers omit `preset`. Set to `'legacy'` + * so consumers built before presets existed (today's intent-tool path) keep + * working without changes. + */ +export const DEFAULT_PRESET = 'legacy'; + +const PRESETS: Record = { + legacy: legacyPreset, +}; + +/** List the IDs of all registered presets. */ +export function listPresets(): readonly string[] { + return Object.keys(PRESETS); +} + +/** + * Resolve a preset by ID. Throws {@link SuperDocCliError} with code + * `PRESET_NOT_FOUND` if the ID is not registered. Omit the argument to + * get the default preset. + */ +export function getPreset(id: string = DEFAULT_PRESET): PresetDescriptor { + const preset = PRESETS[id]; + if (preset == null) { + throw new SuperDocCliError(`Unknown LLM-tools preset: "${id}"`, { + code: 'PRESET_NOT_FOUND', + details: { id, availablePresets: Object.keys(PRESETS) }, + }); + } + return preset; +} diff --git a/packages/sdk/langs/node/src/presets/legacy.ts b/packages/sdk/langs/node/src/presets/legacy.ts new file mode 100644 index 0000000000..a8336fa755 --- /dev/null +++ b/packages/sdk/langs/node/src/presets/legacy.ts @@ -0,0 +1,353 @@ +/** + * Legacy preset — wraps the existing codegen-emitted intent tools verbatim. + * + * The legacy preset is a read-through over the packaged tool artifacts in + * `packages/sdk/tools/` (catalog, per-provider tool JSON, system prompts) and + * delegates dispatch to the codegen-emitted `dispatchIntentTool`. It is the + * default preset returned by `chooseTools()` when callers omit `preset`. + * + * Nothing in this file relocates or rewrites the packaged artifacts. The whole + * point of the read-through wrapper is that running `generate:all` continues + * to refresh `packages/sdk/tools/*.json` in place; the legacy preset picks up + * the new files on the next call. + * + * @internal + */ + +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { BoundDocApi } from '../generated/client.js'; +import type { InvokeOptions } from '../runtime/process.js'; +import { SuperDocCliError } from '../runtime/errors.js'; +import { dispatchIntentTool } from '../generated/intent-dispatch.generated.js'; +import type { PresetDescriptor, GetToolsOptions, GetToolsResult, ToolCatalog, ToolProvider } from '../presets.js'; + +// Resolve tools directory relative to package root (works from both src/ and dist/) +const toolsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'tools'); + +const providerFileByName: Record = { + openai: 'tools.openai.json', + anthropic: 'tools.anthropic.json', + vercel: 'tools.vercel.json', + generic: 'tools.generic.json', +}; + +type OperationEntry = { + operationId: string; + intentAction: string; + required?: string[]; + requiredOneOf?: string[][]; +}; + +type ToolCatalogEntry = { + toolName: string; + description: string; + inputSchema: Record; + mutates: boolean; + operations: OperationEntry[]; +}; + +type LegacyToolCatalog = ToolCatalog & { tools: ToolCatalogEntry[] }; + +const STRIP_EMPTY_OPTIONAL_ARGS = new Set(['parentId', 'parentCommentId', 'id', 'status']); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value != null && !Array.isArray(value); +} + +function isObviouslyCorruptedToolArgKey(key: string): boolean { + const trimmed = key.trim(); + return trimmed.length === 0 || !/[\p{L}\p{N}]/u.test(trimmed); +} + +function stripCorruptedToolArgKeys(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => stripCorruptedToolArgKeys(item)); + } + if (!isRecord(value)) return value; + const clean: Record = {}; + for (const [key, entryValue] of Object.entries(value)) { + if (isObviouslyCorruptedToolArgKey(key)) continue; + clean[key] = stripCorruptedToolArgKeys(entryValue); + } + return clean; +} + +async function readJson(fileName: string): Promise { + const filePath = path.join(toolsDir, fileName); + let raw = ''; + try { + raw = await readFile(filePath, 'utf8'); + } catch (error) { + throw new SuperDocCliError('Unable to load packaged tool artifact.', { + code: 'TOOLS_ASSET_NOT_FOUND', + details: { filePath, message: error instanceof Error ? error.message : String(error) }, + }); + } + try { + return JSON.parse(raw) as T; + } catch (error) { + throw new SuperDocCliError('Packaged tool artifact is invalid JSON.', { + code: 'TOOLS_ASSET_INVALID', + details: { filePath, message: error instanceof Error ? error.message : String(error) }, + }); + } +} + +async function readProviderTools(provider: ToolProvider): Promise<{ + contractVersion: string; + tools: unknown[]; +}> { + return readJson(providerFileByName[provider]); +} + +// Cached catalog instance — loaded once per process. +let _catalogCache: LegacyToolCatalog | null = null; + +async function getCachedCatalog(): Promise { + if (_catalogCache == null) { + _catalogCache = await readJson('catalog.json'); + } + return _catalogCache; +} + +/** + * Apply provider-specific caching markers to the tools array. Clones the last + * entry instead of mutating the input. Anthropic gets an explicit + * `cache_control` on the last tool; other providers pass through. + */ +function applyCacheMarkers(tools: unknown[], provider: ToolProvider, cacheRequested: boolean): GetToolsResult { + if (!cacheRequested) { + return { tools, cacheStrategy: 'disabled' }; + } + + if (provider === 'anthropic') { + if (tools.length === 0) return { tools, cacheStrategy: 'explicit' }; + // Anthropic: marking the LAST tool with cache_control caches the entire + // tools block (and everything before it in the request — system prompt + // first if it also has cache_control). Shallow-spread the last entry so we + // don't mutate the cached tool list in place. + const next = tools.slice(0, -1); + const last = { + ...(tools[tools.length - 1] as Record), + cache_control: { type: 'ephemeral' }, + }; + next.push(last); + return { tools: next, cacheStrategy: 'explicit' }; + } + + if (provider === 'openai') { + // OpenAI caches prompts ≥ 1024 tokens automatically. No marker needed, + // but we still report cacheStrategy:'automatic' so callers can branch on + // it (e.g. for measurement). + return { tools, cacheStrategy: 'automatic' }; + } + + // vercel / generic — depends on underlying model. + return { tools, cacheStrategy: 'unsupported' }; +} + +function resolveDocApiMethod( + documentHandle: BoundDocApi, + operationId: string, +): (args: unknown, options?: InvokeOptions) => Promise { + const tokens = operationId.split('.').slice(1); + let cursor: unknown = documentHandle; + + for (const token of tokens) { + if (!isRecord(cursor) || !(token in cursor)) { + throw new SuperDocCliError(`No SDK doc method found for operation ${operationId}.`, { + code: 'TOOL_DISPATCH_NOT_FOUND', + details: { operationId, token }, + }); + } + cursor = cursor[token]; + } + + if (typeof cursor !== 'function') { + throw new SuperDocCliError(`Resolved member for ${operationId} is not callable.`, { + code: 'TOOL_DISPATCH_NOT_FOUND', + details: { operationId }, + }); + } + + return cursor as (args: unknown, options?: InvokeOptions) => Promise; +} + +/** + * Validate tool arguments against the catalog schema. + * + * Checks three things in order: + * 1. No unknown keys (additionalProperties: false in merged schema) + * 2. All universally-required keys present (merged schema `required`) + * 3. All action-specific required keys present (per-operation `required`) + */ +function validateToolArgs(toolName: string, args: Record, tool: ToolCatalogEntry): void { + const schema = tool.inputSchema; + const properties = isRecord(schema.properties) ? schema.properties : {}; + const required: string[] = Array.isArray(schema.required) ? (schema.required as string[]) : []; + + // 1. Reject unknown keys (additionalProperties: false in merged schema) + const knownKeys = new Set(Object.keys(properties)); + const unknownKeys = Object.keys(args).filter((k) => !knownKeys.has(k)); + if (unknownKeys.length > 0) { + throw new SuperDocCliError(`Unknown argument(s) for ${toolName}: ${unknownKeys.join(', ')}`, { + code: 'INVALID_ARGUMENT', + details: { toolName, unknownKeys, knownKeys: [...knownKeys] }, + }); + } + + // 2. Reject missing universally-required keys (merged schema `required`) + const missingKeys = required.filter((k) => args[k] == null); + if (missingKeys.length > 0) { + throw new SuperDocCliError(`Missing required argument(s) for ${toolName}: ${missingKeys.join(', ')}`, { + code: 'INVALID_ARGUMENT', + details: { toolName, missingKeys }, + }); + } + + // 3. Reject missing per-operation required keys. For multi-action tools, + // resolve the operation by action; for single-op tools, use the sole entry. + const action = args.action; + let op: OperationEntry | undefined; + if (typeof action === 'string' && tool.operations.length > 1) { + op = tool.operations.find((o) => o.intentAction === action); + } else if (tool.operations.length === 1) { + op = tool.operations[0]; + } + + if (op) { + validateOperationRequired(toolName, action, args, op); + } +} + +/** + * Check per-operation required constraints. Handles both flat `required: string[]` + * and discriminated `requiredOneOf: string[][]` shapes emitted by codegen. + */ +function validateOperationRequired( + toolName: string, + action: unknown, + args: Record, + op: OperationEntry, +): void { + const actionLabel = typeof action === 'string' ? ` action "${action}"` : ''; + + if (op.requiredOneOf && op.requiredOneOf.length > 0) { + const satisfied = op.requiredOneOf.some((branch) => branch.every((k) => args[k] != null)); + if (!satisfied) { + const options = op.requiredOneOf.map((b) => b.join(' + ')).join(' | '); + throw new SuperDocCliError( + `Missing required argument(s) for ${toolName}${actionLabel}: must provide one of: ${options}`, + { + code: 'INVALID_ARGUMENT', + details: { toolName, action, requiredOneOf: op.requiredOneOf }, + }, + ); + } + } else if (op.required && op.required.length > 0) { + const missingActionKeys = op.required.filter((k) => args[k] == null); + if (missingActionKeys.length > 0) { + throw new SuperDocCliError( + `Missing required argument(s) for ${toolName}${actionLabel}: ${missingActionKeys.join(', ')}`, + { + code: 'INVALID_ARGUMENT', + details: { toolName, action, missingKeys: missingActionKeys }, + }, + ); + } + } +} + +async function legacyGetTools(provider: ToolProvider, options?: GetToolsOptions): Promise { + const { tools } = await readProviderTools(provider); + const rawTools = Array.isArray(tools) ? tools : []; + return applyCacheMarkers(rawTools, provider, options?.cache === true); +} + +async function legacyGetCatalog(): Promise { + return getCachedCatalog(); +} + +async function legacyGetSystemPrompt(): Promise { + const promptPath = path.join(toolsDir, 'system-prompt.md'); + try { + return await readFile(promptPath, 'utf8'); + } catch { + throw new SuperDocCliError('System prompt not found.', { + code: 'TOOLS_ASSET_NOT_FOUND', + details: { filePath: promptPath }, + }); + } +} + +async function legacyGetMcpPrompt(): Promise { + const promptPath = path.join(toolsDir, 'system-prompt-mcp.md'); + try { + return await readFile(promptPath, 'utf8'); + } catch { + throw new SuperDocCliError('MCP system prompt not found.', { + code: 'TOOLS_ASSET_NOT_FOUND', + details: { filePath: promptPath }, + }); + } +} + +async function legacyDispatch( + documentHandle: BoundDocApi, + toolName: string, + args: Record, + invokeOptions?: InvokeOptions, +): Promise { + if (!isRecord(args)) { + throw new SuperDocCliError(`Tool arguments for ${toolName} must be an object.`, { + code: 'INVALID_ARGUMENT', + details: { toolName }, + }); + } + + const sanitizedArgs = stripCorruptedToolArgKeys(args); + if (!isRecord(sanitizedArgs)) { + throw new SuperDocCliError(`Tool arguments for ${toolName} must be an object.`, { + code: 'INVALID_ARGUMENT', + details: { toolName }, + }); + } + + const catalog = await getCachedCatalog(); + const entries = catalog.tools as ToolCatalogEntry[]; + const tool = entries.find((t) => t.toolName === toolName); + if (tool == null) { + throw new SuperDocCliError(`Unknown tool: ${toolName}`, { + code: 'TOOL_DISPATCH_NOT_FOUND', + details: { toolName }, + }); + } + validateToolArgs(toolName, sanitizedArgs, tool); + + // Strip empty strings for known optional ID/enum params that LLMs fill with "" + // instead of omitting. Only target params where "" is never a valid value. + const cleanArgs: Record = {}; + for (const [key, value] of Object.entries(sanitizedArgs)) { + if (value === '' && STRIP_EMPTY_OPTIONAL_ARGS.has(key)) continue; + cleanArgs[key] = value; + } + + return dispatchIntentTool(toolName, cleanArgs, (operationId, input) => { + const method = resolveDocApiMethod(documentHandle, operationId); + return method(input, invokeOptions); + }); +} + +export const legacyPreset: PresetDescriptor = { + id: 'legacy', + description: 'Codegen-emitted intent tools (default). Wraps packages/sdk/tools/ artifacts verbatim.', + supportsCacheControl: true, + + getTools: legacyGetTools, + getCatalog: legacyGetCatalog, + getSystemPrompt: legacyGetSystemPrompt, + getMcpPrompt: legacyGetMcpPrompt, + dispatch: legacyDispatch, +}; diff --git a/packages/sdk/langs/node/src/tools.ts b/packages/sdk/langs/node/src/tools.ts index a7b80acd63..0c8240759e 100644 --- a/packages/sdk/langs/node/src/tools.ts +++ b/packages/sdk/langs/node/src/tools.ts @@ -1,127 +1,39 @@ -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +/** + * Public LLM-tools API. Thin layer over the preset registry — every call here + * resolves a preset (defaulting to `legacy` for backwards compat) and delegates + * to it. + * + * Presets are the unit of swapping. To add a new tool surface (e.g. handwritten + * "core" tools, prompt-caching variant, lazy-load experiment), register a new + * descriptor in `presets.ts` — no changes here required. + */ + import type { BoundDocApi } from './generated/client.js'; import type { InvokeOptions } from './runtime/process.js'; -import { SuperDocCliError } from './runtime/errors.js'; -import { dispatchIntentTool } from './generated/intent-dispatch.generated.js'; - -export type ToolProvider = 'openai' | 'anthropic' | 'vercel' | 'generic'; - -// Resolve tools directory relative to package root (works from both src/ and dist/) -const toolsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'tools'); -const providerFileByName: Record = { - openai: 'tools.openai.json', - anthropic: 'tools.anthropic.json', - vercel: 'tools.vercel.json', - generic: 'tools.generic.json', -}; - -export type ToolCatalog = { - contractVersion: string; - generatedAt: string | null; - toolCount: number; - tools: ToolCatalogEntry[]; -}; - -type OperationEntry = { - operationId: string; - intentAction: string; - required?: string[]; - requiredOneOf?: string[][]; -}; - -type ToolCatalogEntry = { - toolName: string; - description: string; - inputSchema: Record; - mutates: boolean; - operations: OperationEntry[]; -}; - -const STRIP_EMPTY_OPTIONAL_ARGS = new Set(['parentId', 'parentCommentId', 'id', 'status']); +import { + DEFAULT_PRESET, + getPreset, + listPresets, + type CacheStrategy, + type ToolCatalog, + type ToolProvider, +} from './presets.js'; + +export { DEFAULT_PRESET, getPreset, listPresets }; +export type { CacheStrategy, ToolCatalog, ToolProvider }; -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value != null && !Array.isArray(value); -} - -function isObviouslyCorruptedToolArgKey(key: string): boolean { - const trimmed = key.trim(); - return trimmed.length === 0 || !/[\p{L}\p{N}]/u.test(trimmed); -} - -function stripCorruptedToolArgKeys(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map((item) => stripCorruptedToolArgKeys(item)); - } - - if (!isRecord(value)) return value; - - const clean: Record = {}; - for (const [key, entryValue] of Object.entries(value)) { - if (isObviouslyCorruptedToolArgKey(key)) continue; - clean[key] = stripCorruptedToolArgKeys(entryValue); - } - return clean; -} - -async function readJson(fileName: string): Promise { - const filePath = path.join(toolsDir, fileName); - let raw = ''; - try { - raw = await readFile(filePath, 'utf8'); - } catch (error) { - throw new SuperDocCliError('Unable to load packaged tool artifact.', { - code: 'TOOLS_ASSET_NOT_FOUND', - details: { - filePath, - message: error instanceof Error ? error.message : String(error), - }, - }); - } - - try { - return JSON.parse(raw) as T; - } catch (error) { - throw new SuperDocCliError('Packaged tool artifact is invalid JSON.', { - code: 'TOOLS_ASSET_INVALID', - details: { - filePath, - message: error instanceof Error ? error.message : String(error), - }, - }); - } -} - -async function loadProviderBundle(provider: ToolProvider): Promise<{ - contractVersion: string; - tools: unknown[]; -}> { - return readJson(providerFileByName[provider]); -} - -async function loadCatalog(): Promise { - return readJson('catalog.json'); -} - -export async function getToolCatalog(): Promise { - return getCachedCatalog(); -} - -export async function listTools(provider: ToolProvider): Promise { - const bundle = await loadProviderBundle(provider); - const tools = bundle.tools; - if (!Array.isArray(tools)) { - throw new SuperDocCliError('Tool provider bundle is missing tools array.', { - code: 'TOOLS_ASSET_INVALID', - details: { provider }, - }); - } - return tools; -} +// --------------------------------------------------------------------------- +// chooseTools — provider-shaped tool list with optional cache markers +// --------------------------------------------------------------------------- export type ToolChooserInput = { provider: ToolProvider; + /** + * Preset ID to load tools from. Defaults to {@link DEFAULT_PRESET} + * (`'legacy'`) for backwards compatibility. Use {@link listPresets} to + * discover available presets. + */ + preset?: string; /** * When `true`, applies provider-specific prompt-caching markers to the * returned tools so subsequent identical requests reuse the cached prefix. @@ -139,220 +51,79 @@ export type ToolChooserInput = { cache?: boolean; }; -export type CacheStrategy = 'explicit' | 'automatic' | 'unsupported' | 'disabled'; - /** - * Select all intent tools for a specific provider. - * - * Returns all intent tools in the requested provider format. Pass - * `cache: true` to apply provider-specific caching markers (see - * {@link ToolChooserInput.cache}). + * Select tools for a specific provider from a preset. * * @example * ```ts + * // Default — legacy preset, no cache markers. + * const { tools, meta } = await chooseTools({ provider: 'vercel' }); + * * // Anthropic — last tool gets cache_control automatically. * const { tools, meta } = await chooseTools({ provider: 'anthropic', cache: true }); * - * // OpenAI — caching is automatic when prompts exceed 1024 tokens. - * const { tools } = await chooseTools({ provider: 'openai', cache: true }); + * // Pick a specific preset by ID. + * const { tools, meta } = await chooseTools({ provider: 'openai', preset: 'legacy' }); * ``` */ export async function chooseTools(input: ToolChooserInput): Promise<{ tools: unknown[]; meta: { provider: ToolProvider; + preset: string; toolCount: number; cacheStrategy: CacheStrategy; }; }> { - const bundle = await loadProviderBundle(input.provider); - const rawTools = Array.isArray(bundle.tools) ? bundle.tools : []; - const cacheRequested = input.cache === true; - - const { tools, cacheStrategy } = applyCacheMarkers(rawTools, input.provider, cacheRequested); - + const presetId = input.preset ?? DEFAULT_PRESET; + const preset = getPreset(presetId); + const { tools, cacheStrategy } = await preset.getTools(input.provider, { + cache: input.cache === true, + }); return { tools, meta: { provider: input.provider, + preset: presetId, toolCount: tools.length, cacheStrategy, }, }; } -/** - * Apply provider-specific caching markers to the tools array. Mutates a clone, - * never the input. Anthropic gets an explicit `cache_control` on the last - * tool; other providers pass through. - */ -function applyCacheMarkers( - tools: unknown[], - provider: ToolProvider, - cacheRequested: boolean, -): { tools: unknown[]; cacheStrategy: CacheStrategy } { - if (!cacheRequested) { - return { tools, cacheStrategy: 'disabled' }; - } - - if (provider === 'anthropic') { - if (tools.length === 0) return { tools, cacheStrategy: 'explicit' }; - // Anthropic: marking the LAST tool with cache_control caches the entire - // tools block (and everything before it in the request — system prompt - // first if it also has cache_control). Shallow-spread the last entry so we - // don't mutate the cached bundle in place. - const next = tools.slice(0, -1); - const last = { - ...(tools[tools.length - 1] as Record), - cache_control: { type: 'ephemeral' }, - }; - next.push(last); - return { tools: next, cacheStrategy: 'explicit' }; - } - - if (provider === 'openai') { - // OpenAI caches prompts ≥ 1024 tokens automatically. No marker needed, - // but we still report cacheStrategy:'automatic' so callers can branch on - // it (e.g. for measurement). - return { tools, cacheStrategy: 'automatic' }; - } - - // vercel / generic — depends on underlying model. - return { tools, cacheStrategy: 'unsupported' }; -} - -function resolveDocApiMethod( - documentHandle: BoundDocApi, - operationId: string, -): (args: unknown, options?: InvokeOptions) => Promise { - const tokens = operationId.split('.').slice(1); - let cursor: unknown = documentHandle; - - for (const token of tokens) { - if (!isRecord(cursor) || !(token in cursor)) { - throw new SuperDocCliError(`No SDK doc method found for operation ${operationId}.`, { - code: 'TOOL_DISPATCH_NOT_FOUND', - details: { operationId, token }, - }); - } - cursor = cursor[token]; - } - - if (typeof cursor !== 'function') { - throw new SuperDocCliError(`Resolved member for ${operationId} is not callable.`, { - code: 'TOOL_DISPATCH_NOT_FOUND', - details: { operationId }, - }); - } - - return cursor as (args: unknown, options?: InvokeOptions) => Promise; -} - -// Cached catalog instance — loaded once per process. -let _catalogCache: ToolCatalog | null = null; +// --------------------------------------------------------------------------- +// Catalog + listings (preset-scoped; default to legacy) +// --------------------------------------------------------------------------- -async function getCachedCatalog(): Promise { - if (_catalogCache == null) { - _catalogCache = await loadCatalog(); - } - return _catalogCache; +/** Return the full tool catalog for a preset (default: legacy). */ +export async function getToolCatalog(preset?: string): Promise { + return getPreset(preset ?? DEFAULT_PRESET).getCatalog(); } /** - * Validate tool arguments against the catalog schema. + * Return the raw tool array for a provider from a preset (default: legacy). * - * Checks three things in order: - * 1. No unknown keys (additionalProperties: false in merged schema) - * 2. All universally-required keys present (merged schema `required`) - * 3. All action-specific required keys present (per-operation `required`) + * No cache markers are applied. Use {@link chooseTools} when you need cache + * markers and metadata. */ -function validateToolArgs(toolName: string, args: Record, tool: ToolCatalogEntry): void { - const schema = tool.inputSchema; - const properties = isRecord(schema.properties) ? schema.properties : {}; - const required: string[] = Array.isArray(schema.required) ? (schema.required as string[]) : []; - - // 1. Reject unknown keys - const knownKeys = new Set(Object.keys(properties)); - const unknownKeys = Object.keys(args).filter((k) => !knownKeys.has(k)); - if (unknownKeys.length > 0) { - throw new SuperDocCliError(`Unknown argument(s) for ${toolName}: ${unknownKeys.join(', ')}`, { - code: 'INVALID_ARGUMENT', - details: { toolName, unknownKeys, knownKeys: [...knownKeys] }, - }); - } - - // 2. Reject missing universally-required keys - const missingKeys = required.filter((k) => args[k] == null); - if (missingKeys.length > 0) { - throw new SuperDocCliError(`Missing required argument(s) for ${toolName}: ${missingKeys.join(', ')}`, { - code: 'INVALID_ARGUMENT', - details: { toolName, missingKeys }, - }); - } - - // 3. Reject missing per-operation required keys. - // For multi-action tools, resolve the operation by action; for single-op - // tools, use the sole operation entry. - const action = args.action; - let op: OperationEntry | undefined; - if (typeof action === 'string' && tool.operations.length > 1) { - op = tool.operations.find((o) => o.intentAction === action); - } else if (tool.operations.length === 1) { - op = tool.operations[0]; - } - - if (op) { - validateOperationRequired(toolName, action, args, op); - } +export async function listTools(provider: ToolProvider, preset?: string): Promise { + const { tools } = await getPreset(preset ?? DEFAULT_PRESET).getTools(provider, { cache: false }); + return tools; } -/** - * Check per-operation required constraints. - * - * Handles two shapes emitted by the codegen: - * - `required: string[]` — all listed keys must be present - * - `requiredOneOf: string[][]` — at least one branch must be fully satisfied - * (mirrors JSON Schema `oneOf` with per-branch `required` arrays) - */ -function validateOperationRequired( - toolName: string, - action: unknown, - args: Record, - op: OperationEntry, -): void { - const actionLabel = typeof action === 'string' ? ` action "${action}"` : ''; - - if (op.requiredOneOf && op.requiredOneOf.length > 0) { - const satisfied = op.requiredOneOf.some((branch) => branch.every((k) => args[k] != null)); - if (!satisfied) { - const options = op.requiredOneOf.map((b) => b.join(' + ')).join(' | '); - throw new SuperDocCliError( - `Missing required argument(s) for ${toolName}${actionLabel}: must provide one of: ${options}`, - { - code: 'INVALID_ARGUMENT', - details: { toolName, action, requiredOneOf: op.requiredOneOf }, - }, - ); - } - } else if (op.required && op.required.length > 0) { - const missingActionKeys = op.required.filter((k) => args[k] == null); - if (missingActionKeys.length > 0) { - throw new SuperDocCliError( - `Missing required argument(s) for ${toolName}${actionLabel}: ${missingActionKeys.join(', ')}`, - { - code: 'INVALID_ARGUMENT', - details: { toolName, action, missingKeys: missingActionKeys }, - }, - ); - } - } -} +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- /** - * Dispatch a tool call against a bound document handle. + * Dispatch a tool call against a bound document handle using the default + * preset (`legacy`). + * + * The document handle injects session targeting automatically; tool arguments + * should not contain `doc` or `sessionId`. * - * The document handle injects session targeting automatically. - * Tool arguments should not contain `doc` or `sessionId`. + * For preset-aware dispatch — e.g. when comparing two presets — call + * `getPreset('id').dispatch(...)` directly. */ export async function dispatchSuperDocTool( documentHandle: BoundDocApi, @@ -360,81 +131,32 @@ export async function dispatchSuperDocTool( args: Record = {}, invokeOptions?: InvokeOptions, ): Promise { - if (!isRecord(args)) { - throw new SuperDocCliError(`Tool arguments for ${toolName} must be an object.`, { - code: 'INVALID_ARGUMENT', - details: { toolName }, - }); - } - - const sanitizedArgs = stripCorruptedToolArgKeys(args); - if (!isRecord(sanitizedArgs)) { - throw new SuperDocCliError(`Tool arguments for ${toolName} must be an object.`, { - code: 'INVALID_ARGUMENT', - details: { toolName }, - }); - } - - // Validate against the tool schema before dispatch. - const catalog = await getCachedCatalog(); - const tool = catalog.tools.find((t) => t.toolName === toolName); - if (tool == null) { - throw new SuperDocCliError(`Unknown tool: ${toolName}`, { - code: 'TOOL_DISPATCH_NOT_FOUND', - details: { toolName }, - }); - } - validateToolArgs(toolName, sanitizedArgs, tool); - - // Strip empty strings for known optional ID/enum params that LLMs fill with "" - // instead of omitting. Only target params where "" is never a valid value. - const cleanArgs: Record = {}; - for (const [key, value] of Object.entries(sanitizedArgs)) { - if (value === '' && STRIP_EMPTY_OPTIONAL_ARGS.has(key)) continue; - cleanArgs[key] = value; - } - - return dispatchIntentTool(toolName, cleanArgs, (operationId, input) => { - const method = resolveDocApiMethod(documentHandle, operationId); - return method(input, invokeOptions); - }); + return getPreset(DEFAULT_PRESET).dispatch(documentHandle, toolName, args, invokeOptions); } +// --------------------------------------------------------------------------- +// System prompts (preset-scoped; default to legacy) +// --------------------------------------------------------------------------- + /** - * Read the bundled SDK system prompt for intent tools. + * Read the packaged SDK system prompt (default preset: legacy). * - * This prompt includes a persona preamble ("You are a document editing assistant…") - * suitable for embedded LLM usage (OpenAI, Anthropic, Vercel APIs). - * For MCP server instructions, use {@link getMcpPrompt} instead. + * Includes a persona preamble ("You are a document editing assistant…") + * suitable for embedded LLM usage (OpenAI, Anthropic, Vercel APIs). For MCP + * server instructions, use {@link getMcpPrompt} instead. */ -export async function getSystemPrompt(): Promise { - const promptPath = path.join(toolsDir, 'system-prompt.md'); - try { - return await readFile(promptPath, 'utf8'); - } catch { - throw new SuperDocCliError('System prompt not found.', { - code: 'TOOLS_ASSET_NOT_FOUND', - details: { filePath: promptPath }, - }); - } +export async function getSystemPrompt(preset?: string): Promise { + return getPreset(preset ?? DEFAULT_PRESET).getSystemPrompt(); } /** - * Read the bundled MCP system prompt for intent tools. + * Read the packaged MCP system prompt for intent tools (default preset: legacy). * - * This prompt omits the persona preamble and includes session lifecycle - * instructions (open/save/close) suitable for MCP server `instructions`. + * Omits the persona preamble and includes session lifecycle instructions + * (open/save/close) suitable for MCP server `instructions`. */ -export async function getMcpPrompt(): Promise { - const promptPath = path.join(toolsDir, 'system-prompt-mcp.md'); - try { - return await readFile(promptPath, 'utf8'); - } catch { - throw new SuperDocCliError('MCP system prompt not found.', { - code: 'TOOLS_ASSET_NOT_FOUND', - details: { filePath: promptPath }, - }); - } +export async function getMcpPrompt(preset?: string): Promise { + return getPreset(preset ?? DEFAULT_PRESET).getMcpPrompt(); } // --------------------------------------------------------------------------- @@ -481,9 +203,10 @@ export type SystemPromptForProviderResult = */ export async function getSystemPromptForProvider(input: { provider: ToolProvider; + preset?: string; cache?: boolean; }): Promise { - const text = await getSystemPrompt(); + const text = await getSystemPrompt(input.preset); const cacheRequested = input.cache === true; if (input.provider === 'anthropic') { diff --git a/packages/sdk/langs/python/pyproject.toml b/packages/sdk/langs/python/pyproject.toml index f348eb8488..dfa530296b 100644 --- a/packages/sdk/langs/python/pyproject.toml +++ b/packages/sdk/langs/python/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ packages = [ "superdoc", "superdoc.generated", + "superdoc.presets", "superdoc.skills", "superdoc.tools", ] diff --git a/packages/sdk/langs/python/superdoc/__init__.py b/packages/sdk/langs/python/superdoc/__init__.py index fa0b628a52..40ddf0e273 100644 --- a/packages/sdk/langs/python/superdoc/__init__.py +++ b/packages/sdk/langs/python/superdoc/__init__.py @@ -1,3 +1,4 @@ +from .presets import DEFAULT_PRESET, get_preset, list_presets from .client import AsyncSuperDocClient, AsyncSuperDocDocument, SuperDocClient, SuperDocDocument from .errors import SuperDocError from .skill_api import get_skill, install_skill, list_skills @@ -29,4 +30,7 @@ "dispatch_superdoc_tool_async", "get_mcp_prompt", "get_system_prompt", + "DEFAULT_PRESET", + "get_preset", + "list_presets", ] diff --git a/packages/sdk/langs/python/superdoc/presets/__init__.py b/packages/sdk/langs/python/superdoc/presets/__init__.py new file mode 100644 index 0000000000..3cb8c6cc29 --- /dev/null +++ b/packages/sdk/langs/python/superdoc/presets/__init__.py @@ -0,0 +1,102 @@ +"""Preset registry for SuperDoc LLM tools (Python). + +Mirrors the Node SDK preset registry (see ``packages/sdk/langs/node/src/presets.ts``). +A preset is a self-contained collection of LLM tools — provider catalogs +(openai / anthropic / vercel / generic), a system prompt, and a dispatcher. +Multiple presets can coexist; consumers select one at runtime via +``choose_tools({'preset': ...})``. + +v1 ships a single preset: ``'legacy'`` — a thin wrapper around today's +codegen-emitted intent tools. When callers omit ``preset``, ``legacy`` is used. + +Presets are NOT versioned. The preset id encodes the variant; a new shape +ships as a new id, not a new version of an existing one. +""" + +from __future__ import annotations + +from typing import Any, Awaitable, Dict, List, Literal, Optional, Protocol + +from ..errors import SuperDocError + +ToolProvider = Literal['openai', 'anthropic', 'vercel', 'generic'] + + +class PresetDescriptor(Protocol): + """Self-contained preset of LLM tools. + + Mirrors the Node SDK PresetDescriptor interface 1:1. Each preset owns + its tool catalogs per provider, its system prompts, and its dispatcher. + """ + + id: str + description: str + supports_cache_control: bool + + def get_tools(self, provider: ToolProvider, *, cache: bool = False) -> Dict[str, Any]: ... + def get_catalog(self) -> Dict[str, Any]: ... + def get_system_prompt(self) -> str: ... + def get_mcp_prompt(self) -> str: ... + def dispatch( + self, + document_handle: Any, + tool_name: str, + args: Optional[Dict[str, Any]] = None, + invoke_options: Optional[Dict[str, Any]] = None, + ) -> Any: ... + def dispatch_async( + self, + document_handle: Any, + tool_name: str, + args: Optional[Dict[str, Any]] = None, + invoke_options: Optional[Dict[str, Any]] = None, + ) -> Awaitable[Any]: ... + + +# Lazy import to avoid the registry pulling in heavy modules at package load. +def _build_registry() -> Dict[str, PresetDescriptor]: + from .legacy import legacy_preset # noqa: WPS433 — intentional lazy import + return {'legacy': legacy_preset} + + +DEFAULT_PRESET: str = 'legacy' +_PRESETS: Optional[Dict[str, PresetDescriptor]] = None + + +def _registry() -> Dict[str, PresetDescriptor]: + global _PRESETS + if _PRESETS is None: + _PRESETS = _build_registry() + return _PRESETS + + +def list_presets() -> List[str]: + """List the IDs of all registered presets.""" + return list(_registry().keys()) + + +def get_preset(preset_id: Optional[str] = None) -> PresetDescriptor: + """Resolve a preset by ID. + + Raises :class:`SuperDocError` with code ``PRESET_NOT_FOUND`` if the ID is + not registered. Omit the argument to get the default preset. + """ + resolved = preset_id if preset_id is not None else DEFAULT_PRESET + registry = _registry() + preset = registry.get(resolved) + if preset is None: + raise SuperDocError( + f'Unknown LLM-tools preset: "{resolved}"', + code='PRESET_NOT_FOUND', + details={'id': resolved, 'availablePresets': list(registry.keys())}, + ) + return preset + + +__all__ = [ + 'PresetDescriptor', + 'DEFAULT_PRESET', + 'ToolProvider', + 'get_preset', + 'list_presets', +] diff --git a/packages/sdk/langs/python/superdoc/presets/legacy.py b/packages/sdk/langs/python/superdoc/presets/legacy.py new file mode 100644 index 0000000000..e43f305bb9 --- /dev/null +++ b/packages/sdk/langs/python/superdoc/presets/legacy.py @@ -0,0 +1,278 @@ +"""Legacy preset — wraps the existing codegen-emitted intent tools verbatim. + +Mirrors ``packages/sdk/langs/node/src/presets/legacy.ts``. The legacy preset is +a read-through over the packaged tool artifacts in ``superdoc/tools/`` (catalog, +per-provider tool JSON, system prompts) and delegates dispatch to the +codegen-emitted ``dispatch_intent_tool``. It is the default preset returned +by ``choose_tools()`` when callers omit ``preset``. + +Nothing in this file relocates or rewrites the packaged artifacts. The whole +point of the read-through wrapper is that running ``generate:all`` continues +to refresh the package assets in place; the legacy preset picks up the new +files on the next call. +""" + +from __future__ import annotations + +import inspect +import json +import re +from dataclasses import dataclass +from importlib import resources +from typing import Any, Awaitable, Dict, List, Optional, cast + +from ..errors import SuperDocError +from ..tools.intent_dispatch_generated import dispatch_intent_tool +from . import ToolProvider + +_PROVIDER_FILE: Dict[ToolProvider, str] = { + 'openai': 'tools.openai.json', + 'anthropic': 'tools.anthropic.json', + 'vercel': 'tools.vercel.json', + 'generic': 'tools.generic.json', +} + + +def _read_json_asset(name: str) -> Dict[str, Any]: + resource = resources.files('superdoc').joinpath('tools', name) + try: + raw = resource.read_text(encoding='utf-8') + except FileNotFoundError as error: + raise SuperDocError( + 'Unable to load packaged tool artifact.', + code='TOOLS_ASSET_NOT_FOUND', + details={'file': name}, + ) from error + except Exception as error: + raise SuperDocError( + 'Unable to read packaged tool artifact.', + code='TOOLS_ASSET_NOT_FOUND', + details={'file': name, 'message': str(error)}, + ) from error + + try: + parsed = json.loads(raw) + except Exception as error: + raise SuperDocError( + 'Packaged tool artifact is invalid JSON.', + code='TOOLS_ASSET_INVALID', + details={'file': name, 'message': str(error)}, + ) from error + + if not isinstance(parsed, dict): + raise SuperDocError( + 'Packaged tool artifact root must be an object.', + code='TOOLS_ASSET_INVALID', + details={'file': name}, + ) + + return cast(Dict[str, Any], parsed) + + +_catalog_cache: Optional[Dict[str, Any]] = None + + +def _get_catalog_cached() -> Dict[str, Any]: + global _catalog_cache + if _catalog_cache is None: + _catalog_cache = _read_json_asset('catalog.json') + return _catalog_cache + + +def _apply_cache_markers( + tools: List[Any], + provider: ToolProvider, + cache_requested: bool, +) -> Dict[str, Any]: + if not cache_requested: + return {'tools': tools, 'cacheStrategy': 'disabled'} + + if provider == 'anthropic': + if not tools: + return {'tools': tools, 'cacheStrategy': 'explicit'} + # Mark the LAST tool with cache_control — caches the entire tools block. + next_tools = list(tools[:-1]) + last = dict(tools[-1]) if isinstance(tools[-1], dict) else tools[-1] + if isinstance(last, dict): + last['cache_control'] = {'type': 'ephemeral'} + next_tools.append(last) + return {'tools': next_tools, 'cacheStrategy': 'explicit'} + + if provider == 'openai': + return {'tools': tools, 'cacheStrategy': 'automatic'} + + return {'tools': tools, 'cacheStrategy': 'unsupported'} + + +def _snake_case(token: str) -> str: + token = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1_\2', token) + token = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', token) + return token.replace('-', '_').lower() + + +def _resolve_doc_method(document_handle: Any, operation_id: str) -> Any: + cursor = document_handle + for token in operation_id.split('.')[1:]: + candidates = [token] + snake_token = _snake_case(token) + if snake_token != token: + candidates.append(snake_token) + + resolved = None + for candidate in candidates: + if hasattr(cursor, candidate): + resolved = getattr(cursor, candidate) + break + + if resolved is None: + raise SuperDocError( + 'No SDK doc method found for operation.', + code='TOOL_DISPATCH_NOT_FOUND', + details={'operationId': operation_id, 'token': token}, + ) + cursor = resolved + + if not callable(cursor): + raise SuperDocError( + 'Resolved SDK doc member is not callable.', + code='TOOL_DISPATCH_NOT_FOUND', + details={'operationId': operation_id}, + ) + + return cursor + + +def _legacy_get_tools(provider: ToolProvider, *, cache: bool = False) -> Dict[str, Any]: + if provider not in ('openai', 'anthropic', 'vercel', 'generic'): + raise SuperDocError('provider is required.', code='INVALID_ARGUMENT', details={'provider': provider}) + provider_file = _read_json_asset(_PROVIDER_FILE[provider]) + tools = provider_file.get('tools') + raw_tools = tools if isinstance(tools, list) else [] + return _apply_cache_markers(cast(List[Any], raw_tools), provider, cache) + + +def _legacy_get_catalog() -> Dict[str, Any]: + return _get_catalog_cached() + + +def _legacy_get_system_prompt() -> str: + resource = resources.files('superdoc').joinpath('tools', 'system-prompt.md') + try: + return resource.read_text(encoding='utf-8') + except FileNotFoundError as error: + raise SuperDocError( + 'System prompt not found.', + code='TOOLS_ASSET_NOT_FOUND', + details={'file': 'system-prompt.md'}, + ) from error + + +def _legacy_get_mcp_prompt() -> str: + resource = resources.files('superdoc').joinpath('tools', 'system-prompt-mcp.md') + try: + return resource.read_text(encoding='utf-8') + except FileNotFoundError as error: + raise SuperDocError( + 'MCP system prompt not found.', + code='TOOLS_ASSET_NOT_FOUND', + details={'file': 'system-prompt-mcp.md'}, + ) from error + + +def _legacy_dispatch( + document_handle: Any, + tool_name: str, + args: Optional[Dict[str, Any]] = None, + invoke_options: Optional[Dict[str, Any]] = None, +) -> Any: + payload = args or {} + if not isinstance(payload, dict): + raise SuperDocError( + 'Tool arguments must be an object.', + code='INVALID_ARGUMENT', + details={'toolName': tool_name}, + ) + + payload = {k: v for k, v in payload.items() if k not in ('doc', 'sessionId')} + + def execute(operation_id: str, input_args: Dict[str, Any]) -> Any: + method = _resolve_doc_method(document_handle, operation_id) + if inspect.iscoroutinefunction(method): + raise SuperDocError( + 'legacy.dispatch cannot call async methods. Use dispatch_async.', + code='INVALID_ARGUMENT', + details={'toolName': tool_name, 'operationId': operation_id}, + ) + kwargs = dict(invoke_options or {}) + return method(input_args, **kwargs) + + return dispatch_intent_tool(tool_name, payload, execute) + + +async def _legacy_dispatch_async( + document_handle: Any, + tool_name: str, + args: Optional[Dict[str, Any]] = None, + invoke_options: Optional[Dict[str, Any]] = None, +) -> Any: + payload = args or {} + if not isinstance(payload, dict): + raise SuperDocError( + 'Tool arguments must be an object.', + code='INVALID_ARGUMENT', + details={'toolName': tool_name}, + ) + + payload = {k: v for k, v in payload.items() if k not in ('doc', 'sessionId')} + + def execute(operation_id: str, input_args: Dict[str, Any]) -> Any: + method = _resolve_doc_method(document_handle, operation_id) + kwargs = dict(invoke_options or {}) + return method(input_args, **kwargs) + + result = dispatch_intent_tool(tool_name, payload, execute) + if inspect.isawaitable(result): + return await result + return result + + +@dataclass(frozen=True) +class _LegacyPreset: + id: str = 'legacy' + description: str = ( + 'Codegen-emitted intent tools (default). Wraps superdoc/tools/ artifacts verbatim.' + ) + supports_cache_control: bool = True + + def get_tools(self, provider: ToolProvider, *, cache: bool = False) -> Dict[str, Any]: + return _legacy_get_tools(provider, cache=cache) + + def get_catalog(self) -> Dict[str, Any]: + return _legacy_get_catalog() + + def get_system_prompt(self) -> str: + return _legacy_get_system_prompt() + + def get_mcp_prompt(self) -> str: + return _legacy_get_mcp_prompt() + + def dispatch( + self, + document_handle: Any, + tool_name: str, + args: Optional[Dict[str, Any]] = None, + invoke_options: Optional[Dict[str, Any]] = None, + ) -> Any: + return _legacy_dispatch(document_handle, tool_name, args, invoke_options) + + def dispatch_async( + self, + document_handle: Any, + tool_name: str, + args: Optional[Dict[str, Any]] = None, + invoke_options: Optional[Dict[str, Any]] = None, + ) -> Awaitable[Any]: + return _legacy_dispatch_async(document_handle, tool_name, args, invoke_options) + + +legacy_preset: _LegacyPreset = _LegacyPreset() diff --git a/packages/sdk/langs/python/superdoc/test_parity_helper.py b/packages/sdk/langs/python/superdoc/test_parity_helper.py index 08fbd86f93..92f1560ddc 100644 --- a/packages/sdk/langs/python/superdoc/test_parity_helper.py +++ b/packages/sdk/langs/python/superdoc/test_parity_helper.py @@ -26,6 +26,13 @@ def main() -> None: result.pop('tools', None) print(json.dumps({'ok': True, 'result': result})) + elif action == 'listPresets': + from superdoc import DEFAULT_PRESET, list_presets + print(json.dumps({'ok': True, 'result': { + 'defaultPreset': DEFAULT_PRESET, + 'presets': list_presets(), + }})) + elif action == 'resolveIntentDispatch': from superdoc.tools.intent_dispatch_generated import dispatch_intent_tool tool_name = command['toolName'] diff --git a/packages/sdk/langs/python/superdoc/tools_api.py b/packages/sdk/langs/python/superdoc/tools_api.py index 12ed092873..abcf051fc5 100644 --- a/packages/sdk/langs/python/superdoc/tools_api.py +++ b/packages/sdk/langs/python/superdoc/tools_api.py @@ -1,174 +1,113 @@ +"""Public LLM-tools API (Python SDK). Thin layer over the preset registry. + +Every call here resolves a preset (defaulting to ``legacy`` for backwards +compat) and delegates to it. Mirrors ``packages/sdk/langs/node/src/tools.ts``. +""" + from __future__ import annotations -import inspect -import json -import re -from importlib import resources -from typing import Any, Dict, List, Literal, Optional, TypedDict, cast +from typing import Any, Dict, List, Optional, TypedDict, cast +from .presets import DEFAULT_PRESET, ToolProvider, get_preset, list_presets from .errors import SuperDocError -from .tools.intent_dispatch_generated import dispatch_intent_tool -ToolProvider = Literal['openai', 'anthropic', 'vercel', 'generic'] +__all__ = [ + 'DEFAULT_PRESET', + 'ToolChooserInput', + 'ToolProvider', + 'choose_tools', + 'dispatch_superdoc_tool', + 'dispatch_superdoc_tool_async', + 'get_preset', + 'get_mcp_prompt', + 'get_system_prompt', + 'get_tool_catalog', + 'list_presets', + 'list_tools', +] class ToolChooserInput(TypedDict, total=False): provider: ToolProvider + # Preset ID to load tools from. Defaults to DEFAULT_PRESET ('legacy') + # for backwards compatibility. Use list_presets() to discover presets. + preset: str + # When True, applies provider-specific prompt-cache markers (Anthropic + # ``cache_control: { type: "ephemeral" }`` on the last tool, etc). + cache: bool -PROVIDER_FILE: Dict[ToolProvider, str] = { - 'openai': 'tools.openai.json', - 'anthropic': 'tools.anthropic.json', - 'vercel': 'tools.vercel.json', - 'generic': 'tools.generic.json', -} - +def get_tool_catalog(preset: Optional[str] = None) -> Dict[str, Any]: + """Return the full tool catalog for a preset (default: legacy).""" + return get_preset(preset).get_catalog() -def _read_json_asset(name: str) -> Dict[str, Any]: - resource = resources.files('superdoc').joinpath('tools', name) - try: - raw = resource.read_text(encoding='utf-8') - except FileNotFoundError as error: - raise SuperDocError( - 'Unable to load packaged tool artifact.', - code='TOOLS_ASSET_NOT_FOUND', - details={'file': name}, - ) from error - except Exception as error: - raise SuperDocError( - 'Unable to read packaged tool artifact.', - code='TOOLS_ASSET_NOT_FOUND', - details={'file': name, 'message': str(error)}, - ) from error - - try: - parsed = json.loads(raw) - except Exception as error: - raise SuperDocError( - 'Packaged tool artifact is invalid JSON.', - code='TOOLS_ASSET_INVALID', - details={'file': name, 'message': str(error)}, - ) from error - - if not isinstance(parsed, dict): - raise SuperDocError( - 'Packaged tool artifact root must be an object.', - code='TOOLS_ASSET_INVALID', - details={'file': name}, - ) - return cast(Dict[str, Any], parsed) +def list_tools(provider: ToolProvider, preset: Optional[str] = None) -> List[Dict[str, Any]]: + """Return the raw tool array for a provider from a preset (default: legacy). - -def get_tool_catalog() -> Dict[str, Any]: - return _read_json_asset('catalog.json') - - -def list_tools(provider: ToolProvider) -> List[Dict[str, Any]]: - bundle = _read_json_asset(PROVIDER_FILE[provider]) - tools = bundle.get('tools') - if not isinstance(tools, list): + No cache markers applied. Use :func:`choose_tools` for cache markers and metadata. + """ + if provider not in ('openai', 'anthropic', 'vercel', 'generic'): raise SuperDocError( - 'Tool provider bundle is missing tools array.', - code='TOOLS_ASSET_INVALID', + 'provider is required.', + code='INVALID_ARGUMENT', details={'provider': provider}, ) + result = get_preset(preset).get_tools(provider, cache=False) + tools = result.get('tools') if isinstance(result.get('tools'), list) else [] return cast(List[Dict[str, Any]], tools) def choose_tools(input: ToolChooserInput) -> Dict[str, Any]: - """Select all intent tools for a specific provider. - - Returns all intent tools in the requested provider format. + """Select tools for a specific provider from a preset. Example:: + # Default — legacy preset. result = choose_tools({'provider': 'openai'}) + + # Pick a specific preset. + result = choose_tools({'provider': 'anthropic', 'preset': 'legacy', 'cache': True}) """ provider = input.get('provider') if provider not in ('openai', 'anthropic', 'vercel', 'generic'): - raise SuperDocError('provider is required.', code='INVALID_ARGUMENT', details={'provider': provider}) + raise SuperDocError( + 'provider is required.', + code='INVALID_ARGUMENT', + details={'provider': provider}, + ) - bundle = _read_json_asset(PROVIDER_FILE[provider]) - tools = bundle.get('tools') if isinstance(bundle.get('tools'), list) else [] + preset_id = input.get('preset') or DEFAULT_PRESET + cache_requested = bool(input.get('cache')) + + preset = get_preset(preset_id) + result = preset.get_tools(cast(ToolProvider, provider), cache=cache_requested) + tools = result.get('tools') if isinstance(result.get('tools'), list) else [] + cache_strategy = result.get('cacheStrategy', 'disabled') return { 'tools': tools, 'meta': { 'provider': provider, - 'toolCount': len(tools), + 'preset': preset_id, + 'toolCount': len(tools) if isinstance(tools, list) else 0, + 'cacheStrategy': cache_strategy, }, } -def _resolve_doc_method(document_handle: Any, operation_id: str) -> Any: - def _snake_case(token: str) -> str: - token = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1_\2', token) - token = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', token) - return token.replace('-', '_').lower() - - cursor = document_handle - for token in operation_id.split('.')[1:]: - candidates = [token] - snake_token = _snake_case(token) - if snake_token != token: - candidates.append(snake_token) - - resolved = None - for candidate in candidates: - if hasattr(cursor, candidate): - resolved = getattr(cursor, candidate) - break - - if resolved is None: - raise SuperDocError( - 'No SDK doc method found for operation.', - code='TOOL_DISPATCH_NOT_FOUND', - details={'operationId': operation_id, 'token': token}, - ) - cursor = resolved - - if not callable(cursor): - raise SuperDocError( - 'Resolved SDK doc member is not callable.', - code='TOOL_DISPATCH_NOT_FOUND', - details={'operationId': operation_id}, - ) - - return cursor - - def dispatch_superdoc_tool( document_handle: Any, tool_name: str, args: Optional[Dict[str, Any]] = None, invoke_options: Optional[Dict[str, Any]] = None, ) -> Any: - """Dispatch a tool call against a bound document handle. + """Dispatch a tool call against a bound document handle using the default preset. - The document handle injects session targeting automatically. - Tool arguments should not contain doc or sessionId — those are - stripped if present for backwards compatibility with older tool schemas. + The handle injects session targeting automatically; arguments should not + contain ``doc`` or ``sessionId`` — those are stripped if present. """ - payload = args or {} - if not isinstance(payload, dict): - raise SuperDocError('Tool arguments must be an object.', code='INVALID_ARGUMENT', details={'toolName': tool_name}) - - # Strip doc/sessionId if present — the document handle manages targeting. - payload = {k: v for k, v in payload.items() if k not in ('doc', 'sessionId')} - - def execute(operation_id: str, input_args: Dict[str, Any]) -> Any: - method = _resolve_doc_method(document_handle, operation_id) - if inspect.iscoroutinefunction(method): - raise SuperDocError( - 'dispatch_superdoc_tool cannot call async methods. Use dispatch_superdoc_tool_async.', - code='INVALID_ARGUMENT', - details={'toolName': tool_name, 'operationId': operation_id}, - ) - kwargs = dict(invoke_options or {}) - return method(input_args, **kwargs) - - return dispatch_intent_tool(tool_name, payload, execute) + return get_preset(DEFAULT_PRESET).dispatch(document_handle, tool_name, args, invoke_options) async def dispatch_superdoc_tool_async( @@ -177,55 +116,25 @@ async def dispatch_superdoc_tool_async( args: Optional[Dict[str, Any]] = None, invoke_options: Optional[Dict[str, Any]] = None, ) -> Any: - """Async version of dispatch_superdoc_tool. Dispatches against a bound document handle.""" - payload = args or {} - if not isinstance(payload, dict): - raise SuperDocError('Tool arguments must be an object.', code='INVALID_ARGUMENT', details={'toolName': tool_name}) - - # Strip doc/sessionId if present — the document handle manages targeting. - payload = {k: v for k, v in payload.items() if k not in ('doc', 'sessionId')} + """Async version of :func:`dispatch_superdoc_tool`.""" + return await get_preset(DEFAULT_PRESET).dispatch_async( + document_handle, tool_name, args, invoke_options, + ) - def execute(operation_id: str, input_args: Dict[str, Any]) -> Any: - method = _resolve_doc_method(document_handle, operation_id) - kwargs = dict(invoke_options or {}) - return method(input_args, **kwargs) - result = dispatch_intent_tool(tool_name, payload, execute) - if inspect.isawaitable(result): - return await result - return result +def get_system_prompt(preset: Optional[str] = None) -> str: + """Read the packaged SDK system prompt (default preset: legacy). - -def get_system_prompt() -> str: - """Read the bundled SDK system prompt for intent tools. - - This prompt includes a persona preamble suitable for embedded LLM usage - (OpenAI, Anthropic APIs). For MCP server instructions, use - :func:`get_mcp_prompt` instead. + Includes a persona preamble suitable for embedded LLM usage. For MCP + server instructions, use :func:`get_mcp_prompt` instead. """ - resource = resources.files('superdoc').joinpath('tools', 'system-prompt.md') - try: - return resource.read_text(encoding='utf-8') - except FileNotFoundError as error: - raise SuperDocError( - 'System prompt not found.', - code='TOOLS_ASSET_NOT_FOUND', - details={'file': 'system-prompt.md'}, - ) from error + return get_preset(preset).get_system_prompt() -def get_mcp_prompt() -> str: - """Read the bundled MCP system prompt for intent tools. +def get_mcp_prompt(preset: Optional[str] = None) -> str: + """Read the packaged MCP system prompt for intent tools (default preset: legacy). - This prompt omits the persona preamble and includes session lifecycle - instructions (open/save/close) suitable for MCP server ``instructions``. + Omits the persona preamble and includes session lifecycle instructions + (open/save/close) suitable for MCP server ``instructions``. """ - resource = resources.files('superdoc').joinpath('tools', 'system-prompt-mcp.md') - try: - return resource.read_text(encoding='utf-8') - except FileNotFoundError as error: - raise SuperDocError( - 'MCP system prompt not found.', - code='TOOLS_ASSET_NOT_FOUND', - details={'file': 'system-prompt-mcp.md'}, - ) from error + return get_preset(preset).get_mcp_prompt() diff --git a/packages/sdk/langs/python/tests/test_presets.py b/packages/sdk/langs/python/tests/test_presets.py new file mode 100644 index 0000000000..2a64add2d6 --- /dev/null +++ b/packages/sdk/langs/python/tests/test_presets.py @@ -0,0 +1,137 @@ +"""Preset registry tests (Python SDK) — mirrors Node SDK presets.test.ts.""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from superdoc import ( # noqa: E402 + DEFAULT_PRESET, + SuperDocError, + choose_tools, + get_preset, + get_mcp_prompt, + get_system_prompt, + get_tool_catalog, + list_presets, + list_tools, +) + + +PROVIDERS = ('openai', 'anthropic', 'vercel', 'generic') + + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + +def test_default_preset_is_legacy(): + assert DEFAULT_PRESET == 'legacy' + + +def test_list_presets_includes_legacy(): + presets = list_presets() + assert 'legacy' in presets + + +def test_get_preset_no_arg_returns_legacy(): + preset = get_preset() + assert preset.id == 'legacy' + + +def test_get_preset_explicit_returns_legacy(): + preset = get_preset('legacy') + assert preset.id == 'legacy' + assert preset.description + assert preset.supports_cache_control is True + + +def test_get_preset_nonexistent_raises_preset_not_found(): + with pytest.raises(SuperDocError) as excinfo: + get_preset('nonexistent-preset') + assert excinfo.value.code == 'PRESET_NOT_FOUND' + assert 'nonexistent-preset' in str(excinfo.value) + assert excinfo.value.details['id'] == 'nonexistent-preset' + assert 'legacy' in excinfo.value.details['availablePresets'] + + +# --------------------------------------------------------------------------- +# choose_tools — default preset equivalence +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize('provider', PROVIDERS) +def test_choose_tools_omit_preset_equals_legacy(provider): + implicit = choose_tools({'provider': provider}) + explicit = choose_tools({'provider': provider, 'preset': 'legacy'}) + assert implicit['tools'] == explicit['tools'] + assert implicit['meta']['toolCount'] == explicit['meta']['toolCount'] + assert implicit['meta']['provider'] == explicit['meta']['provider'] + assert implicit['meta']['cacheStrategy'] == explicit['meta']['cacheStrategy'] + assert implicit['meta']['preset'] == 'legacy' + assert explicit['meta']['preset'] == 'legacy' + + +def test_choose_tools_nonexistent_preset_raises(): + with pytest.raises(SuperDocError) as excinfo: + choose_tools({'provider': 'openai', 'preset': 'nonexistent-preset'}) + assert excinfo.value.code == 'PRESET_NOT_FOUND' + + +def test_choose_tools_meta_preset_field_present(): + result = choose_tools({'provider': 'openai'}) + assert result['meta']['preset'] == 'legacy' + + +# --------------------------------------------------------------------------- +# Catalog + listings — default preset equivalence +# --------------------------------------------------------------------------- + +def test_get_tool_catalog_default_equals_legacy(): + implicit = get_tool_catalog() + explicit = get_tool_catalog('legacy') + assert implicit == explicit + + +@pytest.mark.parametrize('provider', PROVIDERS) +def test_list_tools_default_equals_legacy(provider): + implicit = list_tools(provider) + explicit = list_tools(provider, 'legacy') + assert implicit == explicit + + +def test_get_tool_catalog_nonexistent_preset_raises(): + with pytest.raises(SuperDocError) as excinfo: + get_tool_catalog('nonexistent-preset') + assert excinfo.value.code == 'PRESET_NOT_FOUND' + + +# --------------------------------------------------------------------------- +# System prompts — default preset equivalence +# --------------------------------------------------------------------------- + +def test_get_system_prompt_default_equals_legacy(): + assert get_system_prompt() == get_system_prompt('legacy') + + +def test_get_mcp_prompt_default_equals_legacy(): + assert get_mcp_prompt() == get_mcp_prompt('legacy') + + +# --------------------------------------------------------------------------- +# Direct preset access +# --------------------------------------------------------------------------- + +def test_preset_get_catalog_matches_top_level(): + direct = get_preset('legacy').get_catalog() + via_top_level = get_tool_catalog() + assert direct == via_top_level + + +@pytest.mark.parametrize('provider', PROVIDERS) +def test_preset_get_tools_matches_choose_tools(provider): + direct = get_preset('legacy').get_tools(provider) + via_top_level = choose_tools({'provider': provider}) + assert direct['tools'] == via_top_level['tools'] + assert direct['cacheStrategy'] == via_top_level['meta']['cacheStrategy'] From 3385cad53d7b8f33620fd358121d919ce2e91670 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Thu, 28 May 2026 20:44:05 +0300 Subject: [PATCH 02/11] fix: make toc toolbar icon configurable --- .../v1/components/toolbar/defaultItems.js | 12 +++--------- .../components/toolbar/defaultItems.test.js | 19 ++++++++++++++++++- .../v1/components/toolbar/super-toolbar.js | 2 ++ packages/superdoc/src/core/types/index.ts | 4 ++++ 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js index 26c606e4a3..caa06060d0 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js @@ -1076,17 +1076,11 @@ export const makeDefaultItems = ({ const stickyItemsWidth = 120; const toolbarPadding = 32; - const itemsToHideXL = [ - 'linkedStyles', - 'clearFormatting', - 'copyFormat', - 'ruler', - 'formattingMarks', - 'tableOfContents', - ]; + const itemsToHideXL = ['linkedStyles', 'clearFormatting', 'copyFormat', 'ruler', 'formattingMarks']; const itemsToHideSM = ['zoom', 'fontFamily', 'fontSize', 'redo']; const shouldUseLgCompactStyles = availableWidth <= RESPONSIVE_BREAKPOINTS.lg; const shouldIncludeFormattingMarks = superToolbar.config?.showFormattingMarksButton === true; + const shouldIncludeTableOfContents = superToolbar.config?.showTableOfContentsButton === true; if (shouldUseLgCompactStyles) { documentMode.attributes.value = { @@ -1122,7 +1116,7 @@ export const makeDefaultItems = ({ separator, link, image, - tableOfContents, + ...(shouldIncludeTableOfContents ? [tableOfContents] : []), tableItem, tableActionsItem, separator, diff --git a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.test.js b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.test.js index 6f1da73deb..bffe17f17b 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.test.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.test.js @@ -44,6 +44,23 @@ function buildItems(availableWidth, superToolbarOverrides = {}) { }); } +describe('makeDefaultItems table of contents button opt-in', () => { + it('does not include tableOfContents in the default toolbar items', () => { + const { defaultItems, overflowItems } = buildItems(2000); + expect(getItem(defaultItems, overflowItems, 'tableOfContents')).toBeUndefined(); + }); + + it('includes tableOfContents when showTableOfContentsButton is true', () => { + const { defaultItems, overflowItems } = buildItems(2000, { + config: { showTableOfContentsButton: true }, + }); + const tableOfContents = getItem(defaultItems, overflowItems, 'tableOfContents'); + + expect(tableOfContents).toBeDefined(); + expect(tableOfContents.command).toBe('insertTableOfContents'); + }); +}); + describe('makeDefaultItems formatting marks button opt-in', () => { it('does not include formattingMarks in the default toolbar items', () => { const { defaultItems, overflowItems } = buildItems(2000); @@ -73,7 +90,7 @@ describe('makeDefaultItems formatting marks button opt-in', () => { describe('makeDefaultItems XL overflow boundary (SD-2328)', () => { const XL_OVERFLOW_SAFETY_BUFFER = 20; const XL_CUTOFF = RESPONSIVE_BREAKPOINTS.xl + XL_OVERFLOW_SAFETY_BUFFER; - const XL_ITEMS = ['linkedStyles', 'clearFormatting', 'copyFormat', 'ruler', 'tableOfContents']; + const XL_ITEMS = ['linkedStyles', 'clearFormatting', 'copyFormat', 'ruler']; it(`moves XL items into overflow at ${XL_CUTOFF - 1}px (below cutoff)`, () => { const { defaultItems, overflowItems } = buildItems(XL_CUTOFF - 1); diff --git a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js index be44f628b8..0478506e6e 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js @@ -49,6 +49,7 @@ import { insertTableOfContentsAtSelection } from '@extensions/table-of-contents/ * @property {string} [aiEndpoint] - Endpoint for AI integration * @property {Array> | ToolbarItem[]} [customButtons=[]] - Custom buttons to add to the toolbar. SuperDoc forwards the structural `Array>` shape from `Modules.toolbar.customButtons`; the runtime wraps each entry into a full `ToolbarItem` via `useToolbarItem`. * @property {boolean} [showFormattingMarksButton=false] - Show the formatting marks (pilcrow) button in the toolbar. Distinct from `layoutEngineOptions.showFormattingMarks`, which controls whether the marks render in the document. + * @property {boolean} [showTableOfContentsButton=false] - Show the table of contents insert button in the toolbar. Off by default until the feature is generally available. * @property {boolean} [isDev] - Dev-mode flag forwarded from `SuperDoc.isDev`; gates debug tooltips and overlays. * @property {object} [superdoc] - The owning SuperDoc instance. Set by SuperDoc when constructing the toolbar; the toolbar uses it to dispatch commands back through the parent. * @property {boolean} [responsiveToContainer=false] - When `true`, the toolbar measures the container width instead of the document width when deciding which items to collapse. @@ -169,6 +170,7 @@ export class SuperToolbar extends EventEmitter { aiEndpoint: null, customButtons: [], showFormattingMarksButton: false, + showTableOfContentsButton: false, }; /** diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts index 1d085c6ff1..a815cd7911 100644 --- a/packages/superdoc/src/core/types/index.ts +++ b/packages/superdoc/src/core/types/index.ts @@ -1227,6 +1227,10 @@ export interface Modules { * controls whether the marks render in the document. */ showFormattingMarksButton?: boolean; + /** + * Show the table of contents insert button in the toolbar. Off by default. + */ + showTableOfContentsButton?: boolean; } & Record; /** Link click popover configuration. */ links?: { From 2a893baf4d4e58da27710023e6d63db7f1fb72ec Mon Sep 17 00:00:00 2001 From: Artem Nistuley Date: Tue, 26 May 2026 18:09:01 +0300 Subject: [PATCH 03/11] feat: add layered style export --- README.md | 8 +++++ apps/docs/editor/theming/overview.mdx | 16 ++++++++++ apps/docs/getting-started/theming.mdx | 16 ++++++++++ packages/superdoc/AGENTS.md | 13 ++++++++ packages/superdoc/package.json | 3 +- .../superdoc/scripts/type-surface.config.cjs | 1 + packages/superdoc/vite-plugin-layered-css.mjs | 32 +++++++++++++++++++ packages/superdoc/vite.config.cdn.js | 3 +- packages/superdoc/vite.config.js | 2 ++ 9 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 packages/superdoc/vite-plugin-layered-css.mjs diff --git a/README.md b/README.md index c576761708..836b43f13a 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,14 @@ const superdoc = new SuperDoc({ }); ``` +Optional layered CSS mode: + +```css +@layer reset, superdoc, app; +@import 'superdoc/style.layered.css'; +@import 'your-app.css' layer(app); +``` + Or use the CDN: ```html diff --git a/apps/docs/editor/theming/overview.mdx b/apps/docs/editor/theming/overview.mdx index 7872f4a3c0..058c315272 100644 --- a/apps/docs/editor/theming/overview.mdx +++ b/apps/docs/editor/theming/overview.mdx @@ -19,6 +19,22 @@ document.documentElement.classList.add(theme); Five properties theme the entire UI. +## Optional layered stylesheet + +Default usage remains: + +```javascript +import 'superdoc/style.css'; +``` + +If your application uses CSS cascade layers and you want explicit layer ordering, use: + +```css +@layer reset, superdoc, app; +@import 'superdoc/style.layered.css'; +@import 'your-app.css' layer(app); +``` + ## `createTheme()` Pass a config object, get back a CSS class name. Apply it to ``. diff --git a/apps/docs/getting-started/theming.mdx b/apps/docs/getting-started/theming.mdx index 94d065f900..ca2ee29385 100644 --- a/apps/docs/getting-started/theming.mdx +++ b/apps/docs/getting-started/theming.mdx @@ -33,6 +33,22 @@ new SuperDoc({ selector: '#editor', document: 'contract.docx' }); Toolbar buttons, comments sidebar, dropdowns, context menu, search bar, dialog surfaces. Any chrome SuperDoc renders. The document content itself (paragraphs, headings, tables) renders with its own styles from the DOCX. +## Optional layered stylesheet + +By default, you can keep using: + +```javascript +import 'superdoc/style.css'; +``` + +If your app uses cascade layers and you want SuperDoc styles in a named layer, use the optional layered entrypoint: + +```css +@layer reset, superdoc, app; +@import 'superdoc/style.layered.css'; +@import 'your-app.css' layer(app); +``` + ## When to drop to CSS variables `createTheme()` covers the common case. For component-level overrides, use the `vars` option or set raw `--sd-*` variables in your stylesheet: diff --git a/packages/superdoc/AGENTS.md b/packages/superdoc/AGENTS.md index 46498dd4c4..f90de5497f 100644 --- a/packages/superdoc/AGENTS.md +++ b/packages/superdoc/AGENTS.md @@ -134,6 +134,19 @@ const theme = createTheme({ document.documentElement.classList.add(theme); ``` +CSS entrypoints: + +- `superdoc/style.css` — standard stylesheet. +- `superdoc/style.layered.css` — optional layered stylesheet wrapped in `@layer superdoc`. + +Recommended layered setup: + +```css +@layer reset, superdoc, app; +@import 'superdoc/style.layered.css'; +@import 'your-app.css' layer(app); +``` + Docs: https://docs.superdoc.dev/getting-started/theming ## Document Engine — programmatic access diff --git a/packages/superdoc/package.json b/packages/superdoc/package.json index e3f1535c9d..f63ce1daa2 100644 --- a/packages/superdoc/package.json +++ b/packages/superdoc/package.json @@ -82,7 +82,8 @@ "types": "./dist/superdoc/src/public/legacy/file-zipper.d.ts", "import": "./dist/super-editor/file-zipper.es.js" }, - "./style.css": "./dist/style.css" + "./style.css": "./dist/style.css", + "./style.layered.css": "./dist/style.layered.css" }, "types": "./dist/superdoc/src/public/index.d.ts", "typesVersions": { diff --git a/packages/superdoc/scripts/type-surface.config.cjs b/packages/superdoc/scripts/type-surface.config.cjs index 9deba19a4d..2dd646ef71 100644 --- a/packages/superdoc/scripts/type-surface.config.cjs +++ b/packages/superdoc/scripts/type-surface.config.cjs @@ -288,6 +288,7 @@ const publicContract = { ], asset: [ { subpath: './style.css', tier: 'asset', note: 'CSS bundle; no types' }, + { subpath: './style.layered.css', tier: 'asset', note: 'Layered CSS bundle; no types' }, ], deprecated: [], }; diff --git a/packages/superdoc/vite-plugin-layered-css.mjs b/packages/superdoc/vite-plugin-layered-css.mjs new file mode 100644 index 0000000000..02be65b8f8 --- /dev/null +++ b/packages/superdoc/vite-plugin-layered-css.mjs @@ -0,0 +1,32 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export default function layeredCssPlugin() { + return { + name: 'superdoc-layered-css', + writeBundle(outputOptions, bundle) { + const cssAssets = Object.entries(bundle).filter(([, chunk]) => { + return chunk.type === 'asset' && typeof chunk.fileName === 'string' && chunk.fileName.endsWith('.css'); + }); + + if (cssAssets.length === 0) { + return; + } + + const targetAsset = + cssAssets.find(([fileName]) => fileName === 'style.css') ?? + cssAssets[0]; + + const [, chunk] = targetAsset; + const source = typeof chunk.source === 'string' + ? chunk.source + : Buffer.from(chunk.source).toString('utf8'); + + const layeredCss = `@layer superdoc{${source}}\n`; + const outDir = outputOptions.dir ?? path.dirname(outputOptions.file ?? ''); + const layeredFilePath = path.join(outDir, 'style.layered.css'); + + fs.writeFileSync(layeredFilePath, layeredCss); + }, + }; +} diff --git a/packages/superdoc/vite.config.cdn.js b/packages/superdoc/vite.config.cdn.js index c797e18df2..d069c53d72 100644 --- a/packages/superdoc/vite.config.cdn.js +++ b/packages/superdoc/vite.config.cdn.js @@ -2,6 +2,7 @@ import vue from '@vitejs/plugin-vue'; import { defineConfig } from 'vite'; import { version } from './package.json'; import { getAliases } from './vite.config.js'; +import layeredCssPlugin from './vite-plugin-layered-css.mjs'; // Standalone browser bundle for CDN / diff --git a/packages/super-editor/src/editors/v1/components/toolbar/SearchInput.vue b/packages/super-editor/src/editors/v1/components/toolbar/SearchInput.vue index 55d7b85dab..d650e172fd 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/SearchInput.vue +++ b/packages/super-editor/src/editors/v1/components/toolbar/SearchInput.vue @@ -17,7 +17,7 @@ const handleSubmit = () => { @@ -57,15 +57,15 @@ const handleSubmit = () => { } } - .row { + .sd-row { display: flex; - &.submit { + &.sd-submit { margin-top: 10px; flex-direction: row-reverse; } } - .submit-btn { + .sd-submit-btn { display: flex; justify-content: center; align-items: center; diff --git a/packages/super-editor/src/editors/v1/components/toolbar/StyleButtonsList.vue b/packages/super-editor/src/editors/v1/components/toolbar/StyleButtonsList.vue index ae52363778..6417f0458a 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/StyleButtonsList.vue +++ b/packages/super-editor/src/editors/v1/components/toolbar/StyleButtonsList.vue @@ -79,8 +79,8 @@ onMounted(() => {
{ padding: 8px; box-sizing: border-box; - .button-icon { + .sd-button-icon { cursor: pointer; padding: 5px; font-size: var(--sd-ui-font-size-600, 16px); @@ -123,16 +123,16 @@ onMounted(() => { fill: currentColor; } - &.selected { + &.sd-selected { background-color: var(--sd-ui-dropdown-active-bg, #d8dee5); color: var(--sd-ui-dropdown-selected-text, #47484a); } } &.high-contrast { - .button-icon { + .sd-button-icon { &:hover, - &.selected { + &.sd-selected { background-color: #000; color: #fff; } diff --git a/packages/super-editor/src/editors/v1/components/toolbar/TableGrid.vue b/packages/super-editor/src/editors/v1/components/toolbar/TableGrid.vue index 5aa1b591be..e48e957d98 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/TableGrid.vue +++ b/packages/super-editor/src/editors/v1/components/toolbar/TableGrid.vue @@ -35,9 +35,9 @@ const selectGridItems = (allItems, cols, rows) => { let itemsRows = parseInt(item.dataset.rows, 10); if (itemsCols <= cols && itemsRows <= rows) { - item.classList.add('selected'); + item.classList.add('sd-selected'); } else { - item.classList.remove('selected'); + item.classList.remove('sd-selected'); } } }; @@ -162,7 +162,7 @@ onMounted(() => { transition: all 0.15s; } - .toolbar-table-grid__item.selected { + .toolbar-table-grid__item.sd-selected { background-color: var(--sd-ui-dropdown-hover-bg, #d8dee5); } @@ -171,7 +171,7 @@ onMounted(() => { border-color: #000; } - .toolbar-table-grid__item.selected { + .toolbar-table-grid__item.sd-selected { background: #000; } } diff --git a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue index d6dc0ca991..82e5165afa 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue +++ b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue @@ -138,6 +138,7 @@ const handleToolbarMousedown = (e) => { :key="toolbarKey" role="toolbar" aria-label="Toolbar" + data-sd-part="toolbar" data-editor-ui-surface @mousedown="handleToolbarMousedown" > diff --git a/packages/super-editor/src/editors/v1/components/toolbar/ToolbarButton.vue b/packages/super-editor/src/editors/v1/components/toolbar/ToolbarButton.vue index d1e9e7338f..f73ec83b19 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/ToolbarButton.vue +++ b/packages/super-editor/src/editors/v1/components/toolbar/ToolbarButton.vue @@ -132,19 +132,20 @@ const caretIcon = computed(() => { diff --git a/packages/super-editor/src/editors/v1/components/toolbar/ToolbarButtonIcon.vue b/packages/super-editor/src/editors/v1/components/toolbar/ToolbarButtonIcon.vue index d0f6739150..dcc9289a03 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/ToolbarButtonIcon.vue +++ b/packages/super-editor/src/editors/v1/components/toolbar/ToolbarButtonIcon.vue @@ -27,21 +27,21 @@ const hasColorBar = computed(() => { diff --git a/packages/superdoc/src/components/CommentsLayer/CommentsDropdown.vue b/packages/superdoc/src/components/CommentsLayer/CommentsDropdown.vue index a6bbd3d911..58565f330f 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentsDropdown.vue +++ b/packages/superdoc/src/components/CommentsLayer/CommentsDropdown.vue @@ -150,7 +150,7 @@ onBeforeUnmount(() => { v-for="option in options" :key="option.key" class="comments-dropdown__option" - :class="{ disabled: option.disabled }" + :class="{ 'sd-disabled': option.disabled }" @click="onOptionClick(option)" > @@ -201,7 +201,7 @@ onBeforeUnmount(() => { color: var(--sd-ui-comments-option-hover-text, #212121); } -.comments-dropdown__option.disabled { +.comments-dropdown__option.sd-disabled { opacity: 0.5; pointer-events: none; } diff --git a/packages/superdoc/src/components/CommentsLayer/InternalDropdown.vue b/packages/superdoc/src/components/CommentsLayer/InternalDropdown.vue index 38ecc5c3f3..2dd952251e 100644 --- a/packages/superdoc/src/components/CommentsLayer/InternalDropdown.vue +++ b/packages/superdoc/src/components/CommentsLayer/InternalDropdown.vue @@ -76,36 +76,36 @@ onMounted(() => { diff --git a/tests/behavior/tests/comments/reject-format-suggestion.spec.ts b/tests/behavior/tests/comments/reject-format-suggestion.spec.ts index d0b23b4d85..ce194ec951 100644 --- a/tests/behavior/tests/comments/reject-format-suggestion.spec.ts +++ b/tests/behavior/tests/comments/reject-format-suggestion.spec.ts @@ -172,7 +172,7 @@ for (const tc of ALL_CASES) { if (tc.restoredFontFamily) { await superdoc.selectAll(); await superdoc.waitForStable(); - await expect(superdoc.page.locator('[data-item="btn-fontFamily"] .button-label')).toHaveText( + await expect(superdoc.page.locator('[data-item="btn-fontFamily"] .sd-button-label')).toHaveText( tc.restoredFontFamily, ); } diff --git a/tests/behavior/tests/formatting/apply-font.spec.ts b/tests/behavior/tests/formatting/apply-font.spec.ts index 1b5926f3a5..c94a280327 100644 --- a/tests/behavior/tests/formatting/apply-font.spec.ts +++ b/tests/behavior/tests/formatting/apply-font.spec.ts @@ -33,7 +33,7 @@ test('apply Courier New font to selected text in loaded document', async ({ supe await superdoc.assertTextContains(originalText.substring(0, 20)); // Verify font applied via toolbar state for the current selection. - await expect(superdoc.page.locator('[data-item="btn-fontFamily"] .button-label')).toHaveText('Courier New'); + await expect(superdoc.page.locator('[data-item="btn-fontFamily"] .sd-button-label')).toHaveText('Courier New'); await superdoc.snapshot('apply-font-courier'); }); diff --git a/tests/behavior/tests/formatting/toggle-formatting-off.spec.ts b/tests/behavior/tests/formatting/toggle-formatting-off.spec.ts index 21315c3a52..88a7142529 100644 --- a/tests/behavior/tests/formatting/toggle-formatting-off.spec.ts +++ b/tests/behavior/tests/formatting/toggle-formatting-off.spec.ts @@ -46,8 +46,8 @@ test('toggle bold off retains other formatting', async ({ superdoc }) => { await superdoc.type('hello italic'); await superdoc.waitForStable(); - await expect(superdoc.page.locator('[data-item="btn-italic"]')).toHaveClass(/active/); - await expect(superdoc.page.locator('[data-item="btn-bold"]')).not.toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-italic"]')).toHaveClass(/sd-active/); + await expect(superdoc.page.locator('[data-item="btn-bold"]')).not.toHaveClass(/sd-active/); await superdoc.snapshot('toggle-formatting-off'); }); diff --git a/tests/behavior/tests/lists/bullet-style-export.spec.ts b/tests/behavior/tests/lists/bullet-style-export.spec.ts index 799b50b831..ee57be67c6 100644 --- a/tests/behavior/tests/lists/bullet-style-export.spec.ts +++ b/tests/behavior/tests/lists/bullet-style-export.spec.ts @@ -3,7 +3,7 @@ import JSZip from 'jszip'; test.use({ config: { toolbar: 'full' } }); -const BULLET_DROPDOWN_CARET = '[aria-label="Bullet list"] .dropdown-caret'; +const BULLET_DROPDOWN_CARET = '[aria-label="Bullet list"] .sd-dropdown-caret'; const STYLE_OPTION = (label: string) => `.style-buttons-list [aria-label="${label}"]`; const STYLE_LABEL = { diff --git a/tests/behavior/tests/lists/bullet-style-picker.spec.ts b/tests/behavior/tests/lists/bullet-style-picker.spec.ts index 2e86946b07..da0c0f2830 100644 --- a/tests/behavior/tests/lists/bullet-style-picker.spec.ts +++ b/tests/behavior/tests/lists/bullet-style-picker.spec.ts @@ -3,7 +3,7 @@ import { LIST_MARKER_SELECTOR, getParagraphNumberingByText } from '../../helpers test.use({ config: { toolbar: 'full' } }); -const BULLET_DROPDOWN_CARET = '[aria-label="Bullet list"] .dropdown-caret'; +const BULLET_DROPDOWN_CARET = '[aria-label="Bullet list"] .sd-dropdown-caret'; const STYLE_OPTION = (label: string) => `.style-buttons-list [aria-label="${label}"]`; const STYLE_LABEL = { diff --git a/tests/behavior/tests/lists/bullet-style-undo-redo.spec.ts b/tests/behavior/tests/lists/bullet-style-undo-redo.spec.ts index f6b70d9a9d..c880452eb1 100644 --- a/tests/behavior/tests/lists/bullet-style-undo-redo.spec.ts +++ b/tests/behavior/tests/lists/bullet-style-undo-redo.spec.ts @@ -2,7 +2,7 @@ import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js'; test.use({ config: { toolbar: 'full' } }); -const BULLET_DROPDOWN_CARET = '[aria-label="Bullet list"] .dropdown-caret'; +const BULLET_DROPDOWN_CARET = '[aria-label="Bullet list"] .sd-dropdown-caret'; const STYLE_OPTION = (label: string) => `.style-buttons-list [aria-label="${label}"]`; const STYLE_LABEL = { diff --git a/tests/behavior/tests/lists/list-style-changes.spec.ts b/tests/behavior/tests/lists/list-style-changes.spec.ts index b23f33a6ff..6a564f7be5 100644 --- a/tests/behavior/tests/lists/list-style-changes.spec.ts +++ b/tests/behavior/tests/lists/list-style-changes.spec.ts @@ -73,8 +73,8 @@ test.describe('PR-2873 list style changes', () => { await superdoc.waitForStable(); await placeCursorIn(superdoc, 'item'); - await expect(superdoc.page.locator('[data-item="btn-list"]').first()).toHaveClass(/active/); - await expect(superdoc.page.locator('[data-item="btn-numberedlist"]').first()).not.toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-list"]').first()).toHaveClass(/sd-active/); + await expect(superdoc.page.locator('[data-item="btn-numberedlist"]').first()).not.toHaveClass(/sd-active/); }); test('numbered list button is active when caret is in an ordered list', async ({ superdoc }) => { @@ -83,16 +83,16 @@ test.describe('PR-2873 list style changes', () => { await superdoc.waitForStable(); await placeCursorIn(superdoc, 'item'); - await expect(superdoc.page.locator('[data-item="btn-numberedlist"]').first()).toHaveClass(/active/); - await expect(superdoc.page.locator('[data-item="btn-list"]').first()).not.toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-numberedlist"]').first()).toHaveClass(/sd-active/); + await expect(superdoc.page.locator('[data-item="btn-list"]').first()).not.toHaveClass(/sd-active/); }); test('neither button is active when caret is on a plain paragraph', async ({ superdoc }) => { await superdoc.type('plain'); await superdoc.waitForStable(); - await expect(superdoc.page.locator('[data-item="btn-list"]').first()).not.toHaveClass(/active/); - await expect(superdoc.page.locator('[data-item="btn-numberedlist"]').first()).not.toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-list"]').first()).not.toHaveClass(/sd-active/); + await expect(superdoc.page.locator('[data-item="btn-numberedlist"]').first()).not.toHaveClass(/sd-active/); }); }); diff --git a/tests/behavior/tests/tables/add-row-formatting.spec.ts b/tests/behavior/tests/tables/add-row-formatting.spec.ts index 3ca0b8dc0d..1686e26d51 100644 --- a/tests/behavior/tests/tables/add-row-formatting.spec.ts +++ b/tests/behavior/tests/tables/add-row-formatting.spec.ts @@ -11,7 +11,7 @@ test('adding a row after bold cell preserves formatting in new row', async ({ su await superdoc.type('Bold header'); await superdoc.waitForStable(); - await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/sd-active/); // Add a row after the current one await superdoc.executeCommand('addRowAfter'); @@ -26,5 +26,5 @@ test('adding a row after bold cell preserves formatting in new row', async ({ su await superdoc.assertTextContains('Bold header'); // The new text inherits bold from the row it was cloned from. - await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/sd-active/); }); diff --git a/tests/behavior/tests/toolbar/basic-styles.spec.ts b/tests/behavior/tests/toolbar/basic-styles.spec.ts index 938f394ff0..92e3b96499 100644 --- a/tests/behavior/tests/toolbar/basic-styles.spec.ts +++ b/tests/behavior/tests/toolbar/basic-styles.spec.ts @@ -28,7 +28,7 @@ test('bold button applies bold', async ({ superdoc }) => { await boldButton.click(); await superdoc.waitForStable(); - await expect(boldButton).toHaveClass(/active/); + await expect(boldButton).toHaveClass(/sd-active/); await superdoc.snapshot('bold applied'); await superdoc.assertTextHasMarks('is a sentence', ['bold']); @@ -42,7 +42,7 @@ test('italic button applies italic', async ({ superdoc }) => { await italicButton.click(); await superdoc.waitForStable(); - await expect(italicButton).toHaveClass(/active/); + await expect(italicButton).toHaveClass(/sd-active/); await superdoc.snapshot('italic applied'); await superdoc.assertTextHasMarks('is a sentence', ['italic']); @@ -56,7 +56,7 @@ test('underline button applies underline', async ({ superdoc }) => { await underlineButton.click(); await superdoc.waitForStable(); - await expect(underlineButton).toHaveClass(/active/); + await expect(underlineButton).toHaveClass(/sd-active/); await superdoc.snapshot('underline applied'); await superdoc.assertTextHasMarks('is a sentence', ['underline']); @@ -70,7 +70,7 @@ test('strikethrough button applies strike', async ({ superdoc }) => { await strikeButton.click(); await superdoc.waitForStable(); - await expect(strikeButton).toHaveClass(/active/); + await expect(strikeButton).toHaveClass(/sd-active/); await superdoc.snapshot('strikethrough applied'); await superdoc.assertTextHasMarks('is a sentence', ['strike']); @@ -92,7 +92,7 @@ test('font family dropdown changes font', async ({ superdoc }) => { await superdoc.waitForStable(); // Assert the toolbar displays "Georgia" - await expect(fontButton.locator('.button-label')).toHaveText('Georgia'); + await expect(fontButton.locator('.sd-button-label')).toHaveText('Georgia'); await superdoc.snapshot('Georgia font applied'); await superdoc.assertTextMarkAttrs('is a sentence', 'textStyle', { fontFamily: 'Georgia' }); @@ -132,7 +132,7 @@ test('color dropdown changes text color', async ({ superdoc }) => { await superdoc.snapshot('color dropdown open'); // Click the red color swatch (#D2003F) - const redSwatch = superdoc.page.locator('.option[aria-label="red"]').first(); + const redSwatch = superdoc.page.locator('.sd-option[aria-label="red"]').first(); await redSwatch.click(); await superdoc.waitForStable(); @@ -155,7 +155,7 @@ test('highlight dropdown changes background color', async ({ superdoc }) => { await superdoc.snapshot('highlight dropdown open'); // Click a highlight color swatch (#ECCF35) - const yellowSwatch = superdoc.page.locator('.option[aria-label="yellow"]').first(); + const yellowSwatch = superdoc.page.locator('.sd-option[aria-label="yellow"]').first(); await yellowSwatch.click(); await superdoc.waitForStable(); diff --git a/tests/behavior/tests/toolbar/composite-styles.spec.ts b/tests/behavior/tests/toolbar/composite-styles.spec.ts index 35a9397d72..63d8445bb3 100644 --- a/tests/behavior/tests/toolbar/composite-styles.spec.ts +++ b/tests/behavior/tests/toolbar/composite-styles.spec.ts @@ -28,7 +28,7 @@ async function selectDropdownOption(superdoc: SuperDocFixture, dataItem: string, async function selectColorSwatch(superdoc: SuperDocFixture, dataItem: string, label: string): Promise { await superdoc.page.locator(`[data-item="btn-${dataItem}"]`).click(); await superdoc.waitForStable(); - await superdoc.page.locator(`.option[aria-label="${label}"]`).first().click(); + await superdoc.page.locator(`.sd-option[aria-label="${label}"]`).first().click(); await superdoc.waitForStable(); } @@ -41,8 +41,8 @@ test('bold + italic', async ({ superdoc }) => { await clickToolbarButton(superdoc, 'bold'); await clickToolbarButton(superdoc, 'italic'); - await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/active/); - await expect(superdoc.page.locator('[data-item="btn-italic"]')).toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/sd-active/); + await expect(superdoc.page.locator('[data-item="btn-italic"]')).toHaveClass(/sd-active/); await superdoc.snapshot('bold + italic applied'); await superdoc.assertTextHasMarks('is a sentence', ['bold', 'italic']); @@ -55,8 +55,8 @@ test('bold + underline', async ({ superdoc }) => { await clickToolbarButton(superdoc, 'bold'); await clickToolbarButton(superdoc, 'underline'); - await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/active/); - await expect(superdoc.page.locator('[data-item="btn-underline"]')).toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/sd-active/); + await expect(superdoc.page.locator('[data-item="btn-underline"]')).toHaveClass(/sd-active/); await superdoc.snapshot('bold + underline applied'); await superdoc.assertTextHasMarks('is a sentence', ['bold', 'underline']); @@ -69,8 +69,8 @@ test('italic + strikethrough', async ({ superdoc }) => { await clickToolbarButton(superdoc, 'italic'); await clickToolbarButton(superdoc, 'strike'); - await expect(superdoc.page.locator('[data-item="btn-italic"]')).toHaveClass(/active/); - await expect(superdoc.page.locator('[data-item="btn-strike"]')).toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-italic"]')).toHaveClass(/sd-active/); + await expect(superdoc.page.locator('[data-item="btn-strike"]')).toHaveClass(/sd-active/); await superdoc.snapshot('italic + strikethrough applied'); await superdoc.assertTextHasMarks('is a sentence', ['italic', 'strike']); @@ -87,10 +87,10 @@ test('bold + italic + underline + strikethrough', async ({ superdoc }) => { await clickToolbarButton(superdoc, 'underline'); await clickToolbarButton(superdoc, 'strike'); - await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/active/); - await expect(superdoc.page.locator('[data-item="btn-italic"]')).toHaveClass(/active/); - await expect(superdoc.page.locator('[data-item="btn-underline"]')).toHaveClass(/active/); - await expect(superdoc.page.locator('[data-item="btn-strike"]')).toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/sd-active/); + await expect(superdoc.page.locator('[data-item="btn-italic"]')).toHaveClass(/sd-active/); + await expect(superdoc.page.locator('[data-item="btn-underline"]')).toHaveClass(/sd-active/); + await expect(superdoc.page.locator('[data-item="btn-strike"]')).toHaveClass(/sd-active/); await superdoc.snapshot('all four toggles applied'); await superdoc.assertTextHasMarks('is a sentence', ['bold', 'italic', 'underline', 'strike']); @@ -106,8 +106,8 @@ test('bold + font family + font size', async ({ superdoc }) => { await selectDropdownOption(superdoc, 'fontFamily', 'Georgia'); await selectDropdownOption(superdoc, 'fontSize', '24'); - await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/active/); - await expect(superdoc.page.locator('[data-item="btn-fontFamily"] .button-label')).toHaveText('Georgia'); + await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/sd-active/); + await expect(superdoc.page.locator('[data-item="btn-fontFamily"] .sd-button-label')).toHaveText('Georgia'); await expect(superdoc.page.locator('#inlineTextInput-fontSize')).toHaveValue('24'); await superdoc.snapshot('bold + Georgia 24pt applied'); @@ -123,7 +123,7 @@ test('italic + color', async ({ superdoc }) => { await clickToolbarButton(superdoc, 'italic'); await selectColorSwatch(superdoc, 'color', 'red'); - await expect(superdoc.page.locator('[data-item="btn-italic"]')).toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-italic"]')).toHaveClass(/sd-active/); const colorBar = superdoc.page.locator('[data-item="btn-color"] .color-bar'); await expect(colorBar).toHaveCSS('background-color', 'rgb(210, 0, 63)'); await superdoc.snapshot('italic + red color applied'); @@ -142,7 +142,7 @@ test('font family + font size + color', async ({ superdoc }) => { await selectDropdownOption(superdoc, 'fontSize', '18'); await selectColorSwatch(superdoc, 'color', 'dark red'); - await expect(superdoc.page.locator('[data-item="btn-fontFamily"] .button-label')).toHaveText('Georgia'); + await expect(superdoc.page.locator('[data-item="btn-fontFamily"] .sd-button-label')).toHaveText('Georgia'); await expect(superdoc.page.locator('#inlineTextInput-fontSize')).toHaveValue('18'); const colorBar = superdoc.page.locator('[data-item="btn-color"] .color-bar'); await expect(colorBar).toHaveCSS('background-color', 'rgb(134, 0, 40)'); @@ -173,11 +173,11 @@ test('all styles combined', async ({ superdoc }) => { await selectColorSwatch(superdoc, 'highlight', 'yellow'); // Assert all toolbar button states - await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/active/); - await expect(superdoc.page.locator('[data-item="btn-italic"]')).toHaveClass(/active/); - await expect(superdoc.page.locator('[data-item="btn-underline"]')).toHaveClass(/active/); - await expect(superdoc.page.locator('[data-item="btn-strike"]')).toHaveClass(/active/); - await expect(superdoc.page.locator('[data-item="btn-fontFamily"] .button-label')).toHaveText('Courier New'); + await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/sd-active/); + await expect(superdoc.page.locator('[data-item="btn-italic"]')).toHaveClass(/sd-active/); + await expect(superdoc.page.locator('[data-item="btn-underline"]')).toHaveClass(/sd-active/); + await expect(superdoc.page.locator('[data-item="btn-strike"]')).toHaveClass(/sd-active/); + await expect(superdoc.page.locator('[data-item="btn-fontFamily"] .sd-button-label')).toHaveText('Courier New'); await expect(superdoc.page.locator('#inlineTextInput-fontSize')).toHaveValue('24'); const colorBar = superdoc.page.locator('[data-item="btn-color"] .color-bar'); await expect(colorBar).toHaveCSS('background-color', 'rgb(210, 0, 63)'); diff --git a/tests/behavior/tests/toolbar/link.spec.ts b/tests/behavior/tests/toolbar/link.spec.ts index d4ea8c695d..aef99cb455 100644 --- a/tests/behavior/tests/toolbar/link.spec.ts +++ b/tests/behavior/tests/toolbar/link.spec.ts @@ -140,7 +140,7 @@ test('link is not editable in viewing mode', async ({ superdoc }) => { // Link toolbar button should be disabled in viewing mode const linkButton = superdoc.page.locator('[data-item="btn-link"]'); - await expect(linkButton).toHaveClass(/disabled/); + await expect(linkButton).toHaveClass(/sd-disabled/); // Stub window.open so we can assert navigation without depending on popup handling await superdoc.page.evaluate(() => { diff --git a/tests/behavior/tests/toolbar/responsive-container-overflow.spec.ts b/tests/behavior/tests/toolbar/responsive-container-overflow.spec.ts index eb3bdc9a38..4a5d2b7603 100644 --- a/tests/behavior/tests/toolbar/responsive-container-overflow.spec.ts +++ b/tests/behavior/tests/toolbar/responsive-container-overflow.spec.ts @@ -37,7 +37,7 @@ test('toolbar buttons stay inside the container when it narrows (SD-2328)', asyn const container = document.getElementById('toolbar'); if (!container) return null; const containerRect = container.getBoundingClientRect(); - const items = Array.from(container.querySelectorAll('.button-group > .toolbar-item-ctn')); + const items = Array.from(container.querySelectorAll('.button-group > .sd-toolbar-item-ctn')); const overflowing = items .map((el) => { const rect = (el as HTMLElement).getBoundingClientRect(); diff --git a/tests/behavior/tests/toolbar/table-styles.spec.ts b/tests/behavior/tests/toolbar/table-styles.spec.ts index ba8f47af35..70c2997428 100644 --- a/tests/behavior/tests/toolbar/table-styles.spec.ts +++ b/tests/behavior/tests/toolbar/table-styles.spec.ts @@ -25,7 +25,7 @@ test('bold inside a table cell', async ({ superdoc }) => { await boldButton.click(); await superdoc.waitForStable(); - await expect(boldButton).toHaveClass(/active/); + await expect(boldButton).toHaveClass(/sd-active/); await superdoc.snapshot('bold applied in cell'); await superdoc.assertTextHasMarks('table text', ['bold']); @@ -46,12 +46,12 @@ test('multiple styles in one cell', async ({ superdoc }) => { // Open color dropdown and pick red await superdoc.page.locator('[data-item="btn-color"]').click(); await superdoc.waitForStable(); - await superdoc.page.locator('.option[aria-label="red"]').first().click(); + await superdoc.page.locator('.sd-option[aria-label="red"]').first().click(); await superdoc.waitForStable(); // Assert all toolbar states - await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/active/); - await expect(superdoc.page.locator('[data-item="btn-italic"]')).toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/sd-active/); + await expect(superdoc.page.locator('[data-item="btn-italic"]')).toHaveClass(/sd-active/); const colorBar = superdoc.page.locator('[data-item="btn-color"] .color-bar'); await expect(colorBar).toHaveCSS('background-color', 'rgb(210, 0, 63)'); await superdoc.snapshot('bold + italic + red color applied'); @@ -117,7 +117,7 @@ test('font family and size in a table cell', async ({ superdoc }) => { await superdoc.waitForStable(); // Assert toolbar - await expect(superdoc.page.locator('[data-item="btn-fontFamily"] .button-label')).toHaveText('Georgia'); + await expect(superdoc.page.locator('[data-item="btn-fontFamily"] .sd-button-label')).toHaveText('Georgia'); await expect(superdoc.page.locator('#inlineTextInput-fontSize')).toHaveValue('24'); await superdoc.snapshot('Georgia 24pt applied in cell'); diff --git a/tests/behavior/tests/toolbar/undo-redo.spec.ts b/tests/behavior/tests/toolbar/undo-redo.spec.ts index d4d6b62fce..31dcea4bfd 100644 --- a/tests/behavior/tests/toolbar/undo-redo.spec.ts +++ b/tests/behavior/tests/toolbar/undo-redo.spec.ts @@ -13,11 +13,11 @@ async function clickBodySurface(page: Page) { async function expectToolbarButtonDisabledState(button: Locator, disabled: boolean) { if (disabled) { - await expect(button).toHaveClass(/disabled/); + await expect(button).toHaveClass(/sd-disabled/); return; } - await expect(button).not.toHaveClass(/disabled/); + await expect(button).not.toHaveClass(/sd-disabled/); } test('undo button removes last typed text', async ({ superdoc }) => { From 989f0604152b61205f9d99a13703f10b6d25fe0d Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 29 May 2026 19:28:38 -0300 Subject: [PATCH 08/11] feat(demo): contract-templates as a clause + field library on locked SDTs Reframe the contract-templates demo as a building-block library on a locked template surface, driven entirely through the public superdoc/ui + editor.doc.* API with chrome:'none'. This shows the legal-tech workflow: assemble a contract from governed, reusable Word content controls whose variables stay consistent. - Enable the formatting toolbar and center the editor. Fold Clauses into a Template tab; the sidebar is now Template (build) + Values (fill). - Template tab is a catalog: smart-field chips and clause cards (each with category / jurisdiction / version and a "used N times" count, plus a library-only Indemnification clause). Drag or click to insert; a field goes inline at the caret, a clause snaps to a block boundary. Inserts resolve the drop point with ui.viewport.positionAt. - Every control is contentLocked, so it can't be edited by typing. Fields show their name token (e.g. DISCLOSING_PARTY) as a placeholder. Values are filled only through the Values form, which broadcasts to every occurrence - including ones nested in a locked clause (the write briefly unlocks clauses, since a clause's content lock otherwise silently vetoes nested writes). - Clauses are assembled from structured parts (prose + {field} slots): inserting one wraps each slot as a nested, locked inline smart field, so an inserted Permitted Use carries real Receiving party / Purpose fields like the seeded one. - Remove the clause version review/replace lifecycle (out of scope here; it's a separate clause-lifecycle demo). Drop the floating field chip earlier in the arc. - Rewrite the README and file header to the library model; add tests for locking, nested-clause broadcast, clause insert, and inserted-clause field nesting. --- .../contract-templates-focus.spec.ts | 80 +- .../contract-templates-locate.spec.ts | 79 -- .../contract-templates-smart-tags.spec.ts | 231 +++++- demos/contract-templates/README.md | 44 +- demos/contract-templates/index.html | 7 +- demos/contract-templates/src/main.ts | 781 ++++++++++++------ demos/contract-templates/src/style.css | 144 ++-- 7 files changed, 846 insertions(+), 520 deletions(-) delete mode 100644 demos/__tests__/contract-templates-locate.spec.ts diff --git a/demos/__tests__/contract-templates-focus.spec.ts b/demos/__tests__/contract-templates-focus.spec.ts index 8520649355..87e11a3b81 100644 --- a/demos/__tests__/contract-templates-focus.spec.ts +++ b/demos/__tests__/contract-templates-focus.spec.ts @@ -25,7 +25,8 @@ test('clicking a field Focus places the caret inside that control', async ({ pag { timeout: 30_000 }, ); - // Fields tab is the default; the Focus buttons live on field rows. + // Field value rows (with the Focus buttons) live on the Values tab. + await page.click('.tab[data-tab="values"]'); await page.waitForSelector('[data-focus-field]'); const key = await page.getAttribute('[data-focus-field]', 'data-focus-field'); expect(key).toBeTruthy(); @@ -62,80 +63,3 @@ test('clicking a field Focus places the caret inside that control', async ({ pag // After focus, the caret lands inside a control whose tag carries this key. await expect.poll(controlKeyAtSelection, { timeout: 5_000 }).toBe(key); }); - -test('focusing an off-screen clause scrolls it in AND lands the caret inside it', async ({ page }) => { - test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only'); - - await page.route('**/ingest.superdoc.dev/**', (r) => - r.fulfill({ status: 204, contentType: 'application/json', body: '{}' }), - ); - await page.goto('/'); - await page.waitForFunction( - () => (window as any).__demo?.state?.ui?.contentControls?.getSnapshot()?.items?.length > 0, - null, - { timeout: 30_000 }, - ); - - // Clause Focus buttons live in the (initially hidden) clauses panel. - await page.click('.tab[data-tab="clauses"]'); - await page.waitForSelector('[data-focus-clause]'); - - // Bottom-most block clause: its painted id + sectionId (= the button's data attr). - const target = await page.evaluate(() => { - const ui = (window as any).__demo.state.ui; - const blocks = ui.contentControls.getSnapshot().items.filter((i: any) => i.kind === 'block'); - const last = blocks[blocks.length - 1]; - let sectionId: string | null = null; - try { - sectionId = JSON.parse(last?.properties?.tag ?? '{}').sectionId ?? null; - } catch { - sectionId = null; - } - return { id: last?.id ?? null, sectionId }; - }); - expect(target.id).toBeTruthy(); - expect(target.sectionId).toBeTruthy(); - - // Scroll to the top so the bottom clause starts off-screen. - await page.evaluate(() => { - let node: HTMLElement | null = document.querySelector('.presentation-editor__pages'); - while (node && !(node.scrollHeight > node.clientHeight + 4)) node = node.parentElement; - if (node) node.scrollTop = 0; - else window.scrollTo(0, 0); - }); - - const state = () => - page.evaluate((id) => { - // caret's containing control id - const ed = (window as any).__demo.superdoc.activeEditor; - const from = ed?.state?.selection?.from; - let caretIn: string | null = null; - if (typeof from === 'number') { - ed.state.doc.descendants((node: any, pos: number) => { - if ( - (node.type.name === 'structuredContent' || node.type.name === 'structuredContentBlock') && - from > pos && - from < pos + node.nodeSize - ) { - caretIn = String(node.attrs.id); - } - return true; - }); - } - // is the control's painted element in the viewport? - const el = document.querySelector(`[data-sdt-id="${id}"]`); - const r = el?.getBoundingClientRect(); - const inViewport = r ? r.top >= 0 && r.top <= window.innerHeight : false; - return { caretIn, inViewport }; - }, target.id); - - // Before focus: caret is not in the bottom clause and it's off-screen. - const before = await state(); - expect(before.caretIn).not.toBe(target.id); - expect(before.inViewport).toBe(false); - - await page.click(`[data-focus-clause="${target.sectionId}"]`); - - // After focus: the control is scrolled into view AND the caret is inside it. - await expect.poll(state, { timeout: 6_000 }).toEqual({ caretIn: target.id, inViewport: true }); -}); diff --git a/demos/__tests__/contract-templates-locate.spec.ts b/demos/__tests__/contract-templates-locate.spec.ts deleted file mode 100644 index 40d9e76016..0000000000 --- a/demos/__tests__/contract-templates-locate.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { test, expect } from '@playwright/test'; - -/** - * Demo smoke for the contract-templates "Locate" affordance (dogfoods - * `ui.contentControls.scrollIntoView`): clicking a lower clause's Locate - * button scrolls that control's painted element into view. - * - * The shared suite runs once per DEMO, so this skips for every other demo. - */ - -// A short viewport so the bottom clause starts below the fold. -test.use({ viewport: { width: 1100, height: 520 } }); - -test('clicking a lower clause Locate scrolls its content control into view', async ({ page }) => { - test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only'); - - const errors: string[] = []; - page.on('pageerror', (e) => errors.push(e.message)); - page.on('console', (m) => { - if (m.type() === 'error') errors.push(m.text()); - }); - await page.route('**/ingest.superdoc.dev/**', (r) => - r.fulfill({ status: 204, contentType: 'application/json', body: '{}' }), - ); - - await page.goto('/'); - await expect(page.locator('body')).toBeVisible({ timeout: 30_000 }); - - // Wait until SuperDoc has imported the fixture and the UI handle sees controls. - await page.waitForFunction( - () => { - const ui = (window as unknown as { __demo?: { state?: { ui?: unknown } } }).__demo?.state?.ui as - | { contentControls: { getSnapshot(): { items: unknown[] } } } - | undefined; - return !!ui && ui.contentControls.getSnapshot().items.length > 0; - }, - null, - { timeout: 30_000 }, - ); - - // Locate buttons on clause cards live in the (initially hidden) clauses panel. - await page.click('.tab[data-tab="clauses"]'); - await page.waitForSelector('[data-locate-clause]'); - - // Resolve the bottom-most block clause: its painted id (data-sdt-id) and its - // sectionId (= the Locate button's data-locate-clause). - const target = await page.evaluate(() => { - const ui = (window as unknown as { __demo: { state: { ui: { contentControls: { getSnapshot(): { items: Array<{ id: string; kind: string; properties?: { tag?: string } }> } } } } } }).__demo.state.ui; - const items = ui.contentControls.getSnapshot().items; - const blocks = items.filter((i) => i.kind === 'block'); - const last = blocks[blocks.length - 1]; - let sectionId: string | null = null; - try { - sectionId = JSON.parse(last?.properties?.tag ?? '{}').sectionId ?? null; - } catch { - sectionId = null; - } - return { id: last?.id ?? null, sectionId }; - }); - expect(target.id).toBeTruthy(); - expect(target.sectionId).toBeTruthy(); - - const inViewport = () => - page.evaluate((id) => { - const el = document.querySelector(`[data-sdt-id="${id}"]`); - if (!el) return false; - const r = el.getBoundingClientRect(); - return r.top >= 0 && r.top <= window.innerHeight; - }, target.id); - - // The bottom clause starts off-screen. - expect(await inViewport()).toBe(false); - - // Click its Locate button; the document should scroll it into view. - await page.click(`[data-locate-clause="${target.sectionId}"]`); - await expect.poll(inViewport, { timeout: 5_000 }).toBe(true); - - expect(errors).toEqual([]); -}); diff --git a/demos/__tests__/contract-templates-smart-tags.spec.ts b/demos/__tests__/contract-templates-smart-tags.spec.ts index 3d5d4b7e7c..82008ad27f 100644 --- a/demos/__tests__/contract-templates-smart-tags.spec.ts +++ b/demos/__tests__/contract-templates-smart-tags.spec.ts @@ -2,10 +2,10 @@ import { test, expect } from '@playwright/test'; /** * Smart-tags authoring: clicking a tag chip in the sidebar inserts a matching - * inline SDT at the caret (dogfoods ui.selection.capture + create.contentControl - * + ui.contentControls.focus). The inserted field carries the field's tag and - * the token text, and paints with the same .superdoc-structured-content-inline - * wrapper the chips are styled to match. + * inline SDT at the caret (dogfoods ui.selection.capture + create.contentControl). + * The inserted control is created EMPTY (shows the placeholder) and contentLocked + * (values are filled only via the Values form), carries the field's tag, and + * paints with the same .superdoc-structured-content-inline wrapper the chips match. * * Runs only for the contract-templates demo (the shared suite runs once per DEMO). */ @@ -33,27 +33,22 @@ test('clicking a Smart-tags chip inserts a matching inline SDT at the caret', as expect(key).toBeTruthy(); // Count existing controls with this tag, then click the chip and expect one more. + // (The field is inserted empty/locked, so we count by tag, not by text.) const tag = JSON.stringify({ kind: 'smartField', key }); - const token = key!.replace(/([A-Z])/g, '_$1').toUpperCase(); - - const textsForTag = () => + const countForTag = () => page.evaluate((t) => { const ed = (window as any).__demo.superdoc.activeEditor; - const out: string[] = []; + let n = 0; ed.state.doc.descendants((node: any) => { - if (node.type.name === 'structuredContent' && node.attrs?.tag === t) out.push(node.textContent); + if (node.type.name === 'structuredContent' && node.attrs?.tag === t) n += 1; return true; }); - return out; + return n; }, tag); - const before = await textsForTag(); + const before = await countForTag(); await page.click(`[data-tag-key="${key}"]`); - - // A new inline SDT carrying this tag + token text should appear. - await expect - .poll(async () => (await textsForTag()).filter((x) => x === token).length, { timeout: 6_000 }) - .toBeGreaterThan(before.filter((x) => x === token).length); + await expect.poll(countForTag, { timeout: 6_000 }).toBe(before + 1); }); test('clicking an in-editor smart-field token highlights its sidebar chip', async ({ page }) => { @@ -176,3 +171,207 @@ test('a block clause keeps its amber left rail and box across hover/select (no j expect(Math.abs(state.h - rest.h)).toBeLessThanOrEqual(1); } }); + +test('smart fields are contentLocked and fill only through the Values form', async ({ page }) => { + test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only'); + + await page.route('**/ingest.superdoc.dev/**', (r) => + r.fulfill({ status: 204, contentType: 'application/json', body: '{}' }), + ); + await page.goto('/'); + await page.waitForFunction( + () => (window as any).__demo?.state?.ui?.contentControls?.getSnapshot()?.items?.length > 0, + null, + { timeout: 30_000 }, + ); + + const smartFieldLockModes = () => + page.evaluate(() => { + const doc = (window as any).__demo.doc(); + return doc.contentControls + .list({}) + .items.filter((c: any) => { + try { + return JSON.parse(c.properties?.tag ?? '{}').kind === 'smartField'; + } catch { + return false; + } + }) + .map((c: any) => c.lockMode); + }); + const textForDisclosingParty = () => + page.evaluate(() => { + const doc = (window as any).__demo.doc(); + const tag = JSON.stringify({ kind: 'smartField', key: 'disclosingParty' }); + return doc.contentControls.selectByTag({ tag }).items.map((c: any) => c.text); + }); + + // Every smart field starts contentLocked (the user can't type into them). + const before = await smartFieldLockModes(); + expect(before.length).toBeGreaterThan(0); + expect(before.every((m) => m === 'contentLocked')).toBe(true); + + // Editing through the Values form writes through the lock (unlock -> setValue + // -> relock): the field text updates even though the control is locked. Use a + // value distinct from the seeded default so the write is observable. + await page.click('.tab[data-tab="values"]'); + await page.fill('input[data-field="disclosingParty"]', 'Globex Corporation'); + await expect.poll(textForDisclosingParty, { timeout: 6_000 }).toContain('Globex Corporation'); + + // And the controls are relocked afterward, never left editable. + const after = await smartFieldLockModes(); + expect(after.every((m) => m === 'contentLocked')).toBe(true); +}); + +test('block clauses are contentLocked too', async ({ page }) => { + test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only'); + + await page.route('**/ingest.superdoc.dev/**', (r) => + r.fulfill({ status: 204, contentType: 'application/json', body: '{}' }), + ); + await page.goto('/'); + await page.waitForFunction( + () => (window as any).__demo?.state?.ui?.contentControls?.getSnapshot()?.items?.length > 0, + null, + { timeout: 30_000 }, + ); + + // Clause blocks are locked like the inline fields, so their prose can't be + // edited by typing in the document. + const clauseLockModes = await page.evaluate(() => { + const doc = (window as any).__demo.doc(); + return doc.contentControls + .list({}) + .items.filter((c: any) => { + try { + return JSON.parse(c.properties?.tag ?? '{}').kind === 'reusableSection'; + } catch { + return false; + } + }) + .map((c: any) => c.lockMode); + }); + expect(clauseLockModes.length).toBeGreaterThan(0); + expect(clauseLockModes.every((m) => m === 'contentLocked')).toBe(true); +}); + +test('a field value broadcasts to every occurrence, including one nested in a locked clause', async ({ page }) => { + test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only'); + + await page.route('**/ingest.superdoc.dev/**', (r) => + r.fulfill({ status: 204, contentType: 'application/json', body: '{}' }), + ); + await page.goto('/'); + await page.waitForFunction( + () => (window as any).__demo?.state?.ui?.contentControls?.getSnapshot()?.items?.length > 0, + null, + { timeout: 30_000 }, + ); + + // Receiving party appears twice: once in the header sentence and once nested + // inside the (locked) Permitted Use clause. The clause's content lock silently + // vetoes writes to the nested one unless the clause is unlocked around the + // write - this guards that the form value reaches BOTH occurrences. + const receivingPartyTexts = () => + page.evaluate(() => { + const doc = (window as any).__demo.doc(); + const tag = JSON.stringify({ kind: 'smartField', key: 'receivingParty' }); + return doc.contentControls.selectByTag({ tag }).items.map((c: any) => c.text); + }); + + expect((await receivingPartyTexts()).length).toBe(2); + + await page.click('.tab[data-tab="values"]'); + await page.fill('input[data-field="receivingParty"]', 'Beacon Bio'); + + await expect + .poll(async () => (await receivingPartyTexts()).filter((t) => t === 'Beacon Bio').length, { timeout: 6_000 }) + .toBe(2); +}); + +test('clicking a clause card inserts a locked block clause at the cursor', async ({ page }) => { + test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only'); + + await page.route('**/ingest.superdoc.dev/**', (r) => + r.fulfill({ status: 204, contentType: 'application/json', body: '{}' }), + ); + await page.goto('/'); + await page.waitForFunction( + () => (window as any).__demo?.state?.ui?.contentControls?.getSnapshot()?.items?.length > 0, + null, + { timeout: 30_000 }, + ); + await page.waitForSelector('.clause[data-clause-id]'); + + // Caret in the (unlocked) title so the clause inserts at a clean block boundary. + await page.evaluate(() => { + (window as any).__demo.superdoc.activeEditor.commands?.setTextSelection?.({ from: 6, to: 6 }); + }); + + const sectionId = await page.getAttribute('.clause[data-clause-id]', 'data-clause-id'); + expect(sectionId).toBeTruthy(); + + // Count controls for this clause + confirm they're all locked. + const clauseInfo = () => + page.evaluate((sid) => { + const doc = (window as any).__demo.doc(); + const items = doc.contentControls.list({}).items.filter((c: any) => { + try { + return JSON.parse(c.properties?.tag ?? '{}').sectionId === sid; + } catch { + return false; + } + }); + return { count: items.length, allLocked: items.every((c: any) => c.lockMode === 'contentLocked') }; + }, sectionId); + + const before = await clauseInfo(); + await page.click(`.clause[data-clause-id="${sectionId}"]`); + + // A new block clause for this section appears, and every occurrence is locked. + await expect.poll(async () => (await clauseInfo()).count, { timeout: 6_000 }).toBe(before.count + 1); + expect((await clauseInfo()).allLocked).toBe(true); +}); + +test('inserting Permitted Use nests real smart fields that fill from the form', async ({ page }) => { + test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only'); + + await page.route('**/ingest.superdoc.dev/**', (r) => + r.fulfill({ status: 204, contentType: 'application/json', body: '{}' }), + ); + await page.goto('/'); + await page.waitForFunction( + () => (window as any).__demo?.state?.ui?.contentControls?.getSnapshot()?.items?.length > 0, + null, + { timeout: 30_000 }, + ); + await page.waitForSelector('.clause[data-clause-id="permittedUse"]'); + + // Caret in the (unlocked) title so the clause inserts at a clean block boundary. + await page.evaluate(() => { + (window as any).__demo.superdoc.activeEditor.commands?.setTextSelection?.({ from: 6, to: 6 }); + }); + + // Count Receiving party smart fields in the document (an inline structuredContent + // whose tag carries that key) - the Permitted Use clause carries one as a slot. + const receivingPartyControls = () => + page.evaluate(() => { + const doc = (window as any).__demo.doc(); + const tag = JSON.stringify({ kind: 'smartField', key: 'receivingParty' }); + return doc.contentControls.selectByTag({ tag }).items.map((c: any) => c.text); + }); + + const before = (await receivingPartyControls()).length; // 2 seeded + await page.click('.clause[data-clause-id="permittedUse"]'); + + // Inserting the clause adds a real nested Receiving party SDT (not plain text). + await expect.poll(async () => (await receivingPartyControls()).length, { timeout: 6_000 }).toBe(before + 1); + + // Filling Receiving party in the Values form reaches every occurrence, + // including the one just nested inside the inserted clause. + await page.click('.tab[data-tab="values"]'); + await page.fill('input[data-field="receivingParty"]', 'Beacon Bio'); + await expect + .poll(async () => (await receivingPartyControls()).filter((t) => t === 'Beacon Bio').length, { timeout: 6_000 }) + .toBe(before + 1); +}); diff --git a/demos/contract-templates/README.md b/demos/contract-templates/README.md index cbd08e302c..8c0ae35184 100644 --- a/demos/contract-templates/README.md +++ b/demos/contract-templates/README.md @@ -1,21 +1,29 @@ # Contract templates -A demo of building **your own UI for Word content controls (SDT fields)** on top of SuperDoc. It turns off SuperDoc's built-in field chrome (`modules: { contentControls: { chrome: 'none' } }`) and renders its own: smart-field tokens as pills in the document, a "Smart tags" palette in the sidebar using the *same* pill look, and click-to-insert / locate / focus interactions — all on standard, Word-compatible SDTs that round-trip to `.docx`. A Mutual NDA opens with tagged smart fields and six versioned clauses; the app fills fields live, detects and replaces stale clauses, and exports a raw template or a clean final DOCX. Single-page, no backend, no framework. +Build your own UI for Word content controls (SDT fields) on top of SuperDoc. SuperDoc's built-in field chrome is off (`modules: { contentControls: { chrome: 'none' } }`), so you paint the field and clause look yourself and drive every interaction through the public surface: `editor.doc.*` and `superdoc/ui`. The document stays a real, Word-compatible `.docx` that round-trips. Single page, no backend, no framework. -The point: SuperDoc owns *how content is painted*, but with `chrome: 'none'` you own *the field's look and the surrounding UI*. You style the painted SDT wrapper, react to public events, position overlays with `getRect`, and mutate through `editor.doc.contentControls.*` — so fields in the editor can look exactly like your app. For the smallest copy-pasteable primitive, see the [tagged inline text example](../../examples/document-api/content-controls/tagged-inline-text). +The model is a locked template you assemble from a component library. A Mutual NDA opens with its fields and clauses already in place. The document is a locked surface: you can't change a control by typing in it. Instead you drag building blocks in from the sidebar and fill values through a form. Every change goes through the public API. -## What this shows +## What it shows -The starting document is a Mutual NDA at `public/nda-template.docx` with thirteen content controls already in place: seven inline plain-text controls (smart fields) and six block rich-text controls (reusable clauses). Receiving party and Purpose each appear twice — once in the header sentence and once nested inside the Permitted Use clause. Each control carries a `w:tag` with a JSON payload. On boot, SuperDoc imports the DOCX, parses the SDTs, and the demo reads field values and clause versions straight from the parsed controls. +The starting document is `public/nda-template.docx`: inline plain-text fields and six block rich-text clauses, each carrying a `w:tag` with a JSON payload (`{ kind: 'smartField', key }` or `{ kind: 'reusableSection', sectionId }`). Receiving party and Purpose appear twice, in the header sentence and nested inside the Permitted Use clause. -The flows, composed into one app: +**Locked controls.** On load, every field and clause is set to `contentLocked` (`ui.contentControls.setLockMode`). You can't change a value or a clause by typing in the document. This is the template surface; the custom UI drives all edits. -1. **Custom field look + Smart-tags authoring.** Built-in chrome is off, so the demo styles the painted SDT wrapper itself: inline smart fields render as amber token pills via CSS on `.superdoc-structured-content-inline[data-sdt-tag*='smartField']`. The sidebar "Smart tags" palette uses the same `--tag-*` token style, so a palette chip and the field it inserts look identical. Clicking a chip captures the caret (`ui.selection.capture()`), inserts an inline SDT there (`editor.doc.create.contentControl({ at, content, tag })`), then focuses it (`ui.contentControls.focus`). Clicking a token in the document highlights its chip (`content-control:click`) — the two-way loop. Each field and clause also has Locate (`ui.contentControls.scrollIntoView`) and Focus (`ui.contentControls.focus`) to jump to it, and the active field gets a contextual chip overlay positioned with `getRect` + kept anchored with `ui.viewport.observe`. -2. **Smart fields (fill).** Seven inline plain-text content controls across five field keys share a `tag` shape (`{ kind: 'smartField', key: 'disclosingParty' }`) per occurrence. They were authored as Word "Plain Text Content Controls" (`ContentControls.Add(1, range)`), so SuperDoc resolves them as `controlType: 'text'`. Edit a value in the Fields tab; every occurrence of that field updates live via `selectByTag` + per-occurrence `text.setValue`. Receiving party and Purpose appear twice (header sentence and nested inside the Permitted Use clause), so a single edit fans across both locations. -3. **Versioned reusable clauses.** Six block rich-text content controls carry `{ kind: 'reusableSection', sectionId, version }` in their tags. They were authored as Word "Rich Text Content Controls" (`ContentControls.Add(0, range)`), which produces typeless sdtPr; SuperDoc resolves them as `controlType: 'richText'` per ECMA-376 §17.5.2.26. The app reads each live version from `contentControls.list`, compares against the clause library, and surfaces a Review CTA when they diverge. Review expands a card with the current clause text alongside the library clause text plus a Replace with library clause action that calls `replaceContent` + `patch`. -4. **Export.** `superdoc.export({ exportedName, isFinalDoc, triggerDownload })` has two buttons: **Export raw DOCX** uses `isFinalDoc: false` to preserve content controls and tags for future template/library updates; **Export clean DOCX** uses `isFinalDoc: true` to flatten controls so the filled values are in place. +**Template tab, the building-block library.** Two catalogs, fields and clauses, each styled to match what it inserts: -Every mutation goes through `editor.doc.*`. The same operation set runs headless via the Node SDK and CLI. +- Smart-field chips wear the same amber token look as the in-document field (CSS on `.superdoc-structured-content-inline[data-sdt-tag*='smartField']`). Drag a chip onto the document, or click to insert it at the cursor. An unfilled field shows its field-name token (e.g. `DISCLOSING_PARTY`) as a stand-in placeholder. That token is literal text content, not a native SDT placeholder. +- Clause cards wear the same amber block look as the in-document clause and carry metadata: category, jurisdiction, version, and how many times the clause is placed ("Used 2 times"). The catalog includes clauses that aren't in the document yet. Drag a card onto the document, or click to insert it at the cursor. + +Inserts resolve the drop point with `ui.viewport.positionAt({ x, y })` and create the control with `editor.doc.create.contentControl({ kind, at, content, tag, lockMode })`. A field inserts inline at the exact caret; a clause snaps to a block boundary so it lands as a clean section instead of splitting a paragraph. Clicking a control in the document highlights its chip or card (`content-control:click`). + +A clause is assembled from structured `parts`: prose plus `{ field }` slots. Inserting "Permitted Use" creates the block and then wraps each slot as a nested, locked inline smart field, so the inserted clause carries real Receiving party and Purpose fields, just like the seeded one. Filling those fields in the Values tab updates the clause and the header sentence together. + +**Values tab, fill the fields.** Edit a value and it fans to every occurrence of that field, including the ones nested inside a locked clause. Each write briefly unlocks the clauses, sets the value (`selectByTag` + `text.setValue`), then relocks them. A clause's content lock otherwise silently vetoes writes to anything nested in it, so without this the nested occurrence would never update. The form is the only way to change a value. + +**Export.** `superdoc.export({ exportedName, isFinalDoc, triggerDownload })`: raw DOCX keeps the controls and tags; clean DOCX flattens them so the filled values are in place. + +Every mutation goes through `editor.doc.*`, so the same operations run headless via the Node SDK and CLI. ## Run @@ -24,17 +32,19 @@ pnpm install pnpm dev ``` -In the Fields tab, click a chip in the Smart tags palette to insert that field as a styled token at the cursor — it appears in the document with the same pill look as the chip. Click a token in the document and its chip highlights. Use Locate / Focus on a field or clause to jump to it (Focus also drops the cursor inside). Edit a value and it fans to every occurrence (header and nested locations). The seeded NDA ships with three clauses behind their latest versions (Confidentiality, Governing Law, Limitation of Liability); the Clauses tab shows a Review CTA on each, and expanding a card compares the in-document clause with the library version and replaces it in place. Export raw DOCX to keep the template controls, or clean DOCX for a final document with values in place. +Open the Template tab. Drag a field or clause into the document, or click one to insert it at the cursor. Switch to the Values tab and edit a value; it updates every occurrence, header and nested. Export raw DOCX to keep the controls, or clean DOCX for a final document. -## Related work +## Honest limits -If you need a **ready-made React component for authoring templates** with content controls (`{{` trigger menu, linked field groups, owner/signer field types, DOCX export), see [`@superdoc-dev/template-builder`](https://docs.superdoc.dev/solutions/template-builder/introduction). This demo focuses on the *runtime* side: an app filling and updating already-tagged regions. Template Builder focuses on the *authoring* side. +- An inserted clause is a single paragraph of prose with field slots. Multi-paragraph clauses, lists, tables, or other formatting inside a clause aren't modeled here; the slots become inline text fields. (The block control is a `richText` SDT, so richer bodies are possible; this demo just doesn't author them.) +- A drop snaps to the start of the block under the cursor, so a clause lands at a block boundary rather than at the exact pixel. +- The placeholder shown in an unfilled field is the field-name token, set as content. SuperDoc's native empty-control placeholder text is renderer-hardcoded and not settable through the API. +- Every control is `contentLocked`. The demo doesn't exercise `sdtLocked` or `sdtContentLocked`. +- Clause version review / replace (detect an outdated clause, swap in the library text) is intentionally out of scope. This demo proves template assembly, not the clause lifecycle. -## Honest limits +## Related work -- All content controls in the fixture are `unlocked`. Locked controls (`sdtLocked`, `sdtContentLocked`) are not driven programmatically here. -- Smart field values are pushed through `text.setValue` (the typed API for plain-text controls). Clause bodies are pushed through `replaceContent` because rich-text controls don't have a typed setter. -- Clause bodies in the seeded fixture are single-paragraph plain prose; the rich-text wrapper supports formatting/lists/tables when authored that way, but the demo doesn't exercise those. +If you need a ready-made React component for authoring templates with content controls (`{{` trigger menu, linked field groups, owner/signer field types, DOCX export), see [`@superdoc-dev/template-builder`](https://docs.superdoc.dev/solutions/template-builder/introduction). This demo shows how to build that kind of UI yourself on the public API. ## See also diff --git a/demos/contract-templates/index.html b/demos/contract-templates/index.html index cba7044f13..ba843bb2e7 100644 --- a/demos/contract-templates/index.html +++ b/demos/contract-templates/index.html @@ -15,15 +15,16 @@
+
diff --git a/demos/contract-templates/src/main.ts b/demos/contract-templates/src/main.ts index c6220fa1c9..8dc201f35e 100644 --- a/demos/contract-templates/src/main.ts +++ b/demos/contract-templates/src/main.ts @@ -1,40 +1,44 @@ /** - * Contract templates: a runtime workflow on Word content controls. + * Contract templates: build a contract template on Word content controls (SDTs) + * with a fully custom UI. SuperDoc's built-in SDT chrome is off + * (`modules.contentControls.chrome: 'none'`), so the demo paints the field/clause + * look itself (style.css) and drives every interaction through the public + * surface: `editor.doc.*` and `superdoc/ui` (events, viewport.positionAt, + * contentControls.scrollIntoView / focus / setLockMode). * - * The document is a Mutual NDA (`public/nda-template.docx`) - * with content controls already in place: - * - Seven inline plain-text content controls across five field keys - * (disclosing party, receiving party, effective date, purpose, term - * length). Authored via Word's `ContentControls.Add(1, range)`, so their - * `w:sdtPr` carries `` and they resolve as `controlType: 'text'`. - * Receiving party and Purpose each appear twice: once in the header - * sentence and once nested inside the Permitted Use block clause. - * - Six block rich-text content controls (Preamble, Confidentiality, - * Permitted Use, Term and Termination, Governing Law, Limitation of - * Liability). Authored via `ContentControls.Add(0, range)`, which - * produces typeless sdtPr that resolves as `controlType: 'richText'` - * per ECMA-376 §17.5.2.26. Each block carries - * `{ kind: 'reusableSection', sectionId, version }` in its tag. + * The starting document is a Mutual NDA (`public/nda-template.docx`) with + * controls already in place: + * - Inline plain-text fields across five keys (disclosing party, receiving + * party, effective date, purpose, term). Receiving party and Purpose each + * appear twice: in the header sentence and nested inside the Permitted Use + * block clause. + * - Six block rich-text clauses tagged `{ kind: 'reusableSection', sectionId }`. * - * The app: - * 1. Loads the fixture as its starting document. - * 2. Reads each field's text and each clause's version from the parsed SDTs. - * 3. Compares clause versions against the local library and surfaces a - * Review CTA on every stale clause with a one-line summary of the change. - * 4. Field inputs are reactive: typing in a value debounces by ~250ms and - * fans the new text to every occurrence via `selectByTag` + per-occurrence - * `text.setValue` (the typed API path for plain-text controls). - * 5. Review expands a card showing the in-document clause alongside the - * library version. Replace with library clause swaps body via - * `replaceContent` and bumps the tag version via `patch`. - * 6. Export has two paths: raw DOCX keeps content controls for future - * template/library updates; clean DOCX flattens controls to final values. + * The model: + * - Every control is `contentLocked`, so it can't be edited by typing in the + * document. This is a locked template surface; the custom UI drives changes. + * - Template tab = the building-block library. Two catalogs: smart-field chips + * and clause cards (each with category / jurisdiction / version and a "used + * N times" count). Drag or click a chip to insert an inline field, or a card + * to insert a block clause. An unfilled field shows its field-name token + * (e.g. DISCLOSING_PARTY) as a stand-in placeholder - literal text content, + * not a native SDT placeholder. + * - A clause is assembled from structured `parts`: prose plus `{ field }` + * slots. Inserting it creates the block and wraps each slot as a nested, + * locked inline smart field - so an inserted "Permitted Use" carries real + * Receiving party / Purpose fields, like the seeded one. + * - Values tab = fill the fields. Editing a value debounces ~250ms and fans it + * to every occurrence, including ones nested in clauses (the write briefly + * unlocks clauses, since a clause's content lock otherwise vetoes nested + * writes), via `selectByTag` + `text.setValue`. + * - Export: raw DOCX keeps the controls/tags; clean DOCX flattens to values. * - * Every mutation goes through `editor.doc.*`. The same operation set runs - * headless via the Node SDK and CLI. + * Out of scope (deliberately): clause version review / replace. That's a clause + * lifecycle demo; this one proves template assembly. * - * For a packaged React authoring component (`{{` trigger, linked field - * groups, owner/signer types, DOCX export), see `@superdoc-dev/template-builder`. + * Every mutation goes through `editor.doc.*`, so the same operations run headless + * via the Node SDK and CLI. For a packaged React authoring component, see + * `@superdoc-dev/template-builder`. */ import { SuperDoc } from 'superdoc'; @@ -70,12 +74,14 @@ type DocumentApi = { content?: string; tag?: string; alias?: string; + lockMode?: LockMode; }): MutationResult; }; contentControls: { list(input?: Record): { items: ContentControlInfo[]; total: number }; selectByTag(input: { tag: string }): { items: ContentControlInfo[]; total: number }; patch(input: { target: ContentControlTarget; tag?: string; alias?: string }): MutationResult; + setLockMode(input: { target: ContentControlTarget; lockMode: LockMode }): MutationResult; replaceContent(input: { target: ContentControlTarget; content: string; format?: 'text' }): MutationResult; text: { setValue(input: { target: ContentControlTarget; value: string }): MutationResult; @@ -106,76 +112,104 @@ type ClauseId = | 'permittedUse' | 'termination' | 'governingLaw' - | 'limitationOfLiability'; + | 'limitationOfLiability' + | 'indemnification'; -type PreviewSegment = { kind: 'same' | 'insert' | 'delete'; text: string }; +type ClauseCategory = 'Core' | 'Confidentiality' | 'Termination' | 'Risk Allocation'; +/** + * A clause-body part: literal prose, or a `{ field }` slot that becomes a nested + * inline smart-field SDT when the clause is inserted. The slot renders as the + * field's current display (value if filled, otherwise its name token). + */ +type ClausePart = string | { field: FieldKey }; + +/** + * A governed clause in the library catalog: a label + metadata for the sidebar + * card, and the structured `parts` used to assemble the clause when it's + * inserted. The catalog includes clauses that aren't in the document yet. + */ type LibraryClause = { id: ClauseId; label: string; - latestVersion: string; - /** Upgrade prose. Only defined when `latestVersion` differs from v1. */ - upgrade?: { - version: string; - summary: string; - body: string; - /** Hand-authored proposed-change view shown in the review panel. */ - preview: PreviewSegment[]; - }; + category: ClauseCategory; + jurisdiction: string; + version: string; + parts: ClausePart[]; }; const CLAUSE_LIBRARY: LibraryClause[] = [ - { id: 'preamble', label: 'Preamble', latestVersion: 'v1' }, + { + id: 'preamble', + label: 'Preamble', + category: 'Core', + jurisdiction: 'General', + version: 'v1', + parts: [ + 'The parties wish to share Confidential Information for the purposes described above and acknowledge the obligations set out in this Agreement.', + ], + }, { id: 'confidentiality', label: 'Confidentiality Obligations', - latestVersion: 'v2', - upgrade: { - version: 'v2', - summary: 'Extends survival period from 2 years to 5 years.', - body: 'Each party will treat the other party\u2019s Confidential Information as confidential and will protect it with at least the same care it uses for its own confidential information. These obligations survive disclosure for five (5) years.', - preview: [ - { kind: 'same', text: 'Each party will treat the other party\u2019s Confidential Information as confidential and will protect it with at least the same care it uses for its own confidential information. These obligations survive disclosure for ' }, - { kind: 'delete', text: 'two (2) years' }, - { kind: 'insert', text: 'five (5) years' }, - { kind: 'same', text: '.' }, - ], - }, + category: 'Confidentiality', + jurisdiction: 'General', + version: 'v1', + parts: [ + 'Each party will treat the other party’s Confidential Information as confidential and will protect it with at least the same care it uses for its own confidential information. These obligations survive disclosure for two (2) years.', + ], + }, + { + id: 'permittedUse', + label: 'Permitted Use', + category: 'Confidentiality', + jurisdiction: 'General', + version: 'v1', + // Carries nested smart fields: inserting this clause creates real inline + // SDTs for Receiving party and Purpose that fill from the Values form. + parts: [ + 'The ', + { field: 'receivingParty' }, + ' may use Confidential Information solely for ', + { field: 'purpose' }, + ', and for no other purpose, and will limit access to its employees and advisors with a need to know.', + ], + }, + { + id: 'termination', + label: 'Term and Termination', + category: 'Termination', + jurisdiction: 'General', + version: 'v1', + parts: [ + 'Either party may terminate this Agreement upon thirty (30) days’ written notice. Confidentiality obligations survive termination for the period specified above.', + ], }, - { id: 'permittedUse', label: 'Permitted Use', latestVersion: 'v1' }, - { id: 'termination', label: 'Term and Termination', latestVersion: 'v1' }, { id: 'governingLaw', label: 'Governing Law', - latestVersion: 'v2', - upgrade: { - version: 'v2', - summary: 'Changes governing law from California to New York.', - body: 'This Agreement is governed by the laws of the State of New York, without regard to its conflicts of law provisions.', - preview: [ - { kind: 'same', text: 'This Agreement is governed by the laws of the State of ' }, - { kind: 'delete', text: 'California' }, - { kind: 'insert', text: 'New York' }, - { kind: 'same', text: ', without regard to its conflicts of law provisions.' }, - ], - }, + category: 'Core', + jurisdiction: 'US-CA', + version: 'v1', + parts: ['This Agreement is governed by the laws of the State of California, without regard to its conflicts of law provisions.'], }, { id: 'limitationOfLiability', label: 'Limitation of Liability', - latestVersion: 'v2', - upgrade: { - version: 'v2', - summary: 'Extends liability cap from 12 to 24 months and excludes confidentiality and indemnity obligations.', - body: 'Each party\u2019s aggregate liability under this Agreement is limited to fees paid in the twenty-four (24) months preceding the claim. Confidentiality breaches and indemnity obligations are excluded from this cap.', - preview: [ - { kind: 'same', text: 'Each party\u2019s aggregate liability under this Agreement is limited to fees paid in the ' }, - { kind: 'delete', text: 'twelve (12)' }, - { kind: 'insert', text: 'twenty-four (24)' }, - { kind: 'same', text: ' months preceding the claim.' }, - { kind: 'insert', text: ' Confidentiality breaches and indemnity obligations are excluded from this cap.' }, - ], - }, + category: 'Risk Allocation', + jurisdiction: 'General', + version: 'v1', + parts: ['Each party’s aggregate liability under this Agreement is limited to fees paid in the twelve (12) months preceding the claim.'], + }, + { + // A library-only clause: not in the seeded document, so it starts "Used 0". + // Insert it to add a new governed section to the contract. + id: 'indemnification', + label: 'Indemnification', + category: 'Risk Allocation', + jurisdiction: 'General', + version: 'v1', + parts: ['Each party will indemnify and hold the other harmless from third-party claims arising out of its breach of this Agreement.'], }, ]; @@ -188,8 +222,10 @@ type ReusableSectionTag = { kind: 'reusableSection'; sectionId: ClauseId; versio type TagPayload = SmartFieldTag | ReusableSectionTag; const fieldTag = (key: FieldKey) => JSON.stringify({ kind: 'smartField', key } satisfies SmartFieldTag); -const clauseTag = (sectionId: ClauseId, version: string) => - JSON.stringify({ kind: 'reusableSection', sectionId, version } satisfies ReusableSectionTag); +// version is vestigial now (the version lifecycle was removed); inserted clauses +// carry v1 so the tag shape stays valid and parses as a reusableSection. +const clauseTag = (sectionId: ClauseId) => + JSON.stringify({ kind: 'reusableSection', sectionId, version: 'v1' } satisfies ReusableSectionTag); const parseTag = (tag: string | undefined): TagPayload | null => { if (!tag) return null; @@ -209,20 +245,30 @@ const parseTag = (tag: string | undefined): TagPayload | null => { const state = { editor: null as DemoEditor | null, values: {} as Record, - versions: {} as Record, - expandedClause: null as ClauseId | null, /** Smart-tag chip mirrored as active when the caret is in a matching field. */ activeTagKey: null as FieldKey | null, + /** Clause card mirrored as active when the caret is in a matching clause. */ + activeClauseId: null as ClauseId | null, /** UI controller; created in `initialize`, disposed by `teardown`. */ ui: null as ReturnType | null, /** Detaches the document -> palette highlight listeners. */ smartTagSyncTeardown: null as (() => void) | null, + /** Detaches the field drag-and-drop listeners on the editor host. */ + dragDropTeardown: null as (() => void) | null, }; +/** dataTransfer MIME used when dragging a field chip from the palette. */ +const FIELD_MIME = 'application/x-superdoc-field'; +/** dataTransfer MIME used when dragging a clause card from the palette. */ +const CLAUSE_MIME = 'application/x-superdoc-clause'; + const statusEl = qs('#status'); const summaryEl = qs('#summary'); const fieldsPanelEl = qs('#fields-panel'); -const clausesPanelEl = qs('#clauses-panel'); +const valuesPanelEl = qs('#values-panel'); +// The clause cards live in a section inside the Template panel; this container +// is created by renderClausesSection() and re-rendered by renderClausesPanel(). +let clausesListEl: HTMLElement | null = null; setBusy(true); @@ -234,7 +280,13 @@ const superdoc = new SuperDoc({ // highlight). The wrappers and data-sdt-* datasets are preserved, so the demo // paints its own field look in style.css and drives its own UI (Smart-tags // palette, Locate/Focus) through the public surface. - modules: { comments: false, contentControls: { chrome: 'none' } }, + modules: { + comments: false, + contentControls: { chrome: 'none' }, + // responsiveToContainer collapses toolbar items that overflow the editor + // column into an overflow menu, so the toolbar can't spill over the sidebar. + toolbar: { selector: '#superdoc-toolbar', responsiveToContainer: true }, + }, telemetry: { enabled: false }, onReady: ({ superdoc: sd }) => void initialize(sd as DemoSuperDoc), }); @@ -277,7 +329,12 @@ async function initialize(instance: DemoSuperDoc): Promise { return; } state.editor = instance.activeEditor; - readStateFromDocument(); + // Show each field's name as a placeholder and lock it; values are filled only + // through the Values form, which starts empty (see showFieldNamesAndLock). + showFieldNamesAndLock(); + // Lock the seeded clause blocks too, so their prose can't be edited by typing + // in the document. Fields nested in them still fill through the Values form. + lockClauses(); renderPanels(); refreshSummary(); @@ -292,12 +349,16 @@ async function initialize(instance: DemoSuperDoc): Promise { const onTokenClick = ({ target }: { target: { tag?: string } }) => { const parsed = target?.tag ? parseTag(target.tag) : null; state.activeTagKey = parsed?.kind === 'smartField' ? (parsed.key as FieldKey) : null; + state.activeClauseId = parsed?.kind === 'reusableSection' ? (parsed.sectionId as ClauseId) : null; highlightActiveTag(); + highlightActiveClause(); }; const onActiveChange = ({ active }: { active: { tag?: string } | null }) => { if (active) return; state.activeTagKey = null; + state.activeClauseId = null; highlightActiveTag(); + highlightActiveClause(); }; instance.on('content-control:click', onTokenClick); instance.on('content-control:active-change', onActiveChange); @@ -306,35 +367,96 @@ async function initialize(instance: DemoSuperDoc): Promise { instance.off('content-control:active-change', onActiveChange); }; + // Palette -> document: drag a field or clause onto the editor to insert it at + // the drop point (dogfoods ui.viewport.positionAt + create.contentControl). + state.dragDropTeardown = setupPaletteDragDrop(); + setStatus('Ready'); setBusy(false); } -/** Read field values and clause versions from the loaded fixture. */ -function readStateFromDocument(): void { - const doc = getDoc(); - for (const ctrl of doc.contentControls.list({}).items) { - const tag = parseTag(ctrl.properties?.tag); - if (!tag) continue; - if (tag.kind === 'smartField') { - state.values[tag.key] = ctrl.text ?? ''; - } else if (tag.kind === 'reusableSection') { - state.versions[tag.sectionId] = tag.version; - } - } -} // --------------------------------------------------------------------------- // Mutations: smart fields, clause updates, export // --------------------------------------------------------------------------- -/** Push a single field's value to every occurrence in the document. */ +/** + * Push a field's value to every occurrence. The field controls are + * `contentLocked` so a user can't type into them in the document; the Values + * form is the only writer. `text.setValue` is itself blocked on a locked + * control, so briefly unlock, write, then relock. The relock is in `finally` + * so a failed write never strands a field unlocked (editable by the user). + */ function applyField(key: FieldKey, value: string): void { - if (!state.editor?.doc) return; + const doc = state.editor?.doc; + if (!doc) return; state.values[key] = value; - const { items } = state.editor.doc.contentControls.selectByTag({ tag: fieldTag(key) }); - for (const ctrl of items) { - state.editor.doc.contentControls.text.setValue({ target: ctrl.target, value }); + // Filled -> the value; cleared -> back to the field-name placeholder. + const display = fieldDisplay(key); + + // A field can sit inside a clause (Receiving party / Purpose appear inside the + // Permitted Use clause). A clause's content lock SILENTLY vetoes writes to + // anything nested in it - text.setValue even reports success - so the value + // wouldn't broadcast to the nested occurrence. Briefly unlock every clause + // around the write, then relock them in `finally` so they never stay unlocked. + const clauseControls = () => + doc.contentControls.list({}).items.filter((c) => parseTag(c.properties?.tag)?.kind === 'reusableSection'); + for (const c of clauseControls()) { + reportMutation(doc.contentControls.setLockMode({ target: c.target, lockMode: 'unlocked' }), 'Unlock clause'); + } + try { + for (const ctrl of doc.contentControls.selectByTag({ tag: fieldTag(key) }).items) { + // Skip the write if unlock fails - the field stays locked (safe), just stale. + if (!reportMutation(doc.contentControls.setLockMode({ target: ctrl.target, lockMode: 'unlocked' }), `Unlock ${key}`)) { + continue; + } + try { + reportMutation(doc.contentControls.text.setValue({ target: ctrl.target, value: display }), `Update ${key}`); + } finally { + // A failed relock would leave the field editable, so make it loud. + reportMutation(doc.contentControls.setLockMode({ target: ctrl.target, lockMode: 'contentLocked' }), `Relock ${key}`); + } + } + } finally { + for (const c of clauseControls()) { + reportMutation(doc.contentControls.setLockMode({ target: c.target, lockMode: 'contentLocked' }), 'Relock clause'); + } + } +} + +/** + * Put the document into its starting template state. Each smart field's content + * is set to its field-name token (e.g. DISCLOSING_PARTY) as a stand-in + * placeholder - this is literal text content, NOT a native SDT placeholder + * (those are renderer-hardcoded and not settable via the API). Then each field + * is `contentLocked`, so values change only through the Values form, never by + * typing in the document. The form starts empty (every field unfilled). Content + * is written before locking, since a locked control rejects content writes. + */ +function showFieldNamesAndLock(): void { + const doc = state.editor?.doc; + if (!doc) return; + for (const field of FIELDS) { + state.values[field.key] = ''; + for (const ctrl of doc.contentControls.selectByTag({ tag: fieldTag(field.key) }).items) { + reportMutation(doc.contentControls.text.setValue({ target: ctrl.target, value: fieldDisplay(field.key) }), `Reset ${field.key}`); + reportMutation(doc.contentControls.setLockMode({ target: ctrl.target, lockMode: 'contentLocked' }), `Lock ${field.key}`); + } + } +} + +/** + * Lock every clause block as `contentLocked`, like the inline fields, so its + * prose can't be edited by typing in the document. The clauses are a fixed, + * read-only part of the loaded template. + */ +function lockClauses(): void { + const doc = state.editor?.doc; + if (!doc) return; + for (const ctrl of doc.contentControls.list({}).items) { + if (parseTag(ctrl.properties?.tag)?.kind === 'reusableSection') { + reportMutation(doc.contentControls.setLockMode({ target: ctrl.target, lockMode: 'contentLocked' }), 'Lock clause'); + } } } @@ -342,63 +464,205 @@ function applyField(key: FieldKey, value: string): void { const tokenFor = (key: FieldKey): string => key.replace(/([A-Z])/g, '_$1').toUpperCase(); /** - * Insert a smart-tag field at the caret (authoring). Dogfoods the verified - * recipe: capture the caret as a TextTarget, bridge it to a collapsed - * SelectionTarget, then `create.contentControl` with the token as initial - * content. The new inline SDT paints with the same `.superdoc-structured-content-inline` - * wrapper the palette chips are styled to match. Then focus it so the user can type. + * What a field control should display: the entered value if the field is filled, + * otherwise its field-name token (e.g. `DISCLOSING_PARTY`) as a visible + * placeholder. The Values form is the source of truth for filled/unfilled. */ -function insertTagAtCursor(key: FieldKey, label: string): void { +const fieldDisplay = (key: FieldKey): string => { + const value = state.values[key] ?? ''; + return value.trim() ? value : tokenFor(key); +}; + +/** + * Insert a smart-tag field as an inline SDT at `target` (a collapsed + * SelectionTarget). The control shows the field name as its placeholder + * (`fieldDisplay`, e.g. DISCLOSING_PARTY) and is `contentLocked`, so it behaves + * like the seeded fields: filled only through the Values form. It's tagged so it + * paints with the same `.superdoc-structured-content-inline` look as the palette + * chips. Shared by click-to-insert (caret) and drag-and-drop (drop point); only + * how `target` is resolved differs. Then scroll it into view so the user sees it. + */ +function insertField(key: FieldKey, label: string, target: SelectionTarget): void { const ui = state.ui; const editor = state.editor; if (!ui || !editor?.doc) return; - const seg = ui.selection.capture()?.target?.segments?.[0]; - if (!seg) { - // No caret to insert at — tell the user instead of silently no-op'ing. - setStatus('Place the cursor in the document, then click a tag to insert it.'); - return; - } - const point: SelectionPoint = { kind: 'text', blockId: seg.blockId, offset: seg.range.start }; const result = editor.doc.create.contentControl({ kind: 'inline', controlType: 'text', - at: { kind: 'selection', start: point, end: point }, - content: tokenFor(key), + at: target, + content: fieldDisplay(key), tag: fieldTag(key), alias: label, + lockMode: 'contentLocked', }); if (result.success) { state.values[key] = state.values[key] ?? ''; - void ui.contentControls.focus({ id: result.contentControl.nodeId }); + void ui.contentControls.scrollIntoView({ id: result.contentControl.nodeId, block: 'center' }); } } -async function applyClauseVersion(clauseId: ClauseId, toVersion: string, body: string): Promise { - const doc = getDoc(); +/** + * Insert a field at the caret (click-to-insert). Captures the caret as a + * TextTarget and bridges it to a collapsed SelectionTarget (the verified recipe). + */ +function insertFieldAtCursor(key: FieldKey, label: string): void { + const ui = state.ui; + if (!ui || !state.editor?.doc) return; + const seg = ui.selection.capture()?.target?.segments?.[0]; + if (!seg) { + // No caret to insert at — tell the user instead of silently no-op'ing. + setStatus('Place the cursor in the document (or drag the field in), then click a tag to insert it.'); + return; + } + const point: SelectionPoint = { kind: 'text', blockId: seg.blockId, offset: seg.range.start }; + insertField(key, label, { kind: 'selection', start: point, end: point }); +} + +/** The clause's plain text; each field slot renders as its current display. */ +function clauseText(clause: LibraryClause): string { + return clause.parts.map((part) => (typeof part === 'string' ? part : fieldDisplay(part.field))).join(''); +} + +/** Character ranges of each field slot within `clauseText`, for wrapping as SDTs. */ +function clauseFieldRanges(clause: LibraryClause): { field: FieldKey; start: number; end: number }[] { + const ranges: { field: FieldKey; start: number; end: number }[] = []; + let offset = 0; + for (const part of clause.parts) { + const text = typeof part === 'string' ? part : fieldDisplay(part.field); + if (typeof part !== 'string') ranges.push({ field: part.field, start: offset, end: offset + text.length }); + offset += text.length; + } + return ranges; +} + +/** + * Insert a governed clause as a locked block SDT at the START of `blockId` + * (offset 0, a clean block boundary - inserting at the raw drop caret would + * split a paragraph mid-text). The clause is assembled from its parts: the block + * holds the prose, and each `{ field }` slot is wrapped as a nested, locked + * inline smart-field SDT - so an inserted "Permitted Use" carries real Receiving + * party / Purpose fields that fill from the Values form, like the seeded one. + * Inserts unlocked, wraps the slots, then locks the clause. + */ +async function insertClause(clauseId: ClauseId, blockId: string): Promise { + const ui = state.ui; + const editor = state.editor; + if (!ui || !editor?.doc) return; + const doc = editor.doc; const clause = CLAUSE_LIBRARY.find((c) => c.id === clauseId); if (!clause) return; - const ctrl = findClauseControl(clauseId); - if (!ctrl) throw new Error(`Clause ${clauseId} not in document`); + const point: SelectionPoint = { kind: 'text', blockId, offset: 0 }; + const created = doc.create.contentControl({ + kind: 'block', + controlType: 'richText', + at: { kind: 'selection', start: point, end: point }, + content: clauseText(clause), + tag: clauseTag(clauseId), + alias: clause.label, + lockMode: 'unlocked', // unlocked so the field slots can be wrapped, then locked + }); + if (!reportMutation(created, `Insert ${clause.label}`) || !created.success) return; + const clauseTarget = created.contentControl; + + // Wrap each field slot as a nested inline smart-field SDT. Focus the new block + // to resolve its inner text blockId (no coordinates needed), then wrap by + // character range - last slot first, so wrapping one can't shift another's + // offsets. + await ui.contentControls.focus({ id: clauseTarget.nodeId }); + const innerBlockId = ui.selection.capture()?.target?.segments?.[0]?.blockId; + if (innerBlockId) { + for (const range of [...clauseFieldRanges(clause)].reverse()) { + reportMutation( + doc.create.contentControl({ + kind: 'inline', + controlType: 'text', + at: { + kind: 'selection', + start: { kind: 'text', blockId: innerBlockId, offset: range.start }, + end: { kind: 'text', blockId: innerBlockId, offset: range.end }, + }, + tag: fieldTag(range.field), + alias: FIELDS.find((f) => f.key === range.field)?.label ?? range.field, + lockMode: 'contentLocked', + }), + `Nest ${range.field}`, + ); + } + } - assertMutation( - doc.contentControls.replaceContent({ target: ctrl.target, content: body, format: 'text' }), - `Could not update ${clause.label}`, - true, - ); + // Lock the clause now that its slots are wrapped, then refresh the cards + // ("Used N") and scroll the new clause into view. + reportMutation(doc.contentControls.setLockMode({ target: clauseTarget, lockMode: 'contentLocked' }), 'Lock clause'); + renderClausesPanel(); + void ui.contentControls.scrollIntoView({ id: clauseTarget.nodeId, block: 'center' }); +} - const refreshed = findClauseControl(clauseId) ?? ctrl; - assertMutation( - doc.contentControls.patch({ - target: refreshed.target, - tag: clauseTag(clauseId, toVersion), - alias: `${clause.label} (${toVersion})`, - }), - `Could not patch clause tag for ${clause.label}`, - true, - ); +/** Insert a clause at the caret's block boundary (click-to-insert). */ +function insertClauseAtCursor(clauseId: ClauseId): void { + const ui = state.ui; + if (!ui || !state.editor?.doc) return; + const seg = ui.selection.capture()?.target?.segments?.[0]; + if (!seg) { + setStatus('Place the cursor in the document (or drag the clause in), then click a clause to insert it.'); + return; + } + void insertClause(clauseId, seg.blockId); +} - state.versions[clauseId] = toVersion; +/** + * Palette -> document drag-and-drop for both building blocks. Resolves the drop + * point with the public `ui.viewport.positionAt`, then: a field inserts inline + * at the exact caret; a clause inserts as a block at the drop block's boundary + * (see insertClause). Returns a teardown. + */ +function setupPaletteDragDrop(): () => void { + const host = qs('#editor'); + const draggingPaletteItem = (event: DragEvent) => + event.dataTransfer?.types.includes(FIELD_MIME) || event.dataTransfer?.types.includes(CLAUSE_MIME); + + const onDragOver = (event: DragEvent): void => { + if (!draggingPaletteItem(event)) return; + // preventDefault on dragover is what makes an element a valid drop target. + event.preventDefault(); + event.dataTransfer!.dropEffect = 'copy'; + host.classList.add('drop-target'); + }; + const onDragLeave = (event: DragEvent): void => { + // Only clear when leaving the host itself, not when crossing child nodes. + if (event.target === host) host.classList.remove('drop-target'); + }; + const onDrop = (event: DragEvent): void => { + host.classList.remove('drop-target'); + const fieldKey = event.dataTransfer?.getData(FIELD_MIME) as FieldKey | ''; + const clauseId = event.dataTransfer?.getData(CLAUSE_MIME) as ClauseId | ''; + if (!fieldKey && !clauseId) return; + event.preventDefault(); + const hit = state.ui?.viewport.positionAt({ x: event.clientX, y: event.clientY }); + // A text caret is the only droppable target (a node-edge hit has no offset). + if (!hit || hit.point.kind !== 'text') { + setStatus('Drop onto the document text.'); + return; + } + if (fieldKey) { + const field = FIELDS.find((f) => f.key === fieldKey); + if (!field) return; + const point: SelectionPoint = { kind: 'text', blockId: hit.point.blockId, offset: hit.point.offset }; + insertField(field.key, field.label, { kind: 'selection', start: point, end: point }); + } else if (clauseId) { + const clause = CLAUSE_LIBRARY.find((c) => c.id === clauseId); + if (clause) void insertClause(clause.id, hit.point.blockId); // offset 0 (block boundary) + } + }; + + host.addEventListener('dragover', onDragOver); + host.addEventListener('dragleave', onDragLeave); + host.addEventListener('drop', onDrop); + return () => { + host.removeEventListener('dragover', onDragOver); + host.removeEventListener('dragleave', onDragLeave); + host.removeEventListener('drop', onDrop); + }; } async function exportDocument(mode: 'raw' | 'clean'): Promise { @@ -448,7 +712,7 @@ function focusByTag(tag: string): void { function renderPanels(): void { renderFieldsPanel(); - renderClausesPanel(); + renderValuesPanel(); } /** @@ -461,22 +725,27 @@ function renderSmartTagsPalette(): void { const section = document.createElement('div'); section.className = 'smart-tags'; section.innerHTML = ` -

Click a tag to insert it at the cursor.

- -
Offer
+

Drag a field into the document, or click to insert it at the cursor.

+ +
Template fields
${FIELDS.map( (f) => - ``, + ``, ).join('')}
`; fieldsPanelEl.appendChild(section); section.querySelectorAll('.smart-tag').forEach((btn) => { + const field = FIELDS.find((f) => f.key === (btn.dataset.tagKey as FieldKey)); btn.addEventListener('click', () => { - const field = FIELDS.find((f) => f.key === (btn.dataset.tagKey as FieldKey)); - if (field) insertTagAtCursor(field.key, field.label); + if (field) insertFieldAtCursor(field.key, field.label); + }); + btn.addEventListener('dragstart', (event) => { + if (!field || !event.dataTransfer) return; + event.dataTransfer.setData(FIELD_MIME, field.key); + event.dataTransfer.effectAllowed = 'copy'; }); }); @@ -503,9 +772,66 @@ function highlightActiveTag(): void { }); } +/** + * Mirror the active clause: the card whose id matches `state.activeClauseId` + * gets `.is-active`. Driven by `content-control:click` on a clause block (and + * cleared on blur) — the clauses' half of the document -> sidebar loop. + */ +function highlightActiveClause(): void { + clausesListEl?.querySelectorAll('.clause').forEach((card) => { + card.classList.toggle('is-active', card.dataset.clauseId === state.activeClauseId); + }); +} + +/** + * Template tab: the contract's building blocks. Two catalogs - inline Smart tags + * (variable chips) and block Clauses (cards with metadata + usage count). Both + * drag or click to insert; values are filled on the Values tab. + */ function renderFieldsPanel(): void { fieldsPanelEl.innerHTML = ''; renderSmartTagsPalette(); + renderClausesSection(); +} + +/** + * Clauses section of the Template tab: a search + the clause cards. Mirrors the + * Smart-tags section's style (group header, search) but the clauses render as + * compact amber cards, not pills, since they're block controls. Creates the + * list container (clausesListEl) that renderClausesPanel re-renders into. + */ +function renderClausesSection(): void { + const section = document.createElement('div'); + section.className = 'clauses-section'; + section.innerHTML = ` +
Clauses
+

Drag a clause into the document, or click to insert it at the cursor.

+ +
+ `; + fieldsPanelEl.appendChild(section); + clausesListEl = section.querySelector('.clauses-list'); + + const search = section.querySelector('.clauses-search'); + search?.addEventListener('input', () => { + const q = search.value.trim().toLowerCase(); + clausesListEl?.querySelectorAll('.clause').forEach((card) => { + const label = (card.querySelector('.clause-label')?.textContent ?? '').toLowerCase(); + card.style.display = !q || label.includes(q) ? '' : 'none'; + }); + }); + + renderClausesPanel(); +} + +/** + * Values tab: fill the fields that are in the document. Editing a value + * debounces ~250ms and fans it to every occurrence of that field's tag + * (`selectByTag` + per-occurrence `text.setValue`). Locate/Focus jump to the + * first occurrence. + */ +function renderValuesPanel(): void { + valuesPanelEl.innerHTML = ''; for (const field of FIELDS) { // A
wrapper (not
- + `; - fieldsPanelEl.appendChild(row); + valuesPanelEl.appendChild(row); row.querySelector('.locate')?.addEventListener('click', () => { locateByTag(fieldTag(field.key)); }); @@ -545,97 +871,63 @@ function renderFieldsPanel(): void { } } +/** + * Render the clause cards: one card per clause, styled like the in-document + * block clause (amber left rail). Like the smart-tag chips, a card is draggable + * into the document or click-to-insert at the cursor (insertClause snaps to a + * block boundary). A card highlights when its clause is clicked in the document. + */ +/** Every control in the document for a given clause (a clause can be placed more than once). */ +function clauseControls(clauseId: ClauseId): ContentControlInfo[] { + const doc = state.editor?.doc; + if (!doc) return []; + return doc.contentControls.list({}).items.filter((c) => { + const t = parseTag(c.properties?.tag); + return t?.kind === 'reusableSection' && t.sectionId === clauseId; + }); +} + +/** + * Render the clause library catalog: a card per available clause with its + * category / jurisdiction / version and how many times it's placed in the + * document. A card wears the in-document block clause's amber look. Drag a card + * in, or click to insert it at the cursor; the card highlights when its clause + * is clicked in the document. + */ function renderClausesPanel(): void { - clausesPanelEl.innerHTML = ''; + const list = clausesListEl; + if (!list) return; + list.innerHTML = ''; for (const clause of CLAUSE_LIBRARY) { - const inDoc = state.versions[clause.id] ?? clause.latestVersion; - const stale = clause.upgrade != null && inDoc !== clause.latestVersion; - const expanded = stale && state.expandedClause === clause.id; - + const used = clauseControls(clause.id).length; + const usedText = used === 0 ? 'Not used' : `Used ${used} time${used === 1 ? '' : 's'}`; const card = document.createElement('article'); - card.className = 'clause' + (stale ? ' stale' : ' current') + (expanded ? ' expanded' : ''); - - if (stale && clause.upgrade) { - const upgrade = clause.upgrade; - const previewHtml = upgrade.preview.map(renderSegment).join(''); - card.innerHTML = ` -
-

${escapeHtml(clause.label)}

-
- Update available - - -
-
-

${escapeHtml(upgrade.summary)}

-

Document ${escapeHtml(inDoc)} \u00b7 Library ${escapeHtml(upgrade.version)}

- - ${ - expanded - ? ` -
-
Proposed change
-

${previewHtml}

- -
- ` - : '' - } - `; - card.querySelector('.clause-review')?.addEventListener('click', () => { - state.expandedClause = expanded ? null : clause.id; - renderClausesPanel(); - }); - card.querySelector('.clause-replace')?.addEventListener('click', () => { - void run(`${clause.label}: replaced with library clause`, async () => { - await applyClauseVersion(clause.id, upgrade.version, upgrade.body); - state.expandedClause = null; - }); - }); - } else { - card.innerHTML = ` -
-

${escapeHtml(clause.label)}

-
- Current - - -
-
-

Document ${escapeHtml(inDoc)}

- `; - } - - card.querySelector('.locate')?.addEventListener('click', () => { - locateByTag(clauseTag(clause.id, inDoc)); - }); - card.querySelector('.focus')?.addEventListener('click', () => { - focusByTag(clauseTag(clause.id, inDoc)); + card.className = 'clause' + (clause.id === state.activeClauseId ? ' is-active' : ''); + card.dataset.clauseId = clause.id; + card.draggable = true; + card.title = `Drag into the document, or click to insert the ${clause.label} clause at the cursor`; + card.innerHTML = ` +

${escapeHtml(clause.label)}

+

${escapeHtml(clause.category)} · ${escapeHtml(clause.jurisdiction)} · ${escapeHtml(clause.version)} · ${escapeHtml(usedText)}

+ `; + card.addEventListener('click', () => insertClauseAtCursor(clause.id)); + card.addEventListener('dragstart', (event) => { + if (!event.dataTransfer) return; + event.dataTransfer.setData(CLAUSE_MIME, clause.id); + event.dataTransfer.effectAllowed = 'copy'; }); - clausesPanelEl.appendChild(card); + list.appendChild(card); } } function refreshSummary(): void { - const stale = CLAUSE_LIBRARY.filter( - (c) => c.upgrade != null && (state.versions[c.id] ?? c.latestVersion) !== c.latestVersion, - ).length; - const updateText = stale === 0 ? 'all clauses current' : `${stale} update${stale === 1 ? '' : 's'} available`; - summaryEl.textContent = `${FIELDS.length} fields \u00b7 ${CLAUSE_LIBRARY.length} clauses \u00b7 ${updateText}`; + summaryEl.textContent = `${FIELDS.length} fields \u00b7 ${CLAUSE_LIBRARY.length} clauses`; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -function findClauseControl(clauseId: ClauseId): ContentControlInfo | undefined { - const doc = getDoc(); - return doc.contentControls.list({}).items.find((ctrl) => { - const t = parseTag(ctrl.properties?.tag); - return t?.kind === 'reusableSection' && t.sectionId === clauseId; - }); -} - async function run(status: string, action: () => Promise): Promise { setBusy(true); setStatus('Working'); @@ -656,10 +948,18 @@ function getDoc(): DocumentApi { return state.editor.doc; } -function assertMutation(result: MutationResult, message: string, allowNoOp = false): void { - if (result.success) return; - if (allowNoOp && result.failure.code === 'NO_OP') return; - throw new Error(result.failure.message || message); +/** + * Surface a failed mutation instead of swallowing it. Returns whether it + * succeeded so callers can branch (e.g. skip the write if the unlock failed). + * NO_OP (value already matches) is treated as success. Used on the form-only + * write path, where a silent failure would leave a field stale or - worse, on a + * failed relock - editable by the user. + */ +function reportMutation(result: MutationResult, context: string): boolean { + if (result.success || result.failure.code === 'NO_OP') return true; + console.error(`[contract-templates] ${context} failed:`, result.failure); + setStatus(`${context} failed: ${result.failure.message}`); + return false; } function setBusy(busy: boolean): void { @@ -682,13 +982,6 @@ function escapeHtml(s: string): string { return s.replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[ch]!); } -function renderSegment(seg: PreviewSegment): string { - const text = escapeHtml(seg.text); - if (seg.kind === 'insert') return `${text}`; - if (seg.kind === 'delete') return `${text}`; - return text; -} - function escapeAttr(s: string): string { return escapeHtml(s).replace(/'/g, '''); } @@ -709,6 +1002,12 @@ const teardown = () => { /* best-effort teardown */ } state.smartTagSyncTeardown = null; + try { + state.dragDropTeardown?.(); + } catch { + /* best-effort teardown */ + } + state.dragDropTeardown = null; try { state.ui?.destroy(); } catch { diff --git a/demos/contract-templates/src/style.css b/demos/contract-templates/src/style.css index 93dc1a918f..dab6743569 100644 --- a/demos/contract-templates/src/style.css +++ b/demos/contract-templates/src/style.css @@ -34,7 +34,30 @@ button { cursor: pointer; } .app { display: grid; grid-template-columns: 1fr 360px; height: 100vh; } .editor-area { display: flex; flex-direction: column; min-width: 0; min-height: 0; } -.editor-area #editor { flex: 1; overflow: auto; padding: 12px; } +/* SuperDoc's formatting toolbar, rendered into #superdoc-toolbar. Clip to the + editor column so overflowing items can't paint over the sidebar (they collapse + into the responsive overflow menu instead; popovers append to ). */ +#superdoc-toolbar { + border-bottom: 1px solid var(--demo-border); + background: var(--demo-bg); + overflow: hidden; + min-width: 0; +} +/* Center the page horizontally; align to top so multi-page docs scroll. */ +.editor-area #editor { + flex: 1; + overflow: auto; + padding: 12px; + display: flex; + justify-content: center; + align-items: flex-start; +} +/* Drop zone: highlight the editor while a field chip is dragged over it. */ +.editor-area #editor.drop-target { + outline: 2px dashed var(--demo-accent); + outline-offset: -4px; + background: var(--demo-accent-soft); +} .toolbar { display: flex; @@ -136,9 +159,9 @@ input:focus { } .btn:disabled { color: var(--demo-text-muted); cursor: not-allowed; opacity: 0.55; } -/* "Locate" — scroll a control into view (ui.contentControls.scrollIntoView); - "Focus" — scroll AND place the caret inside it (ui.contentControls.focus). */ -.clause-actions { display: flex; align-items: center; gap: 8px; } +/* Field rows (Values tab): "Locate" scrolls a control into view + (ui.contentControls.scrollIntoView); "Focus" scrolls AND places the caret + inside it (ui.contentControls.focus). */ .row-actions { display: flex; align-items: center; gap: 6px; } .locate, .focus { @@ -161,96 +184,41 @@ input:focus { #fields-panel .btn { width: 100%; margin-top: 12px; } /* ----------------------------------------------------------------------- - Clauses panel + Clause library (Template tab) + + A catalog of governed clauses. Each card echoes the in-document block clause + (amber left rail, soft border, faint amber fill) and shows its metadata and + how many times it's placed. Drag a card in, or click to insert at the cursor. + The clauses themselves are locked blocks. ----------------------------------------------------------------------- */ +.clauses-section { margin-top: 16px; } + .clause { - border: 1px solid var(--demo-border); - border-radius: var(--sd-radius-100, 6px); - padding: 10px 12px; - margin-bottom: 8px; - background: var(--demo-bg); -} -.clause.current { - background: transparent; - border-color: var(--demo-border); -} -.clause.stale { - background: var(--demo-stale-soft); - border-color: var(--demo-accent); -} -.clause-header { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; - margin-bottom: 4px; + border: 1px solid var(--tag-block-border); + border-left: 4px solid var(--tag-color); + border-radius: var(--tag-radius); + background-color: var(--tag-block-bg); + padding: 7px 10px; + margin-bottom: 6px; + cursor: grab; } +.clause:hover { background-color: var(--tag-block-bg-hover); } +.clause:active { cursor: grabbing; } +.clause.is-active { box-shadow: 0 0 0 2px var(--tag-color); } .clause-label { margin: 0; font-size: var(--sd-font-size-300, 13px); font-weight: 600; color: var(--demo-text); -} -.clause-status { - font-size: var(--sd-font-size-200, 11px); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--demo-accent); -} -.clause-status.muted { color: var(--demo-text-muted); } -.clause-summary { - margin: 0 0 6px; - font-size: var(--sd-font-size-300, 13px); - color: var(--demo-text); - line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .clause-meta { - margin: 0 0 8px; - font-size: var(--sd-font-size-200, 12px); - color: var(--demo-text-muted); -} -.clause .btn { width: 100%; } -.clause-review-panel { - margin-top: 10px; - padding-top: 10px; - border-top: 1px solid var(--demo-accent); - display: flex; - flex-direction: column; - gap: 10px; -} -.review-label { - font-size: var(--sd-font-size-200, 11px); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--demo-text-muted); -} -.clause-preview { - margin: 0; - padding: 10px 12px; - background: var(--demo-bg); - border: 1px solid var(--demo-border); - border-radius: var(--sd-radius-50, 4px); - font-size: var(--sd-font-size-300, 13px); - line-height: 1.5; - color: var(--demo-text); -} -.clause-preview ins { - background: color-mix(in srgb, #15803d 18%, transparent); - color: var(--demo-text); - text-decoration: none; - padding: 0 2px; - border-radius: 2px; -} -.clause-preview del { + margin: 2px 0 0; + font-size: var(--sd-font-size-100, 11px); color: var(--demo-text-muted); - text-decoration: line-through; - text-decoration-color: #d92e2e; - background: color-mix(in srgb, #d92e2e 12%, transparent); - padding: 0 2px; - border-radius: 2px; } .status { @@ -276,9 +244,12 @@ input:focus { /* Smart-tag token look, shared by the in-editor inline SDT and the Smart-tags palette chips so a sidebar tag and the field it inserts read as one object. */ :root { - /* Smart tags get their own amber identity (distinct from the blue UI accent), - echoing a typical template-variable palette. One token set, applied to both - the palette chip and the painted in-editor field. */ + /* Template fields wear a deliberate amber identity, distinct from the + SuperDoc-blue UI accent (--demo-accent), so a reader can see "this is a + template variable" at a glance. Amber isn't a built-in --sd-* token, so + this is an intentional demo-local accent (the theming the demo is showing + off, per demos/AGENTS.md). One token set, applied to both the palette chip + and the painted in-editor field. */ --tag-color: var(--sd-color-amber-600, #d97706); --tag-border: var(--tag-color); --tag-bg: color-mix(in srgb, var(--tag-color) 12%, transparent); @@ -352,7 +323,7 @@ input:focus { font-size: 14px; font-weight: 500; line-height: 1.2; - cursor: pointer; + cursor: grab; padding: 1px 6px; border: 1px solid var(--tag-border); border-radius: var(--tag-radius); @@ -361,6 +332,7 @@ input:focus { white-space: nowrap; } .smart-tag:hover { background: var(--tag-bg-hover); } +.smart-tag:active { cursor: grabbing; } .smart-tag.is-active { box-shadow: 0 0 0 2px var(--demo-accent); } .smart-tag:focus-visible { outline: 1px solid var(--demo-accent); outline-offset: 1px; } From bfd052571bee8f5b063687539be9720acce2802c Mon Sep 17 00:00:00 2001 From: aorlov Date: Fri, 29 May 2026 23:41:05 +0200 Subject: [PATCH 09/11] fix(sdk): address PR review for preset registry (SD-3128) Three review findings from PR 3541: 1. Restore structured ToolCatalog.tools type. The refactor narrowed the public catalog row to `unknown[]`, breaking TS consumers that read tools[i].toolName etc. Move ToolCatalogEntry + ToolCatalogOperation into presets.ts as public types and tighten the catalog signature. 2. Fail fast on malformed provider bundles. Node and Python preset loaders previously coerced a missing or non-array `tools` field to `[]`, hiding broken codegen output behind a silently empty tool surface. Restore the pre-presets TOOLS_ASSET_INVALID throw at the preset boundary. 3. Cross-lang parity for empty-string presets. Python choose_tools treated `{'preset': ''}` as legacy via `or DEFAULT_PRESET`; Node and MCP both raise PRESET_NOT_FOUND. Use an explicit None check so Python matches. Tests added covering structural catalog access, empty-string preset fail-fast, and cross-lang parity for the empty-string case. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../langs/node/src/__tests__/presets.test.ts | 33 ++++++++++++ packages/sdk/langs/node/src/index.ts | 2 + packages/sdk/langs/node/src/presets.ts | 29 ++++++++++- packages/sdk/langs/node/src/presets/legacy.ts | 52 +++++++++---------- packages/sdk/langs/node/src/tools.ts | 4 +- .../langs/python/superdoc/presets/legacy.py | 12 ++++- .../sdk/langs/python/superdoc/tools_api.py | 7 ++- .../sdk/langs/python/tests/test_presets.py | 15 ++++++ 8 files changed, 122 insertions(+), 32 deletions(-) diff --git a/packages/sdk/langs/node/src/__tests__/presets.test.ts b/packages/sdk/langs/node/src/__tests__/presets.test.ts index b789e14738..28f17a1be3 100644 --- a/packages/sdk/langs/node/src/__tests__/presets.test.ts +++ b/packages/sdk/langs/node/src/__tests__/presets.test.ts @@ -50,6 +50,39 @@ describe('preset registry', () => { expect(details.availablePresets).toContain('legacy'); } }); + + test('getPreset("") throws PRESET_NOT_FOUND (empty string is not the default)', () => { + try { + getPreset(''); + throw new Error('Expected getPreset("") to throw.'); + } catch (error) { + expect(error).toBeInstanceOf(SuperDocCliError); + expect((error as SuperDocCliError).code).toBe('PRESET_NOT_FOUND'); + } + }); + + test('chooseTools({preset: ""}) throws PRESET_NOT_FOUND (cross-lang parity)', async () => { + await expect(chooseTools({ provider: 'openai', preset: '' })).rejects.toMatchObject({ + code: 'PRESET_NOT_FOUND', + }); + }); +}); + +describe('public ToolCatalog type — structural access', () => { + test('getToolCatalog().tools entries expose typed properties', async () => { + const catalog = await getToolCatalog(); + expect(catalog.tools.length).toBeGreaterThan(0); + const first = catalog.tools[0]!; + // These property accesses validate that ToolCatalog.tools is structurally + // typed (ToolCatalogEntry[]) — not unknown[]. Compile failure here means + // the public catalog row type regressed. + expect(typeof first.toolName).toBe('string'); + expect(typeof first.description).toBe('string'); + expect(typeof first.mutates).toBe('boolean'); + expect(Array.isArray(first.operations)).toBe(true); + expect(typeof first.operations[0]?.operationId).toBe('string'); + expect(typeof first.operations[0]?.intentAction).toBe('string'); + }); }); describe('chooseTools — default preset equivalence', () => { diff --git a/packages/sdk/langs/node/src/index.ts b/packages/sdk/langs/node/src/index.ts index 68b33aee19..86ba0123b9 100644 --- a/packages/sdk/langs/node/src/index.ts +++ b/packages/sdk/langs/node/src/index.ts @@ -262,6 +262,8 @@ export type { CacheStrategy, SystemPromptForProviderResult, ToolCatalog, + ToolCatalogEntry, + ToolCatalogOperation, ToolChooserInput, ToolProvider, } from './tools.js'; diff --git a/packages/sdk/langs/node/src/presets.ts b/packages/sdk/langs/node/src/presets.ts index b3633dbfb3..460dea6e88 100644 --- a/packages/sdk/langs/node/src/presets.ts +++ b/packages/sdk/langs/node/src/presets.ts @@ -44,6 +44,33 @@ export type ToolProvider = 'openai' | 'anthropic' | 'vercel' | 'generic'; */ export type CacheStrategy = 'explicit' | 'automatic' | 'unsupported' | 'disabled'; +/** + * One operation row in a {@link ToolCatalogEntry}. Each catalog entry can + * dispatch to one or more operations (e.g. multi-action intent tools), so + * the catalog records the operation id and the action discriminator that + * routes to it. + */ +export type ToolCatalogOperation = { + operationId: string; + intentAction: string; + required?: string[]; + requiredOneOf?: string[][]; +}; + +/** + * One entry in the {@link ToolCatalog}. Matches the shape of the catalog + * emitted by the legacy preset's codegen — kept stable as the public + * catalog row shape so TypeScript consumers can introspect `tools[i]` + * without losing property typing. + */ +export type ToolCatalogEntry = { + toolName: string; + description: string; + inputSchema: Record; + mutates: boolean; + operations: ToolCatalogOperation[]; +}; + /** * Full tool catalog shape. The legacy preset returns the existing codegen * catalog with `contractVersion`, `generatedAt`, `toolCount`, `tools`. @@ -52,7 +79,7 @@ export type ToolCatalog = { contractVersion: string; generatedAt: string | null; toolCount: number; - tools: unknown[]; + tools: ToolCatalogEntry[]; }; export interface GetToolsOptions { diff --git a/packages/sdk/langs/node/src/presets/legacy.ts b/packages/sdk/langs/node/src/presets/legacy.ts index a8336fa755..7351ee893b 100644 --- a/packages/sdk/langs/node/src/presets/legacy.ts +++ b/packages/sdk/langs/node/src/presets/legacy.ts @@ -21,7 +21,15 @@ import type { BoundDocApi } from '../generated/client.js'; import type { InvokeOptions } from '../runtime/process.js'; import { SuperDocCliError } from '../runtime/errors.js'; import { dispatchIntentTool } from '../generated/intent-dispatch.generated.js'; -import type { PresetDescriptor, GetToolsOptions, GetToolsResult, ToolCatalog, ToolProvider } from '../presets.js'; +import type { + GetToolsOptions, + GetToolsResult, + PresetDescriptor, + ToolCatalog, + ToolCatalogEntry, + ToolCatalogOperation, + ToolProvider, +} from '../presets.js'; // Resolve tools directory relative to package root (works from both src/ and dist/) const toolsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'tools'); @@ -33,23 +41,6 @@ const providerFileByName: Record = { generic: 'tools.generic.json', }; -type OperationEntry = { - operationId: string; - intentAction: string; - required?: string[]; - requiredOneOf?: string[][]; -}; - -type ToolCatalogEntry = { - toolName: string; - description: string; - inputSchema: Record; - mutates: boolean; - operations: OperationEntry[]; -}; - -type LegacyToolCatalog = ToolCatalog & { tools: ToolCatalogEntry[] }; - const STRIP_EMPTY_OPTIONAL_ARGS = new Set(['parentId', 'parentCommentId', 'id', 'status']); function isRecord(value: unknown): value is Record { @@ -103,11 +94,11 @@ async function readProviderTools(provider: ToolProvider): Promise<{ } // Cached catalog instance — loaded once per process. -let _catalogCache: LegacyToolCatalog | null = null; +let _catalogCache: ToolCatalog | null = null; -async function getCachedCatalog(): Promise { +async function getCachedCatalog(): Promise { if (_catalogCache == null) { - _catalogCache = await readJson('catalog.json'); + _catalogCache = await readJson('catalog.json'); } return _catalogCache; } @@ -210,7 +201,7 @@ function validateToolArgs(toolName: string, args: Record, tool: // 3. Reject missing per-operation required keys. For multi-action tools, // resolve the operation by action; for single-op tools, use the sole entry. const action = args.action; - let op: OperationEntry | undefined; + let op: ToolCatalogOperation | undefined; if (typeof action === 'string' && tool.operations.length > 1) { op = tool.operations.find((o) => o.intentAction === action); } else if (tool.operations.length === 1) { @@ -230,7 +221,7 @@ function validateOperationRequired( toolName: string, action: unknown, args: Record, - op: OperationEntry, + op: ToolCatalogOperation, ): void { const actionLabel = typeof action === 'string' ? ` action "${action}"` : ''; @@ -262,8 +253,16 @@ function validateOperationRequired( async function legacyGetTools(provider: ToolProvider, options?: GetToolsOptions): Promise { const { tools } = await readProviderTools(provider); - const rawTools = Array.isArray(tools) ? tools : []; - return applyCacheMarkers(rawTools, provider, options?.cache === true); + // Fail fast on malformed provider artifacts so agents don't silently boot + // with zero tools. Matches the pre-presets behavior of the public + // `listTools` path (TOOLS_ASSET_INVALID). + if (!Array.isArray(tools)) { + throw new SuperDocCliError('Tool provider bundle is missing tools array.', { + code: 'TOOLS_ASSET_INVALID', + details: { provider }, + }); + } + return applyCacheMarkers(tools, provider, options?.cache === true); } async function legacyGetCatalog(): Promise { @@ -316,8 +315,7 @@ async function legacyDispatch( } const catalog = await getCachedCatalog(); - const entries = catalog.tools as ToolCatalogEntry[]; - const tool = entries.find((t) => t.toolName === toolName); + const tool = catalog.tools.find((t) => t.toolName === toolName); if (tool == null) { throw new SuperDocCliError(`Unknown tool: ${toolName}`, { code: 'TOOL_DISPATCH_NOT_FOUND', diff --git a/packages/sdk/langs/node/src/tools.ts b/packages/sdk/langs/node/src/tools.ts index 0c8240759e..68fea3a78f 100644 --- a/packages/sdk/langs/node/src/tools.ts +++ b/packages/sdk/langs/node/src/tools.ts @@ -16,11 +16,13 @@ import { listPresets, type CacheStrategy, type ToolCatalog, + type ToolCatalogEntry, + type ToolCatalogOperation, type ToolProvider, } from './presets.js'; export { DEFAULT_PRESET, getPreset, listPresets }; -export type { CacheStrategy, ToolCatalog, ToolProvider }; +export type { CacheStrategy, ToolCatalog, ToolCatalogEntry, ToolCatalogOperation, ToolProvider }; // --------------------------------------------------------------------------- // chooseTools — provider-shaped tool list with optional cache markers diff --git a/packages/sdk/langs/python/superdoc/presets/legacy.py b/packages/sdk/langs/python/superdoc/presets/legacy.py index e43f305bb9..33647150eb 100644 --- a/packages/sdk/langs/python/superdoc/presets/legacy.py +++ b/packages/sdk/langs/python/superdoc/presets/legacy.py @@ -147,8 +147,16 @@ def _legacy_get_tools(provider: ToolProvider, *, cache: bool = False) -> Dict[st raise SuperDocError('provider is required.', code='INVALID_ARGUMENT', details={'provider': provider}) provider_file = _read_json_asset(_PROVIDER_FILE[provider]) tools = provider_file.get('tools') - raw_tools = tools if isinstance(tools, list) else [] - return _apply_cache_markers(cast(List[Any], raw_tools), provider, cache) + # Fail fast on malformed provider artifacts so agents don't silently boot + # with zero tools. Matches the Node legacy preset's behavior and the + # pre-presets contract of the public list_tools path. + if not isinstance(tools, list): + raise SuperDocError( + 'Tool provider bundle is missing tools array.', + code='TOOLS_ASSET_INVALID', + details={'provider': provider}, + ) + return _apply_cache_markers(cast(List[Any], tools), provider, cache) def _legacy_get_catalog() -> Dict[str, Any]: diff --git a/packages/sdk/langs/python/superdoc/tools_api.py b/packages/sdk/langs/python/superdoc/tools_api.py index abcf051fc5..87847c16a2 100644 --- a/packages/sdk/langs/python/superdoc/tools_api.py +++ b/packages/sdk/langs/python/superdoc/tools_api.py @@ -77,7 +77,12 @@ def choose_tools(input: ToolChooserInput) -> Dict[str, Any]: details={'provider': provider}, ) - preset_id = input.get('preset') or DEFAULT_PRESET + # Default only when `preset` is absent. An explicit empty string is passed + # through to get_preset() so it raises PRESET_NOT_FOUND, matching Node/MCP + # fail-fast behavior. Using `or DEFAULT_PRESET` would silently treat + # `preset: ''` as legacy and hide misconfiguration. + preset_arg = input.get('preset') + preset_id = preset_arg if preset_arg is not None else DEFAULT_PRESET cache_requested = bool(input.get('cache')) preset = get_preset(preset_id) diff --git a/packages/sdk/langs/python/tests/test_presets.py b/packages/sdk/langs/python/tests/test_presets.py index 2a64add2d6..7178496b37 100644 --- a/packages/sdk/langs/python/tests/test_presets.py +++ b/packages/sdk/langs/python/tests/test_presets.py @@ -57,6 +57,21 @@ def test_get_preset_nonexistent_raises_preset_not_found(): assert 'legacy' in excinfo.value.details['availablePresets'] +def test_get_preset_empty_string_raises_preset_not_found(): + """Empty string is NOT the default — it must fail fast like Node.""" + with pytest.raises(SuperDocError) as excinfo: + get_preset('') + assert excinfo.value.code == 'PRESET_NOT_FOUND' + + +def test_choose_tools_empty_preset_raises_preset_not_found(): + """Cross-lang parity with Node: chooseTools({preset: ''}) must throw, not + silently use legacy.""" + with pytest.raises(SuperDocError) as excinfo: + choose_tools({'provider': 'openai', 'preset': ''}) + assert excinfo.value.code == 'PRESET_NOT_FOUND' + + # --------------------------------------------------------------------------- # choose_tools — default preset equivalence # --------------------------------------------------------------------------- From 955b2a3b28ef9c41dbe0566d741d1796f7e44059 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 29 May 2026 20:08:14 -0300 Subject: [PATCH 10/11] refactor(demo): single-use clause library + brand-blue fields Make the clause library a single-use inclusion checklist instead of a duplicate stamp tool. A clause is either "In contract" or available to "Add clause": a clause already placed can't be inserted again - clicking its card reveals the existing section, while an available card adds it (click or drag) and then flips to "In contract". Drops the "used N times" surface. This teaches the right model: fields are reusable variables, clauses are governed sections included once. - Add a library-only "Return of Materials" clause carrying a nested Receiving party slot, so insert-with-nested-fields stays demonstrable now that the seeded Permitted Use is "In contract" and no longer insertable. - Recolor fields and clauses to the SuperDoc brand blue (--sd-color-blue-500/600, per brand.md) instead of amber. They render as tinted/outlined pills, so they stay distinct from the solid-blue primary buttons. - Update tests (single-use status badges, add-once-no-duplicate, nested-field on add), the README, and code comments to the single-use + blue model. --- .../contract-templates-smart-tags.spec.ts | 72 +++++++----- demos/contract-templates/README.md | 4 +- demos/contract-templates/src/main.ts | 104 +++++++++++++----- demos/contract-templates/src/style.css | 68 ++++++++---- 4 files changed, 172 insertions(+), 76 deletions(-) diff --git a/demos/__tests__/contract-templates-smart-tags.spec.ts b/demos/__tests__/contract-templates-smart-tags.spec.ts index 82008ad27f..face61bf61 100644 --- a/demos/__tests__/contract-templates-smart-tags.spec.ts +++ b/demos/__tests__/contract-templates-smart-tags.spec.ts @@ -132,7 +132,7 @@ test('a smart-field pill does not shift its box on hover or click (no jitter)', } }); -test('a block clause keeps its amber left rail and box across hover/select (no jitter)', async ({ page }) => { +test('a block clause keeps its left rail and box across hover/select (no jitter)', async ({ page }) => { test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only'); await page.route('**/ingest.superdoc.dev/**', (r) => @@ -148,7 +148,7 @@ test('a block clause keeps its amber left rail and box across hover/select (no j await page.waitForSelector(sel); // Block SDTs strip border + fill on .sdt-group-hover / .ProseMirror-selectednode; - // the demo overrides them. Guard the 4px amber left rail and box stay constant. + // the demo overrides them. Guard the 4px left rail and box stay constant. const box = () => page.evaluate((s) => { const el = document.querySelector(s) as HTMLElement; @@ -289,7 +289,7 @@ test('a field value broadcasts to every occurrence, including one nested in a lo .toBe(2); }); -test('clicking a clause card inserts a locked block clause at the cursor', async ({ page }) => { +test('the clause library is single-use: seeded clauses are In contract, others Add clause', async ({ page }) => { test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only'); await page.route('**/ingest.superdoc.dev/**', (r) => @@ -303,37 +303,60 @@ test('clicking a clause card inserts a locked block clause at the cursor', async ); await page.waitForSelector('.clause[data-clause-id]'); - // Caret in the (unlocked) title so the clause inserts at a clean block boundary. + // A seeded clause is already in the contract; a library-only one is available. + await expect(page.locator('.clause[data-clause-id="permittedUse"] .clause-status')).toHaveText('In contract'); + await expect(page.locator('.clause[data-clause-id="permittedUse"]')).toHaveClass(/is-present/); + await expect(page.locator('.clause[data-clause-id="indemnification"] .clause-status')).toHaveText('Add clause'); + await expect(page.locator('.clause[data-clause-id="indemnification"]')).toHaveClass(/is-available/); +}); + +test('clicking an available clause adds it once (single-use, then In contract)', async ({ page }) => { + test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only'); + + await page.route('**/ingest.superdoc.dev/**', (r) => + r.fulfill({ status: 204, contentType: 'application/json', body: '{}' }), + ); + await page.goto('/'); + await page.waitForFunction( + () => (window as any).__demo?.state?.ui?.contentControls?.getSnapshot()?.items?.length > 0, + null, + { timeout: 30_000 }, + ); + await page.waitForSelector('.clause[data-clause-id="indemnification"]'); + + // Caret in the (unlocked) title so the clause adds at a clean block boundary. await page.evaluate(() => { (window as any).__demo.superdoc.activeEditor.commands?.setTextSelection?.({ from: 6, to: 6 }); }); - const sectionId = await page.getAttribute('.clause[data-clause-id]', 'data-clause-id'); - expect(sectionId).toBeTruthy(); - - // Count controls for this clause + confirm they're all locked. - const clauseInfo = () => - page.evaluate((sid) => { + const indemnificationInfo = () => + page.evaluate(() => { const doc = (window as any).__demo.doc(); const items = doc.contentControls.list({}).items.filter((c: any) => { try { - return JSON.parse(c.properties?.tag ?? '{}').sectionId === sid; + return JSON.parse(c.properties?.tag ?? '{}').sectionId === 'indemnification'; } catch { return false; } }); return { count: items.length, allLocked: items.every((c: any) => c.lockMode === 'contentLocked') }; - }, sectionId); + }); + + expect((await indemnificationInfo()).count).toBe(0); + await page.click('.clause[data-clause-id="indemnification"]'); - const before = await clauseInfo(); - await page.click(`.clause[data-clause-id="${sectionId}"]`); + // It's added once, locked, and the card flips to In contract. + await expect.poll(async () => (await indemnificationInfo()).count, { timeout: 6_000 }).toBe(1); + expect((await indemnificationInfo()).allLocked).toBe(true); + await expect(page.locator('.clause[data-clause-id="indemnification"] .clause-status')).toHaveText('In contract'); - // A new block clause for this section appears, and every occurrence is locked. - await expect.poll(async () => (await clauseInfo()).count, { timeout: 6_000 }).toBe(before.count + 1); - expect((await clauseInfo()).allLocked).toBe(true); + // Clicking again does NOT duplicate it (single-use; reveals the existing one). + await page.click('.clause[data-clause-id="indemnification"]'); + await page.waitForTimeout(500); + expect((await indemnificationInfo()).count).toBe(1); }); -test('inserting Permitted Use nests real smart fields that fill from the form', async ({ page }) => { +test('adding the Return of Materials clause nests a real smart field that fills from the form', async ({ page }) => { test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only'); await page.route('**/ingest.superdoc.dev/**', (r) => @@ -345,15 +368,14 @@ test('inserting Permitted Use nests real smart fields that fill from the form', null, { timeout: 30_000 }, ); - await page.waitForSelector('.clause[data-clause-id="permittedUse"]'); + await page.waitForSelector('.clause[data-clause-id="returnOfMaterials"]'); - // Caret in the (unlocked) title so the clause inserts at a clean block boundary. + // Caret in the (unlocked) title so the clause adds at a clean block boundary. await page.evaluate(() => { (window as any).__demo.superdoc.activeEditor.commands?.setTextSelection?.({ from: 6, to: 6 }); }); - // Count Receiving party smart fields in the document (an inline structuredContent - // whose tag carries that key) - the Permitted Use clause carries one as a slot. + // Receiving party smart fields in the document (Return of Materials carries one). const receivingPartyControls = () => page.evaluate(() => { const doc = (window as any).__demo.doc(); @@ -362,13 +384,13 @@ test('inserting Permitted Use nests real smart fields that fill from the form', }); const before = (await receivingPartyControls()).length; // 2 seeded - await page.click('.clause[data-clause-id="permittedUse"]'); + await page.click('.clause[data-clause-id="returnOfMaterials"]'); - // Inserting the clause adds a real nested Receiving party SDT (not plain text). + // Adding the clause creates a real nested Receiving party SDT (not plain text). await expect.poll(async () => (await receivingPartyControls()).length, { timeout: 6_000 }).toBe(before + 1); // Filling Receiving party in the Values form reaches every occurrence, - // including the one just nested inside the inserted clause. + // including the one just nested inside the added clause. await page.click('.tab[data-tab="values"]'); await page.fill('input[data-field="receivingParty"]', 'Beacon Bio'); await expect diff --git a/demos/contract-templates/README.md b/demos/contract-templates/README.md index 8c0ae35184..d2d974512d 100644 --- a/demos/contract-templates/README.md +++ b/demos/contract-templates/README.md @@ -12,8 +12,8 @@ The starting document is `public/nda-template.docx`: inline plain-text fields an **Template tab, the building-block library.** Two catalogs, fields and clauses, each styled to match what it inserts: -- Smart-field chips wear the same amber token look as the in-document field (CSS on `.superdoc-structured-content-inline[data-sdt-tag*='smartField']`). Drag a chip onto the document, or click to insert it at the cursor. An unfilled field shows its field-name token (e.g. `DISCLOSING_PARTY`) as a stand-in placeholder. That token is literal text content, not a native SDT placeholder. -- Clause cards wear the same amber block look as the in-document clause and carry metadata: category, jurisdiction, version, and how many times the clause is placed ("Used 2 times"). The catalog includes clauses that aren't in the document yet. Drag a card onto the document, or click to insert it at the cursor. +- Smart-field chips wear the same blue token look as the in-document field (CSS on `.superdoc-structured-content-inline[data-sdt-tag*='smartField']`). Drag a chip onto the document, or click to insert it at the cursor. An unfilled field shows its field-name token (e.g. `DISCLOSING_PARTY`) as a stand-in placeholder. That token is literal text content, not a native SDT placeholder. +- Clause cards wear the same blue block look as the in-document clause and carry metadata (category, jurisdiction, version) and a status. A clause is single-use, like an inclusion checklist: a card already in the contract reads **In contract** and clicking it reveals the existing clause; an available card reads **Add clause** and drags or clicks in. The catalog includes clauses that aren't in the document yet (e.g. Indemnification, Return of Materials). Inserts resolve the drop point with `ui.viewport.positionAt({ x, y })` and create the control with `editor.doc.create.contentControl({ kind, at, content, tag, lockMode })`. A field inserts inline at the exact caret; a clause snaps to a block boundary so it lands as a clean section instead of splitting a paragraph. Clicking a control in the document highlights its chip or card (`content-control:click`). diff --git a/demos/contract-templates/src/main.ts b/demos/contract-templates/src/main.ts index 8dc201f35e..adc6cd3d75 100644 --- a/demos/contract-templates/src/main.ts +++ b/demos/contract-templates/src/main.ts @@ -18,11 +18,13 @@ * - Every control is `contentLocked`, so it can't be edited by typing in the * document. This is a locked template surface; the custom UI drives changes. * - Template tab = the building-block library. Two catalogs: smart-field chips - * and clause cards (each with category / jurisdiction / version and a "used - * N times" count). Drag or click a chip to insert an inline field, or a card - * to insert a block clause. An unfilled field shows its field-name token - * (e.g. DISCLOSING_PARTY) as a stand-in placeholder - literal text content, - * not a native SDT placeholder. + * (reusable variables) and clause cards (governed sections, single-use). A + * chip drags/clicks in as an inline field. A clause card shows category / + * jurisdiction / version and a status: "Add clause" when available (drag or + * click to add) or "In contract" once placed (click reveals the existing one + * - a clause appears once, like an inclusion checklist). An unfilled field + * shows its field-name token (e.g. DISCLOSING_PARTY) as a stand-in + * placeholder - literal text content, not a native SDT placeholder. * - A clause is assembled from structured `parts`: prose plus `{ field }` * slots. Inserting it creates the block and wraps each slot as a nested, * locked inline smart field - so an inserted "Permitted Use" carries real @@ -113,7 +115,8 @@ type ClauseId = | 'termination' | 'governingLaw' | 'limitationOfLiability' - | 'indemnification'; + | 'indemnification' + | 'returnOfMaterials'; type ClauseCategory = 'Core' | 'Confidentiality' | 'Termination' | 'Risk Allocation'; @@ -202,7 +205,7 @@ const CLAUSE_LIBRARY: LibraryClause[] = [ parts: ['Each party’s aggregate liability under this Agreement is limited to fees paid in the twelve (12) months preceding the claim.'], }, { - // A library-only clause: not in the seeded document, so it starts "Used 0". + // A library-only clause: not in the seeded document, so it starts "Add clause". // Insert it to add a new governed section to the contract. id: 'indemnification', label: 'Indemnification', @@ -211,6 +214,20 @@ const CLAUSE_LIBRARY: LibraryClause[] = [ version: 'v1', parts: ['Each party will indemnify and hold the other harmless from third-party claims arising out of its breach of this Agreement.'], }, + { + // Library-only and carries a nested field slot: adding it shows that an + // inserted clause's embedded variables become real, broadcast-linked SDTs. + id: 'returnOfMaterials', + label: 'Return of Materials', + category: 'Confidentiality', + jurisdiction: 'General', + version: 'v1', + parts: [ + 'Upon termination or at the disclosing party’s request, ', + { field: 'receivingParty' }, + ' will promptly return or destroy all Confidential Information in its possession.', + ], + }, ]; // --------------------------------------------------------------------------- @@ -592,7 +609,7 @@ async function insertClause(clauseId: ClauseId, blockId: string): Promise } // Lock the clause now that its slots are wrapped, then refresh the cards - // ("Used N") and scroll the new clause into view. + // (the card flips to "In contract") and scroll the new clause into view. reportMutation(doc.contentControls.setLockMode({ target: clauseTarget, lockMode: 'contentLocked' }), 'Lock clause'); renderClausesPanel(); void ui.contentControls.scrollIntoView({ id: clauseTarget.nodeId, block: 'center' }); @@ -602,9 +619,14 @@ async function insertClause(clauseId: ClauseId, blockId: string): Promise function insertClauseAtCursor(clauseId: ClauseId): void { const ui = state.ui; if (!ui || !state.editor?.doc) return; + // Single-use: if it's already in the contract, reveal it instead of duplicating. + if (isClauseInDocument(clauseId)) { + revealClause(clauseId); + return; + } const seg = ui.selection.capture()?.target?.segments?.[0]; if (!seg) { - setStatus('Place the cursor in the document (or drag the clause in), then click a clause to insert it.'); + setStatus('Place the cursor in the document, then click a clause to add it.'); return; } void insertClause(clauseId, seg.blockId); @@ -651,7 +673,10 @@ function setupPaletteDragDrop(): () => void { insertField(field.key, field.label, { kind: 'selection', start: point, end: point }); } else if (clauseId) { const clause = CLAUSE_LIBRARY.find((c) => c.id === clauseId); - if (clause) void insertClause(clause.id, hit.point.blockId); // offset 0 (block boundary) + if (!clause) return; + // Single-use: a clause already in the contract reveals instead of duplicating. + if (isClauseInDocument(clause.id)) revealClause(clause.id); + else void insertClause(clause.id, hit.point.blockId); // offset 0 (block boundary) } }; @@ -785,8 +810,10 @@ function highlightActiveClause(): void { /** * Template tab: the contract's building blocks. Two catalogs - inline Smart tags - * (variable chips) and block Clauses (cards with metadata + usage count). Both - * drag or click to insert; values are filled on the Values tab. + * (reusable variable chips, drag or click to insert) and block Clauses (governed, + * single-use cards with metadata + a status; an available clause adds by drag or + * click, one already in the contract reveals it). Values are filled on the + * Values tab. */ function renderFieldsPanel(): void { fieldsPanelEl.innerHTML = ''; @@ -797,7 +824,7 @@ function renderFieldsPanel(): void { /** * Clauses section of the Template tab: a search + the clause cards. Mirrors the * Smart-tags section's style (group header, search) but the clauses render as - * compact amber cards, not pills, since they're block controls. Creates the + * compact blue cards, not pills, since they're block controls. Creates the * list container (clausesListEl) that renderClausesPanel re-renders into. */ function renderClausesSection(): void { @@ -873,11 +900,11 @@ function renderValuesPanel(): void { /** * Render the clause cards: one card per clause, styled like the in-document - * block clause (amber left rail). Like the smart-tag chips, a card is draggable + * block clause (blue left rail). Like the smart-tag chips, a card is draggable * into the document or click-to-insert at the cursor (insertClause snaps to a * block boundary). A card highlights when its clause is clicked in the document. */ -/** Every control in the document for a given clause (a clause can be placed more than once). */ +/** Every control in the document for a given clause (used internally for counts). */ function clauseControls(clauseId: ClauseId): ContentControlInfo[] { const doc = state.editor?.doc; if (!doc) return []; @@ -887,32 +914,51 @@ function clauseControls(clauseId: ClauseId): ContentControlInfo[] { }); } +/** A clause is single-use: it's either in the contract or available to add. */ +function isClauseInDocument(clauseId: ClauseId): boolean { + return clauseControls(clauseId).length > 0; +} + +/** Scroll the clause's placement into view and highlight its card. */ +function revealClause(clauseId: ClauseId): void { + const ctrl = clauseControls(clauseId)[0]; + if (!state.ui || !ctrl) return; + state.activeClauseId = clauseId; + highlightActiveClause(); + void state.ui.contentControls.scrollIntoView({ id: ctrl.target.nodeId, block: 'center' }); +} + /** - * Render the clause library catalog: a card per available clause with its - * category / jurisdiction / version and how many times it's placed in the - * document. A card wears the in-document block clause's amber look. Drag a card - * in, or click to insert it at the cursor; the card highlights when its clause - * is clicked in the document. + * Render the clause library as a single-use inclusion checklist. Each card shows + * the clause's category / jurisdiction / version and whether it's "In contract" + * or available to "Add clause". A clause is governed and appears once: a card + * that's already in the contract can't be inserted again - clicking it reveals + * the existing clause instead; an available card inserts (click) or drags in. */ function renderClausesPanel(): void { const list = clausesListEl; if (!list) return; list.innerHTML = ''; for (const clause of CLAUSE_LIBRARY) { - const used = clauseControls(clause.id).length; - const usedText = used === 0 ? 'Not used' : `Used ${used} time${used === 1 ? '' : 's'}`; + const inDoc = isClauseInDocument(clause.id); const card = document.createElement('article'); - card.className = 'clause' + (clause.id === state.activeClauseId ? ' is-active' : ''); + card.className = + 'clause ' + (inDoc ? 'is-present' : 'is-available') + (clause.id === state.activeClauseId ? ' is-active' : ''); card.dataset.clauseId = clause.id; - card.draggable = true; - card.title = `Drag into the document, or click to insert the ${clause.label} clause at the cursor`; + card.draggable = !inDoc; // single-use: can't drag a clause that's already in + card.title = inDoc + ? `${clause.label} is in the contract — click to reveal it` + : `Drag into the document, or click to add the ${clause.label} clause at the cursor`; card.innerHTML = ` -

${escapeHtml(clause.label)}

-

${escapeHtml(clause.category)} · ${escapeHtml(clause.jurisdiction)} · ${escapeHtml(clause.version)} · ${escapeHtml(usedText)}

+
+

${escapeHtml(clause.label)}

+ ${inDoc ? 'In contract' : 'Add clause'} +
+

${escapeHtml(clause.category)} · ${escapeHtml(clause.jurisdiction)} · ${escapeHtml(clause.version)}

`; - card.addEventListener('click', () => insertClauseAtCursor(clause.id)); + card.addEventListener('click', () => (isClauseInDocument(clause.id) ? revealClause(clause.id) : insertClauseAtCursor(clause.id))); card.addEventListener('dragstart', (event) => { - if (!event.dataTransfer) return; + if (!event.dataTransfer || isClauseInDocument(clause.id)) return; event.dataTransfer.setData(CLAUSE_MIME, clause.id); event.dataTransfer.effectAllowed = 'copy'; }); diff --git a/demos/contract-templates/src/style.css b/demos/contract-templates/src/style.css index dab6743569..ae499069a1 100644 --- a/demos/contract-templates/src/style.css +++ b/demos/contract-templates/src/style.css @@ -186,10 +186,11 @@ input:focus { /* ----------------------------------------------------------------------- Clause library (Template tab) - A catalog of governed clauses. Each card echoes the in-document block clause - (amber left rail, soft border, faint amber fill) and shows its metadata and - how many times it's placed. Drag a card in, or click to insert at the cursor. - The clauses themselves are locked blocks. + A single-use catalog of governed clauses. Each card echoes the in-document + block clause (blue left rail, soft border, faint blue fill) and shows its + metadata plus a status: an available clause ("Add clause") drags or clicks in, + while one already in the contract ("In contract") is a quieter card that + clicks to reveal the existing section. The clauses themselves are locked blocks. ----------------------------------------------------------------------- */ .clauses-section { margin-top: 16px; } @@ -201,13 +202,25 @@ input:focus { background-color: var(--tag-block-bg); padding: 7px 10px; margin-bottom: 6px; - cursor: grab; } .clause:hover { background-color: var(--tag-block-bg-hover); } -.clause:active { cursor: grabbing; } .clause.is-active { box-shadow: 0 0 0 2px var(--tag-color); } +/* Available clauses drag/click to add; clauses already in the contract are a + quieter card you click to reveal the existing section. */ +.clause.is-available { cursor: grab; } +.clause.is-available:active { cursor: grabbing; } +.clause.is-present { cursor: pointer; } +.clause.is-present { background-color: var(--tag-block-bg); } +.clause-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} .clause-label { margin: 0; + flex: 1; + min-width: 0; font-size: var(--sd-font-size-300, 13px); font-weight: 600; color: var(--demo-text); @@ -215,6 +228,23 @@ input:focus { text-overflow: ellipsis; white-space: nowrap; } +.clause-status { + flex-shrink: 0; + font-size: var(--sd-font-size-100, 10px); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 1px 6px; + border-radius: 999px; +} +.clause.is-present .clause-status { + color: var(--demo-text-muted); + background: color-mix(in srgb, var(--demo-text-muted) 14%, transparent); +} +.clause.is-available .clause-status { + color: var(--tag-fg); + background: var(--tag-bg); +} .clause-meta { margin: 2px 0 0; font-size: var(--sd-font-size-100, 11px); @@ -244,18 +274,16 @@ input:focus { /* Smart-tag token look, shared by the in-editor inline SDT and the Smart-tags palette chips so a sidebar tag and the field it inserts read as one object. */ :root { - /* Template fields wear a deliberate amber identity, distinct from the - SuperDoc-blue UI accent (--demo-accent), so a reader can see "this is a - template variable" at a glance. Amber isn't a built-in --sd-* token, so - this is an intentional demo-local accent (the theming the demo is showing - off, per demos/AGENTS.md). One token set, applied to both the palette chip - and the painted in-editor field. */ - --tag-color: var(--sd-color-amber-600, #d97706); + /* Template fields use the SuperDoc brand blue (brand.md: blue-500 / blue-600), + via the --sd-color-blue-* tokens. One token set, applied to both the palette + chip and the painted in-editor field. Fields read as tinted/outlined pills, + so they stay distinct from the solid-blue primary buttons. */ + --tag-color: var(--sd-color-blue-500, #1355ff); --tag-border: var(--tag-color); --tag-bg: color-mix(in srgb, var(--tag-color) 12%, transparent); - --tag-fg: var(--sd-color-amber-700, #b45309); + --tag-fg: var(--sd-color-blue-600, #0f44cc); --tag-bg-hover: color-mix(in srgb, var(--tag-color) 22%, transparent); - /* Block clauses are large regions, so they wear the amber language quietly: + /* Block clauses are large regions, so they wear the same blue quietly: a left rail + a faint fill + a soft border, lighter than the inline pill. */ --tag-block-border: color-mix(in srgb, var(--tag-color) 30%, var(--demo-border)); --tag-block-bg: color-mix(in srgb, var(--tag-color) 4%, var(--demo-bg)); @@ -274,7 +302,7 @@ input:focus { } .superdoc-cc-chrome-none .superdoc-structured-content-inline[data-sdt-tag*='smartField']:hover { /* Under chrome:'none' SuperDoc resets the field's border + fill (including on - hover) so the consumer owns the look. We re-assert the amber box so hover + hover) so the consumer owns the look. We re-assert the box so hover never moves or recolors the field. The !important wins over that reset without coupling to SuperDoc's selector specificity — a custom-UI styling rough edge today (no first-class per-control styling hook yet). */ @@ -283,8 +311,8 @@ input:focus { } /* Selecting a field is a ProseMirror NodeSelection (.ProseMirror-selectednode). Under chrome:'none' SuperDoc resets the border + fill to transparent in that - state too; without re-asserting, the field loses its amber and the box can - shift (~2px) on click. Keep the same box and a controlled amber "selected" + state too; without re-asserting, the field loses its fill and the box can + shift (~2px) on click. Keep the same box and a controlled blue "selected" fill so hover/click/selected stay on-brand and never move the field. */ .superdoc-cc-chrome-none .superdoc-structured-content-inline[data-sdt-tag*='smartField'].ProseMirror-selectednode { /* !important to win over the chrome-none reset; same rough edge as hover. */ @@ -336,9 +364,9 @@ input:focus { .smart-tag.is-active { box-shadow: 0 0 0 2px var(--demo-accent); } .smart-tag:focus-visible { outline: 1px solid var(--demo-accent); outline-offset: 1px; } -/* Block clauses: a quiet card with an amber left rail — same field language as +/* Block clauses: a quiet card with a blue left rail, same field language as the inline pills, but a region not a token: soft border, faint fill, a 4px - amber spine. */ + blue spine. */ .superdoc-cc-chrome-none .superdoc-structured-content-block[data-sdt-tag*='reusableSection'] { border: 1px solid var(--tag-block-border); border-left: 4px solid var(--tag-color); From 1371d49308c3473f89c87aa8c42a6a54599a021a Mon Sep 17 00:00:00 2001 From: Nick Bernal <117235294+harbournick@users.noreply.github.com> Date: Fri, 29 May 2026 21:10:47 -0700 Subject: [PATCH 11/11] fix: nested replacement tracked-change decisions (#3557) --- .../v2/importer/trackedChangeIdMapper.js | 101 +++++++++++-- .../v1/document-api-adapters/find-adapter.ts | 4 +- .../v1/document-api-adapters/find/common.ts | 60 +++++++- .../find/text-strategy.ts | 17 ++- .../document-api-adapters/get-text-adapter.ts | 2 +- .../helpers/adapter-utils.ts | 11 +- .../helpers/sd-projection.ts | 139 +++++++++++------- .../helpers/selection-target-resolver.ts | 13 +- .../helpers/text-offset-resolver.test.ts | 42 +++++- .../helpers/text-offset-resolver.ts | 72 ++++++++- .../helpers/text-with-tabs.ts | 8 + .../plan-engine/compiler.ts | 79 +++++++--- .../plan-engine/executor.ts | 4 +- .../plan-engine/query-match-adapter.ts | 25 ++-- .../plan-engine/style-resolver.test.ts | 23 +++ .../plan-engine/style-resolver.ts | 17 ++- .../tracked-rewrite.integration.test.ts | 91 ++++++++++++ .../review-model/decision-engine.js | 106 ++++++++++++- .../review-model/decision-engine.test.js | 60 ++++++++ .../review-model/overlap-compiler.js | 53 ++++--- .../review-model/overlap-compiler.test.js | 43 +++--- .../trackChangesHelpers/replaceStep.js | 7 +- ...er-multi-paragraph-tracked-changes.spec.ts | 20 ++- 23 files changed, 827 insertions(+), 170 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js index 2710d1b6c0..ce9562724f 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js @@ -3,8 +3,8 @@ import { v4 as uuidv4 } from 'uuid'; /** * @typedef {'paired' | 'independent'} TrackChangesReplacements - * @typedef {{ type: string, author: string, date: string, internalId: string }} TrackedChangeEntry - * @typedef {{ lastTrackedChange: TrackedChangeEntry | null, replacements: TrackChangesReplacements }} WalkContext + * @typedef {{ type: string, author: string, date: string, internalId?: string }} TrackedChangeEntry + * @typedef {{ beforeLastTrackedChange: TrackedChangeEntry | null, lastTrackedChange: TrackedChangeEntry | null, replacements: TrackChangesReplacements }} WalkContext */ const TRACKED_CHANGE_NAMES = new Set(['w:ins', 'w:del']); @@ -44,6 +44,66 @@ function isReplacementPair(previous, current) { return previous.type !== current.type && previous.author === current.author && previous.date === current.date; } +/** + * @param {object} element + * @returns {TrackedChangeEntry} + */ +function trackedChangeEntryFromElement(element) { + return { + type: element.name, + author: element.attributes?.['w:author'] ?? '', + date: element.attributes?.['w:date'] ?? '', + }; +} + +/** + * Returns the next sibling tracked-change element, skipping only non-content + * markers. Content-bearing elements terminate the sibling check because they + * break Word replacement adjacency. + * + * @param {Array} elements + * @param {number} startIndex + * @returns {TrackedChangeEntry | null} + */ +function findNextSiblingTrackedChange(elements, startIndex) { + if (!Array.isArray(elements)) return null; + + for (let i = startIndex; i < elements.length; i += 1) { + const element = elements[i]; + if (TRACKED_CHANGE_NAMES.has(element?.name)) { + return trackedChangeEntryFromElement(element); + } + if (!PAIRING_TRANSPARENT_NAMES.has(element?.name)) { + return null; + } + } + + return null; +} + +/** + * Word serializes a replacement selected inside another author's deletion as + * child insertion/deletion sides surrounded by the parent deletion fragments. + * In paired mode the generic adjacent-replacement heuristic would otherwise + * collapse the child sides into one replacement. Keep them independent when + * either side of the candidate pair touches a different-author deletion. + * + * @param {TrackedChangeEntry | null} beforePrevious + * @param {TrackedChangeEntry} previous + * @param {TrackedChangeEntry} current + * @param {TrackedChangeEntry | null} next + * @returns {boolean} + */ +function isChildReplacementInsideDeletion(beforePrevious, previous, current, next) { + if (!isReplacementPair(previous, current)) return false; + + const touchesDifferentAuthorDeletionBefore = + beforePrevious?.type === 'w:del' && beforePrevious.author !== previous.author; + const touchesDifferentAuthorDeletionAfter = next?.type === 'w:del' && next.author !== previous.author; + + return touchesDifferentAuthorDeletionBefore || touchesDifferentAuthorDeletionAfter; +} + /** * Assigns an internal UUID to a tracked change element. In paired mode, * adjacent replacement halves (w:del + w:ins with matching author/date) @@ -53,8 +113,9 @@ function isReplacementPair(previous, current) { * @param {Map} idMap Accumulates Word ID → internal UUID * @param {WalkContext} context Mutable walk state for replacement pairing * @param {boolean} insideTrackedChange Whether this element is nested in another tracked change + * @param {TrackedChangeEntry | null} nextTrackedChange */ -function assignInternalId(element, idMap, context, insideTrackedChange) { +function assignInternalId(element, idMap, context, insideTrackedChange, nextTrackedChange = null) { const wordId = String(element.attributes?.['w:id'] ?? ''); if (!wordId) return; @@ -66,15 +127,24 @@ function assignInternalId(element, idMap, context, insideTrackedChange) { return; } - const current = { - type: element.name, - author: element.attributes?.['w:author'] ?? '', - date: element.attributes?.['w:date'] ?? '', - }; + const current = trackedChangeEntryFromElement(element); const shouldPair = context.replacements === 'paired'; + const shouldKeepChildSides = + context.lastTrackedChange && + isChildReplacementInsideDeletion( + context.beforeLastTrackedChange, + context.lastTrackedChange, + current, + nextTrackedChange, + ); - if (shouldPair && context.lastTrackedChange && isReplacementPair(context.lastTrackedChange, current)) { + if ( + shouldPair && + context.lastTrackedChange && + !shouldKeepChildSides && + isReplacementPair(context.lastTrackedChange, current) + ) { // Second half of a replacement — share the first half's UUID, but only // if this w:id hasn't already been mapped. A reused id that was already // part of an earlier pair must keep its original mapping. @@ -82,6 +152,7 @@ function assignInternalId(element, idMap, context, insideTrackedChange) { idMap.set(wordId, context.lastTrackedChange.internalId); } context.lastTrackedChange = null; + context.beforeLastTrackedChange = null; } else { // Reuse an existing mapping when the same w:id appears more than once // (Word reuses tracked-change ids across the document). Minting a fresh @@ -89,6 +160,7 @@ function assignInternalId(element, idMap, context, insideTrackedChange) { // pair that was already recorded for this id. const internalId = idMap.get(wordId) ?? uuidv4(); idMap.set(wordId, internalId); + context.beforeLastTrackedChange = context.lastTrackedChange; context.lastTrackedChange = { ...current, internalId }; } } @@ -105,9 +177,11 @@ function assignInternalId(element, idMap, context, insideTrackedChange) { function walkElements(elements, idMap, context, insideTrackedChange = false) { if (!Array.isArray(elements)) return; - for (const element of elements) { + for (let index = 0; index < elements.length; index += 1) { + const element = elements[index]; if (TRACKED_CHANGE_NAMES.has(element.name)) { - assignInternalId(element, idMap, context, insideTrackedChange); + const nextTrackedChange = findNextSiblingTrackedChange(elements, index + 1); + assignInternalId(element, idMap, context, insideTrackedChange, nextTrackedChange); if (element.elements) { // Descend with an isolated context so content inside a tracked change @@ -116,7 +190,7 @@ function walkElements(elements, idMap, context, insideTrackedChange = false) { walkElements( element.elements, idMap, - { lastTrackedChange: null, replacements: context.replacements }, + { beforeLastTrackedChange: null, lastTrackedChange: null, replacements: context.replacements }, /* insideTrackedChange */ true, ); } @@ -125,6 +199,7 @@ function walkElements(elements, idMap, context, insideTrackedChange = false) { // markers (comment/bookmark/permission ranges) are transparent. if (!PAIRING_TRANSPARENT_NAMES.has(element.name)) { context.lastTrackedChange = null; + context.beforeLastTrackedChange = null; } if (element.elements) { @@ -150,7 +225,7 @@ function buildTrackedChangeIdMapForPart(part, options = {}) { const replacements = options.replacements === 'independent' ? 'independent' : 'paired'; const idMap = new Map(); - walkElements(root.elements, idMap, { lastTrackedChange: null, replacements }); + walkElements(root.elements, idMap, { beforeLastTrackedChange: null, lastTrackedChange: null, replacements }); return idMap; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/find-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/find-adapter.ts index 54d1013680..27436d9e70 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/find-adapter.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/find-adapter.ts @@ -249,12 +249,12 @@ function projectMatchToSDNodeResult( const found = blockIndex.candidates.find((c) => c.nodeType === address.nodeType && c.nodeId === address.nodeId); if (!found) return null; return { - node: projectContentNode(found.node), + node: projectContentNode(found.node, { textModel: 'visible' }), address, }; } return { - node: projectContentNode(candidate.node), + node: projectContentNode(candidate.node, { textModel: 'visible' }), address, }; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/find/common.ts b/packages/super-editor/src/editors/v1/document-api-adapters/find/common.ts index 974a961ae4..88e3327f50 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/find/common.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/find/common.ts @@ -8,7 +8,7 @@ import type { UnknownNodeDiagnostic, } from '@superdoc/document-api'; import { toId } from '../helpers/value-utils.js'; -import { getInlineIndex } from '../helpers/index-cache.js'; +import { getBlockIndex, getInlineIndex } from '../helpers/index-cache.js'; import { findBlockById, toBlockAddress, @@ -17,6 +17,7 @@ import { } from '../helpers/node-address-resolver.js'; import { findInlineByAnchor, isInlineQueryType } from '../helpers/inline-address-resolver.js'; import { findCandidateByPos } from '../helpers/adapter-utils.js'; +import { pmPositionToTextOffset, textContentInBlock, type TextOffsetOptions } from '../helpers/text-offset-resolver.js'; /** Characters of document text to include before and after a match in snippet context. */ const SNIPPET_PADDING = 30; @@ -49,6 +50,13 @@ const KNOWN_INLINE_PM_NODE_TYPES = new Set([ 'commentRangeEnd', ]); +function getCandidateText(editor: Editor, candidate: BlockCandidate, options?: TextOffsetOptions): string { + if (candidate.node.childCount > 0) { + return textContentInBlock(candidate.node, options); + } + return editor.state.doc.textBetween(candidate.pos + 1, candidate.end - 1, '\n', '\ufffc'); +} + function resolveUnknownBlockId(attrs: Record | undefined): string | undefined { if (!attrs) return undefined; return toId(attrs.paraId) ?? toId(attrs.sdBlockId) ?? toId(attrs.blockId) ?? toId(attrs.id) ?? toId(attrs.uuid); @@ -85,7 +93,46 @@ export function buildTextContext( matchFrom: number, matchTo: number, textRanges?: TextAddress[], + options?: TextOffsetOptions, ): MatchContext { + if (textRanges?.length) { + const index = getBlockIndex(editor); + const firstRange = textRanges[0]; + const lastRange = textRanges[textRanges.length - 1]; + const firstBlock = index.candidates.find((candidate) => candidate.nodeId === firstRange.blockId); + const lastBlock = index.candidates.find((candidate) => candidate.nodeId === lastRange.blockId); + + if (firstBlock && lastBlock) { + const matchText = textRanges + .map((range) => { + const block = index.candidates.find((candidate) => candidate.nodeId === range.blockId); + if (!block) return ''; + return getCandidateText(editor, block, options).slice(range.range.start, range.range.end); + }) + .join('\n'); + const firstText = getCandidateText(editor, firstBlock, options); + const lastText = getCandidateText(editor, lastBlock, options); + const leftContext = firstText.slice( + Math.max(0, firstRange.range.start - SNIPPET_PADDING), + firstRange.range.start, + ); + const rightContext = lastText.slice(lastRange.range.end, lastRange.range.end + SNIPPET_PADDING); + const snippet = `${leftContext}${matchText}${rightContext}`.replace(/ {2,}/g, ' '); + const prefix = leftContext.replace(/ {2,}/g, ' '); + const normalizedMatch = matchText.replace(/ {2,}/g, ' '); + + return { + address, + snippet, + highlightRange: { + start: prefix.length, + end: prefix.length + normalizedMatch.length, + }, + textRanges, + }; + } + } + const docSize = editor.state.doc.content.size; const snippetFrom = Math.max(0, matchFrom - SNIPPET_PADDING); const snippetTo = Math.min(docSize, matchTo + SNIPPET_PADDING); @@ -120,13 +167,20 @@ export function toTextAddress( editor: Editor, block: BlockCandidate, range: { from: number; to: number }, + options?: TextOffsetOptions, ): TextAddress | undefined { const blockStart = block.pos + 1; const blockEnd = block.end - 1; if (range.from < blockStart || range.to > blockEnd) return undefined; - const start = editor.state.doc.textBetween(blockStart, range.from, '\n', '\ufffc').length; - const end = editor.state.doc.textBetween(blockStart, range.to, '\n', '\ufffc').length; + const start = + block.node.childCount > 0 + ? pmPositionToTextOffset(block.node, block.pos, range.from, options) + : editor.state.doc.textBetween(blockStart, range.from, '\n', '\ufffc').length; + const end = + block.node.childCount > 0 + ? pmPositionToTextOffset(block.node, block.pos, range.to, options) + : editor.state.doc.textBetween(blockStart, range.to, '\n', '\ufffc').length; return { kind: 'text', diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/find/text-strategy.ts b/packages/super-editor/src/editors/v1/document-api-adapters/find/text-strategy.ts index a24765be07..1b3bea1fd4 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/find/text-strategy.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/find/text-strategy.ts @@ -19,6 +19,7 @@ import { addDiagnostic, findCandidateByPos, paginate, resolveWithinScope } from import { buildTextContext, toTextAddress } from './common.js'; import { DocumentApiAdapterError } from '../errors.js'; import { requireEditorCommand } from '../helpers/mutation-helpers.js'; +import type { TextOffsetModel } from '../helpers/text-offset-resolver.js'; /** Shape returned by `editor.commands.search`. */ type SearchMatch = { @@ -30,6 +31,10 @@ type SearchMatch = { /** Maximum allowed pattern length to guard against ReDoS and excessive memory usage. */ const MAX_PATTERN_LENGTH = 1024; +export type TextSelectorSearchModel = Extract; +export type ExecuteTextSelectorOptions = { + searchModel?: TextSelectorSearchModel; +}; function compileRegex(selector: TextSelector, diagnostics: UnknownNodeDiagnostic[]): RegExp | null { if (selector.pattern.length > MAX_PATTERN_LENGTH) { @@ -81,6 +86,7 @@ export function executeTextSelector( index: BlockIndex, query: Query, diagnostics: UnknownNodeDiagnostic[], + options: ExecuteTextSelectorOptions = {}, ): QueryResult { if (query.select.type !== 'text') { addDiagnostic(diagnostics, `Text strategy received a non-text selector (type="${query.select.type}").`); @@ -100,12 +106,15 @@ export function executeTextSelector( if (!pattern) return { matches: [], total: 0 }; const search = requireEditorCommand(editor.commands?.search, 'find (search)'); + const searchModel = options.searchModel ?? 'visible'; + const textOffsetOptions = { textModel: searchModel }; + pattern.lastIndex = 0; const rawResult = search(pattern, { highlight: false, caseSensitive: selector.caseSensitive ?? false, maxMatches: Infinity, - searchModel: 'visible', + searchModel, }); if (!Array.isArray(rawResult)) { @@ -114,9 +123,9 @@ export function executeTextSelector( 'Editor search command returned an unexpected result format.', ); } - const allMatches = rawResult as SearchMatch[]; const scopeRange = scope.range; + const allMatches = rawResult as SearchMatch[]; const matches = scopeRange ? allMatches.filter((m) => m.from >= scopeRange.start && m.to <= scopeRange.end) : allMatches; @@ -133,7 +142,7 @@ export function executeTextSelector( const block = findCandidateByPos(textBlocks, range.from); if (!block) return undefined; if (!source) source = block; - return toTextAddress(editor, block, range); + return toTextAddress(editor, block, range, textOffsetOptions); }) .filter((range): range is TextAddress => Boolean(range)); @@ -144,7 +153,7 @@ export function executeTextSelector( const address = toBlockAddress(source); addresses.push(address); - contexts.push(buildTextContext(editor, address, match.from, match.to, textRanges)); + contexts.push(buildTextContext(editor, address, match.from, match.to, textRanges, textOffsetOptions)); } const paged = paginate(addresses, query.offset, query.limit); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/get-text-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/get-text-adapter.ts index a9628e0ccd..e4dd3c8c2b 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/get-text-adapter.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/get-text-adapter.ts @@ -16,5 +16,5 @@ import { textBetweenWithTabs } from './helpers/text-with-tabs.js'; export function getTextAdapter(editor: Editor, input: GetTextInput): string { const runtime = resolveStoryRuntime(editor, input.in); const doc = runtime.editor.state.doc; - return textBetweenWithTabs(doc, 0, doc.content.size, '\n', '\n'); + return textBetweenWithTabs(doc, 0, doc.content.size, '\n', '\n', { textModel: 'visible' }); } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/adapter-utils.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/adapter-utils.ts index 5917cfd775..2f6167e99b 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/adapter-utils.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/adapter-utils.ts @@ -74,7 +74,7 @@ export function resolveTextTarget(editor: Editor, target: TextAddress): Resolved assertUnambiguous(matches, target.blockId); const block = matches[0]; if (!block) return null; - return resolveTextRangeInBlock(block.node, block.pos, target.range); + return resolveTextRangeInBlock(block.node, block.pos, target.range, { textModel: 'visible' }); } /** @@ -167,8 +167,13 @@ export function resolveDefaultInsertTarget(editor: Editor): DefaultInsertTarget for (let i = index.candidates.length - 1; i >= 0; i--) { const candidate = index.candidates[i]; if (topLevelPositions.has(candidate.pos) && isTextBlockCandidate(candidate)) { - const textLength = computeTextContentLength(candidate.node); - const range = resolveTextRangeInBlock(candidate.node, candidate.pos, { start: textLength, end: textLength }); + const textLength = computeTextContentLength(candidate.node, { textModel: 'visible' }); + const range = resolveTextRangeInBlock( + candidate.node, + candidate.pos, + { start: textLength, end: textLength }, + { textModel: 'visible' }, + ); if (!range) continue; return { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/sd-projection.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/sd-projection.ts index c1dad5a706..452312e7b4 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/sd-projection.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/sd-projection.ts @@ -45,6 +45,18 @@ import type { ParagraphAttrs, TableAttrs, TableCellAttrs, ImageAttrs } from '../ import { getHeadingLevel } from './node-address-resolver.js'; import { parseTocInstruction } from '../../core/super-converter/field-references/shared/toc-switches.js'; import { resolveSectionProjections, type SectionProjection } from './sections-resolver.js'; +import { textContentInBlock, type TextOffsetOptions } from './text-offset-resolver.js'; +import { TrackDeleteMarkName } from '../../extensions/track-changes/constants.js'; + +type ProjectionOptions = TextOffsetOptions; + +function isVisibleProjection(options?: ProjectionOptions): boolean { + return options?.textModel === 'visible'; +} + +function hasTrackDeleteMark(pmNode: ProseMirrorNode): boolean { + return pmNode.marks?.some((mark) => mark.type.name === TrackDeleteMarkName) ?? false; +} // --------------------------------------------------------------------------- // Public API @@ -53,15 +65,15 @@ import { resolveSectionProjections, type SectionProjection } from './sections-re /** * Projects a single ProseMirror content node into an SDM/1 SDContentNode. */ -export function projectContentNode(pmNode: ProseMirrorNode): SDContentNode { - return projectBlock(pmNode); +export function projectContentNode(pmNode: ProseMirrorNode, options?: ProjectionOptions): SDContentNode { + return projectBlock(pmNode, options); } /** * Projects a ProseMirror text node (with marks) into SDM/1 inline nodes. */ -export function projectInlineNode(pmNode: ProseMirrorNode): SDInlineNode { - return projectInline(pmNode); +export function projectInlineNode(pmNode: ProseMirrorNode, options?: ProjectionOptions): SDInlineNode { + return projectInline(pmNode, options) ?? { kind: 'run', run: { text: '' } }; } /** @@ -163,9 +175,10 @@ export function resolveTextByBlockId( export function projectDocument(editor: Editor, options?: SDReadOptions): SDDocument { const doc = editor.state.doc; const body: SDContentNode[] = []; + const projectionOptions: ProjectionOptions = { textModel: 'visible' }; doc.forEach((child) => { - body.push(projectBlock(child)); + body.push(projectBlock(child, projectionOptions)); }); const sections = projectSections(editor); @@ -319,28 +332,28 @@ interface TranslatedLevel { // Block-level dispatch // --------------------------------------------------------------------------- -function projectBlock(pmNode: ProseMirrorNode): SDContentNode { +function projectBlock(pmNode: ProseMirrorNode, options?: ProjectionOptions): SDContentNode { const typeName = pmNode.type.name; switch (typeName) { case 'paragraph': - return projectParagraphOrHeading(pmNode); + return projectParagraphOrHeading(pmNode, options); case 'heading': - return projectHeadingNode(pmNode); + return projectHeadingNode(pmNode, options); case 'table': - return projectTable(pmNode); + return projectTable(pmNode, options); case 'bulletList': case 'orderedList': - return projectList(pmNode, typeName === 'orderedList'); + return projectList(pmNode, typeName === 'orderedList', options); case 'listItem': - return projectListItemAsContent(pmNode); + return projectListItemAsContent(pmNode, options); case 'image': return projectBlockImage(pmNode); case 'tableOfContents': return projectToc(pmNode); case 'sdt': case 'structuredContentBlock': - return projectBlockSdt(pmNode); + return projectBlockSdt(pmNode, options); case 'sectionBreak': return projectSectionBreak(pmNode); case 'pageBreak': @@ -349,7 +362,7 @@ function projectBlock(pmNode: ProseMirrorNode): SDContentNode { case 'drawing': return projectBlockDrawing(pmNode); default: - return projectFallbackBlock(pmNode); + return projectFallbackBlock(pmNode, options); } } @@ -357,26 +370,30 @@ function projectBlock(pmNode: ProseMirrorNode): SDContentNode { // Paragraph / Heading // --------------------------------------------------------------------------- -function projectParagraphOrHeading(pmNode: ProseMirrorNode): SDParagraph | SDHeading { +function projectParagraphOrHeading(pmNode: ProseMirrorNode, options?: ProjectionOptions): SDParagraph | SDHeading { const attrs = pmNode.attrs as ParagraphAttrs | undefined; const headingLevel = getHeadingLevel(attrs?.paragraphProperties?.styleId); if (headingLevel && headingLevel >= 1 && headingLevel <= 6) { - return buildHeading(pmNode, attrs, headingLevel as 1 | 2 | 3 | 4 | 5 | 6); + return buildHeading(pmNode, attrs, headingLevel as 1 | 2 | 3 | 4 | 5 | 6, options); } - return buildParagraph(pmNode, attrs); + return buildParagraph(pmNode, attrs, options); } -function projectHeadingNode(pmNode: ProseMirrorNode): SDHeading { +function projectHeadingNode(pmNode: ProseMirrorNode, options?: ProjectionOptions): SDHeading { const attrs = pmNode.attrs as ParagraphAttrs | undefined; const rawLevel = (pmNode.attrs as any)?.level; const level = (rawLevel ?? getHeadingLevel(attrs?.paragraphProperties?.styleId) ?? 1) as 1 | 2 | 3 | 4 | 5 | 6; - return buildHeading(pmNode, attrs, level); + return buildHeading(pmNode, attrs, level, options); } -function buildParagraph(pmNode: ProseMirrorNode, attrs: ParagraphAttrs | undefined): SDParagraph { - const inlines = projectInlineChildren(pmNode); +function buildParagraph( + pmNode: ProseMirrorNode, + attrs: ParagraphAttrs | undefined, + options?: ProjectionOptions, +): SDParagraph { + const inlines = projectInlineChildren(pmNode, options); const result: SDParagraph = { kind: 'paragraph', id: resolveNodeId(pmNode), @@ -396,8 +413,9 @@ function buildHeading( pmNode: ProseMirrorNode, attrs: ParagraphAttrs | undefined, level: 1 | 2 | 3 | 4 | 5 | 6, + options?: ProjectionOptions, ): SDHeading { - const inlines = projectInlineChildren(pmNode); + const inlines = projectInlineChildren(pmNode, options); const result: SDHeading = { kind: 'heading', id: resolveNodeId(pmNode), @@ -417,14 +435,14 @@ function buildHeading( // Table // --------------------------------------------------------------------------- -function projectTable(pmNode: ProseMirrorNode): SDTable { +function projectTable(pmNode: ProseMirrorNode, options?: ProjectionOptions): SDTable { const attrs = pmNode.attrs as TableAttrs | undefined; const pmAttrs = pmNode.attrs as Record; const rows: SDTableRow[] = []; pmNode.forEach((child) => { if (child.type.name === 'tableRow') { - rows.push(projectTableRow(child)); + rows.push(projectTableRow(child, options)); } }); @@ -464,11 +482,11 @@ function projectTable(pmNode: ProseMirrorNode): SDTable { return result; } -function projectTableRow(pmNode: ProseMirrorNode): SDTableRow { +function projectTableRow(pmNode: ProseMirrorNode, options?: ProjectionOptions): SDTableRow { const cells: SDTableCell[] = []; pmNode.forEach((child) => { if (child.type.name === 'tableCell' || child.type.name === 'tableHeader') { - cells.push(projectTableCell(child)); + cells.push(projectTableCell(child, options)); } }); @@ -482,11 +500,11 @@ function projectTableRow(pmNode: ProseMirrorNode): SDTableRow { return row; } -function projectTableCell(pmNode: ProseMirrorNode): SDTableCell { +function projectTableCell(pmNode: ProseMirrorNode, options?: ProjectionOptions): SDTableCell { const attrs = pmNode.attrs as TableCellAttrs | undefined; const content: SDContentNode[] = []; pmNode.forEach((child) => { - content.push(projectBlock(child)); + content.push(projectBlock(child, options)); }); const cell: SDTableCell = { content }; @@ -506,11 +524,11 @@ function projectTableCell(pmNode: ProseMirrorNode): SDTableCell { // List // --------------------------------------------------------------------------- -function projectList(pmNode: ProseMirrorNode, ordered: boolean): SDList { +function projectList(pmNode: ProseMirrorNode, ordered: boolean, options?: ProjectionOptions): SDList { const items: SDListItem[] = []; pmNode.forEach((child) => { if (child.type.name === 'listItem') { - items.push(projectListItem(child)); + items.push(projectListItem(child, options)); } }); @@ -529,10 +547,10 @@ function projectList(pmNode: ProseMirrorNode, ordered: boolean): SDList { return result; } -function projectListItem(pmNode: ProseMirrorNode): SDListItem { +function projectListItem(pmNode: ProseMirrorNode, options?: ProjectionOptions): SDListItem { const content: SDContentNode[] = []; pmNode.forEach((child) => { - content.push(projectBlock(child)); + content.push(projectBlock(child, options)); }); const item: SDListItem = { @@ -544,8 +562,8 @@ function projectListItem(pmNode: ProseMirrorNode): SDListItem { } /** When a listItem appears at top-level (orphan), wrap it in a paragraph. */ -function projectListItemAsContent(pmNode: ProseMirrorNode): SDParagraph { - const inlines = projectInlineChildren(pmNode); +function projectListItemAsContent(pmNode: ProseMirrorNode, options?: ProjectionOptions): SDParagraph { + const inlines = projectInlineChildren(pmNode, options); return { kind: 'paragraph', id: resolveNodeId(pmNode), @@ -616,10 +634,10 @@ function extractSdtMetadata(attrs: Record): Omit { - children.push(projectBlock(child)); + children.push(projectBlock(child, options)); }); return { @@ -633,8 +651,8 @@ function projectBlockSdt(pmNode: ProseMirrorNode): SDSdt { }; } -function projectInlineSdt(pmNode: ProseMirrorNode): SDSdt { - const inlines = projectInlineChildren(pmNode); +function projectInlineSdt(pmNode: ProseMirrorNode, options?: ProjectionOptions): SDSdt { + const inlines = projectInlineChildren(pmNode, options); return { kind: 'sdt', @@ -690,8 +708,8 @@ function projectBlockDrawing(pmNode: ProseMirrorNode): SDContentNode { // Fallback block // --------------------------------------------------------------------------- -function projectFallbackBlock(pmNode: ProseMirrorNode): SDParagraph { - const inlines = projectInlineChildren(pmNode); +function projectFallbackBlock(pmNode: ProseMirrorNode, options?: ProjectionOptions): SDParagraph { + const inlines = projectInlineChildren(pmNode, options); return { kind: 'paragraph', id: resolveNodeId(pmNode), @@ -703,23 +721,23 @@ function projectFallbackBlock(pmNode: ProseMirrorNode): SDParagraph { // Inline projection // --------------------------------------------------------------------------- -function projectInlineChildren(pmNode: ProseMirrorNode): SDInlineNode[] { +function projectInlineChildren(pmNode: ProseMirrorNode, options?: ProjectionOptions): SDInlineNode[] { const inlines: SDInlineNode[] = []; pmNode.forEach((child) => { - const projected = projectInline(child); - inlines.push(projected); + const projected = projectInline(child, options); + if (projected) inlines.push(projected); }); return inlines; } -function projectInline(pmNode: ProseMirrorNode): SDInlineNode { +function projectInline(pmNode: ProseMirrorNode, options?: ProjectionOptions): SDInlineNode | null { if (pmNode.isText) { - return projectTextRun(pmNode); + return projectTextRun(pmNode, options); } switch (pmNode.type.name) { case 'run': - return projectRunNode(pmNode); + return projectRunNode(pmNode, options); case 'image': return projectInlineImage(pmNode); case 'tab': @@ -736,9 +754,9 @@ function projectInline(pmNode: ProseMirrorNode): SDInlineNode { case 'field': return projectInlineField(pmNode); case 'structuredContent': - return projectInlineSdt(pmNode); + return projectInlineSdt(pmNode, options); default: - return projectInlineFallback(pmNode); + return projectInlineFallback(pmNode, options); } } @@ -746,10 +764,11 @@ function projectInline(pmNode: ProseMirrorNode): SDInlineNode { // Run node (SuperDoc schema: paragraph → run → text) // --------------------------------------------------------------------------- -function projectRunNode(pmNode: ProseMirrorNode): SDRun | SDHyperlink { +function projectRunNode(pmNode: ProseMirrorNode, options?: ProjectionOptions): SDRun | SDHyperlink | null { const attrs = (pmNode.attrs ?? {}) as Record; const runProperties = attrs.runProperties as Record | undefined; - const text = pmNode.textContent; + const text = isVisibleProjection(options) ? textContentInBlock(pmNode, options) : pmNode.textContent; + if (isVisibleProjection(options) && text.length === 0) return null; // Check for hyperlink wrapping via link mark on children let linkMark: ProseMirrorMark | undefined; @@ -760,7 +779,7 @@ function projectRunNode(pmNode: ProseMirrorNode): SDRun | SDHyperlink { }); if (linkMark) { - return buildHyperlinkFromRunNode(pmNode, linkMark); + return buildHyperlinkFromRunNode(pmNode, linkMark, options); } const run: SDRun = { kind: 'run', run: { text } }; @@ -779,11 +798,17 @@ function projectRunNode(pmNode: ProseMirrorNode): SDRun | SDHyperlink { return run; } -function buildHyperlinkFromRunNode(pmNode: ProseMirrorNode, linkMark: ProseMirrorMark): SDHyperlink { +function buildHyperlinkFromRunNode( + pmNode: ProseMirrorNode, + linkMark: ProseMirrorMark, + options?: ProjectionOptions, +): SDHyperlink | null { const attrs = linkMark.attrs as Record; + const text = isVisibleProjection(options) ? textContentInBlock(pmNode, options) : pmNode.textContent; + if (isVisibleProjection(options) && text.length === 0) return null; const childRun: SDRun = { kind: 'run', - run: { text: pmNode.textContent }, + run: { text }, }; const runProperties = (pmNode.attrs as Record)?.runProperties; @@ -918,7 +943,9 @@ function extractRunPropsFromRunProperties(runProperties: Record | u // Text run (bare PM text nodes — used in schemas without the run node wrapper) // --------------------------------------------------------------------------- -function projectTextRun(pmNode: ProseMirrorNode): SDRun | SDHyperlink { +function projectTextRun(pmNode: ProseMirrorNode, options?: ProjectionOptions): SDRun | SDHyperlink | null { + if (isVisibleProjection(options) && hasTrackDeleteMark(pmNode)) return null; + const marks = pmNode.marks; // Check if wrapped in a link mark → hyperlink @@ -1025,10 +1052,12 @@ function projectInlineField(pmNode: ProseMirrorNode): SDField { }; } -function projectInlineFallback(pmNode: ProseMirrorNode): SDRun { +function projectInlineFallback(pmNode: ProseMirrorNode, options?: ProjectionOptions): SDRun | null { + const text = isVisibleProjection(options) ? textContentInBlock(pmNode, options) : (pmNode.textContent ?? '\ufffc'); + if (isVisibleProjection(options) && text.length === 0) return null; return { kind: 'run', - run: { text: pmNode.textContent ?? '\ufffc' }, + run: { text }, }; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-target-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-target-resolver.ts index 0e08690c56..6b4b7e8fbd 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-target-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-target-resolver.ts @@ -54,10 +54,15 @@ function resolveTextPoint( }); } - const resolved = resolveTextRangeInBlock(candidate.node, candidate.pos, { - start: point.offset, - end: point.offset, - }); + const resolved = resolveTextRangeInBlock( + candidate.node, + candidate.pos, + { + start: point.offset, + end: point.offset, + }, + { textModel: 'visible' }, + ); if (!resolved) { throw new DocumentApiAdapterError( 'INVALID_TARGET', diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.test.ts index bf372bea9a..eb1374ccc8 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.test.ts @@ -1,8 +1,14 @@ import type { Node as ProseMirrorNode } from 'prosemirror-model'; -import { computeTextContentLength, pmPositionToTextOffset, resolveTextRangeInBlock } from './text-offset-resolver.js'; +import { + computeTextContentLength, + pmPositionToTextOffset, + resolveTextRangeInBlock, + textContentInBlock, +} from './text-offset-resolver.js'; type NodeOptions = { text?: string; + marks?: Array<{ type: { name: string } }>; isInline?: boolean; isBlock?: boolean; isLeaf?: boolean; @@ -31,6 +37,7 @@ function createNode(typeName: string, children: ProseMirrorNode[] = [], options: inlineContent, isTextblock: inlineContent, isLeaf, + marks: options.marks ?? [], childCount: children.length, child(index: number) { return children[index]!; @@ -120,6 +127,17 @@ describe('resolveTextRangeInBlock', () => { expect(result).toEqual({ from: 5, to: 6 }); }); + + it('maps visible offsets across tracked deleted text without counting it', () => { + const textA = createNode('text', [], { text: 'A' }); + const deleted = createNode('text', [], { text: 'gone', marks: [{ type: { name: 'trackDelete' } }] }); + const textB = createNode('text', [], { text: 'B' }); + const paragraph = createNode('paragraph', [textA, deleted, textB], { isBlock: true, inlineContent: true }); + + const result = resolveTextRangeInBlock(paragraph, 0, { start: 1, end: 2 }, { textModel: 'visible' }); + + expect(result).toEqual({ from: 6, to: 7 }); + }); }); describe('computeTextContentLength', () => { @@ -175,6 +193,17 @@ describe('computeTextContentLength', () => { expect(computeTextContentLength(paragraph)).toBe(2); }); + + it('excludes tracked deleted text in the visible text model', () => { + const textA = createNode('text', [], { text: 'A' }); + const deleted = createNode('text', [], { text: 'gone', marks: [{ type: { name: 'trackDelete' } }] }); + const textB = createNode('text', [], { text: 'B' }); + const paragraph = createNode('paragraph', [textA, deleted, textB], { isBlock: true, inlineContent: true }); + + expect(computeTextContentLength(paragraph)).toBe(6); + expect(computeTextContentLength(paragraph, { textModel: 'visible' })).toBe(2); + expect(textContentInBlock(paragraph, { textModel: 'visible' })).toBe('AB'); + }); }); describe('pmPositionToTextOffset', () => { @@ -228,4 +257,15 @@ describe('pmPositionToTextOffset', () => { // Past-end PM positions clamp to block length. expect(pmPositionToTextOffset(paragraph, 0, 1000)).toBe(2); }); + + it('keeps PM positions inside tracked deletions at the surrounding visible offset', () => { + const textA = createNode('text', [], { text: 'A' }); + const deleted = createNode('text', [], { text: 'gone', marks: [{ type: { name: 'trackDelete' } }] }); + const textB = createNode('text', [], { text: 'B' }); + const paragraph = createNode('paragraph', [textA, deleted, textB], { isBlock: true, inlineContent: true }); + + expect(pmPositionToTextOffset(paragraph, 0, 3, { textModel: 'visible' })).toBe(1); + expect(pmPositionToTextOffset(paragraph, 0, 6, { textModel: 'visible' })).toBe(1); + expect(pmPositionToTextOffset(paragraph, 0, 7, { textModel: 'visible' })).toBe(2); + }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.ts index 6cbfbe942a..5395f05ce1 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.ts @@ -1,4 +1,5 @@ import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import { TrackDeleteMarkName } from '../../extensions/track-changes/constants.js'; export type TextOffsetRange = { start: number; @@ -10,6 +11,24 @@ export type ResolvedTextRange = { to: number; }; +export type TextOffsetModel = 'raw' | 'visible'; + +export type TextOffsetOptions = { + textModel?: TextOffsetModel; +}; + +function isVisibleTextModel(options?: TextOffsetOptions): boolean { + return options?.textModel === 'visible'; +} + +function hasTrackDeleteMark(node: ProseMirrorNode): boolean { + return node.marks?.some((mark) => mark.type.name === TrackDeleteMarkName) ?? false; +} + +function shouldSkipTextNode(node: ProseMirrorNode, options?: TextOffsetOptions): boolean { + return isVisibleTextModel(options) && hasTrackDeleteMark(node); +} + function resolveSegmentPosition( targetOffset: number, segmentStart: number, @@ -35,7 +54,12 @@ function resolveSegmentPosition( * (`run`, etc.) or leaf atoms, because PM positions include wrapper * boundary tokens that the flattened model does not. */ -export function pmPositionToTextOffset(blockNode: ProseMirrorNode, blockPos: number, pmPos: number): number { +export function pmPositionToTextOffset( + blockNode: ProseMirrorNode, + blockPos: number, + pmPos: number, + options?: TextOffsetOptions, +): number { const contentStart = blockPos + 1; if (pmPos <= contentStart) return 0; @@ -48,6 +72,10 @@ export function pmPositionToTextOffset(blockNode: ProseMirrorNode, blockPos: num if (node.isText) { const text = node.text ?? ''; const endPos = docPos + text.length; + if (shouldSkipTextNode(node, options)) { + if (pmPos < endPos) done = true; + return; + } if (pmPos >= endPos) { offset += text.length; } else { @@ -103,11 +131,12 @@ export function pmPositionToTextOffset(blockNode: ProseMirrorNode, blockPos: num * offset model as {@link resolveTextRangeInBlock}: text contributes its * length, leaf atoms contribute 1, block separators contribute 1. */ -export function computeTextContentLength(blockNode: ProseMirrorNode): number { +export function computeTextContentLength(blockNode: ProseMirrorNode, options?: TextOffsetOptions): number { let length = 0; const walk = (node: ProseMirrorNode): void => { if (node.isText) { + if (shouldSkipTextNode(node, options)) return; length += (node.text ?? '').length; return; } @@ -149,6 +178,7 @@ export function resolveTextRangeInBlock( blockNode: ProseMirrorNode, blockPos: number, range: TextOffsetRange, + options?: TextOffsetOptions, ): ResolvedTextRange | null { if (range.start < 0 || range.end < range.start) return null; @@ -192,7 +222,7 @@ export function resolveTextRangeInBlock( const walkNode = (node: ProseMirrorNode, docPos: number) => { if (node.isText) { const text = node.text ?? ''; - if (text.length > 0) { + if (text.length > 0 && !shouldSkipTextNode(node, options)) { advanceSegment(text.length, docPos, docPos + text.length); } return; @@ -219,3 +249,39 @@ export function resolveTextRangeInBlock( if (fromPos == null || toPos == null) return null; return { from: fromPos, to: toPos }; } + +export function textContentInBlock(blockNode: ProseMirrorNode, options?: TextOffsetOptions): string { + let text = ''; + + const walkNode = (node: ProseMirrorNode): void => { + if (node.isText) { + if (!shouldSkipTextNode(node, options)) { + text += node.text ?? ''; + } + return; + } + + if (node.isLeaf) { + text += '\ufffc'; + return; + } + + let isFirstChild = true; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child.isBlock && !isFirstChild) text += '\n'; + walkNode(child); + isFirstChild = false; + } + }; + + let isFirstChild = true; + for (let i = 0; i < blockNode.childCount; i++) { + const child = blockNode.child(i); + if (child.isBlock && !isFirstChild) text += '\n'; + walkNode(child); + isFirstChild = false; + } + + return text; +} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.ts index c69f17e653..3a44a8582f 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.ts @@ -7,6 +7,7 @@ import type { Schema, } from 'prosemirror-model'; import type { Transaction } from 'prosemirror-state'; +import { TrackDeleteMarkName } from '../../extensions/track-changes/constants.js'; /** * Build a text-or-fragment suitable for insertion, splitting on '\t' and @@ -79,6 +80,7 @@ export function textBetweenWithTabs( to: number, blockSeparator: string, leafFallback: string, + options: { textModel?: 'raw' | 'visible' } = {}, ): string { // Defensive path for mocked docs: when `nodesBetween` isn't available, fall // back to the legacy `textBetween` semantics with no tab handling. Real PM @@ -108,6 +110,12 @@ export function textBetweenWithTabs( return false; } if (node.isText) { + if ( + options.textModel === 'visible' && + node.marks?.some((mark: ProseMirrorMark) => mark.type.name === TrackDeleteMarkName) + ) { + return false; + } const start = Math.max(from, pos) - pos; const end = Math.min(to, pos + node.nodeSize) - pos; // In real PM, node.text is always a string of length nodeSize. Some tests diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/compiler.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/compiler.ts index ba6f2c8005..a9f0ecbbb8 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/compiler.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/compiler.ts @@ -40,7 +40,12 @@ import { type BlockCandidate, type BlockIndex, } from '../helpers/node-address-resolver.js'; -import { resolveTextRangeInBlock } from '../helpers/text-offset-resolver.js'; +import { + resolveTextRangeInBlock, + textContentInBlock, + type TextOffsetModel, + type TextOffsetOptions, +} from '../helpers/text-offset-resolver.js'; import { resolveSelectionTarget, resolveSelectionPointPosition } from '../helpers/selection-target-resolver.js'; import { expandDeleteSelection } from '../helpers/expand-delete-selection.js'; @@ -238,6 +243,18 @@ interface ResolvedAddress { text: string; marks: readonly unknown[]; blockPos: number; + textModel: TextOffsetModel; +} + +export interface CompilePlanOptions { + /** + * Text model used only for `where.by = "select"` text selectors. + * + * Public discovery refs and explicit SelectionTargets stay visible-offset + * based. Tracked authoring selectors can opt into raw/review text so they + * can address unresolved deletion text without changing public read APIs. + */ + selectTextModel?: TextOffsetModel; } // --------------------------------------------------------------------------- @@ -250,8 +267,9 @@ function resolveAbsoluteRange( from: number, to: number, stepId: string, + options?: TextOffsetOptions, ): { absFrom: number; absTo: number } { - const resolved = resolveTextRangeInBlock(candidate.node, candidate.pos, { start: from, end: to }); + const resolved = resolveTextRangeInBlock(candidate.node, candidate.pos, { start: from, end: to }, options); if (!resolved) { throw planError('INVALID_INPUT', `text offset [${from}, ${to}) out of range in block`, stepId); } @@ -381,10 +399,11 @@ function buildRangeTarget( addr: ResolvedAddress, candidate: Pick, ): CompiledRangeTarget { - const abs = resolveAbsoluteRange(editor, candidate, addr.from, addr.to, step.id); + const textOffsetOptions = { textModel: addr.textModel }; + const abs = resolveAbsoluteRange(editor, candidate, addr.from, addr.to, step.id, textOffsetOptions); const capturedStyle = step.op === 'text.rewrite' || step.op === 'format.apply' - ? captureRunsInRange(editor, candidate.pos, addr.from, addr.to) + ? captureRunsInRange(editor, candidate.pos, addr.from, addr.to, textOffsetOptions) : undefined; return { @@ -412,6 +431,7 @@ function buildSpanTarget( step: MutationStep, segments: Array<{ blockId: string; from: number; to: number }>, matchId: string, + textModel: TextOffsetModel = 'visible', ): CompiledSpanTarget { // Validate segment ordering and contiguity in document order validateSegmentOrder(editor, index, segments, step.id); @@ -426,7 +446,8 @@ function buildSpanTarget( throw planError('INVALID_INPUT', `block "${seg.blockId}" not found for span segment`, step.id); } - const abs = resolveAbsoluteRange(editor, candidate, seg.from, seg.to, step.id); + const textOffsetOptions = { textModel }; + const abs = resolveAbsoluteRange(editor, candidate, seg.from, seg.to, step.id, textOffsetOptions); compiledSegments.push({ blockId: seg.blockId, from: seg.from, @@ -435,11 +456,11 @@ function buildSpanTarget( absTo: abs.absTo, }); - const blockText = getBlockText(editor, candidate); + const blockText = getBlockText(editor, candidate, textOffsetOptions); textParts.push(blockText.slice(seg.from, seg.to)); if (step.op === 'text.rewrite' || step.op === 'format.apply') { - capturedStyles.push(captureRunsInRange(editor, candidate.pos, seg.from, seg.to)); + capturedStyles.push(captureRunsInRange(editor, candidate.pos, seg.from, seg.to, textOffsetOptions)); } } @@ -516,15 +537,17 @@ function resolveTextSelector( selector: TextSelector | NodeSelector, within: import('@superdoc/document-api').BlockNodeAddress | undefined, stepId: string, - options?: { allBlockTypes?: boolean }, + options?: { allBlockTypes?: boolean; textModel?: TextOffsetModel }, ): { addresses: ResolvedAddress[] } { + const textModel = options?.textModel ?? 'visible'; + const textOffsetOptions = { textModel }; if (selector.type === 'text') { const query = { select: selector, within: within as import('@superdoc/document-api').BlockNodeAddress | undefined, includeNodes: false, }; - const result = executeTextSelector(editor, index, query, []); + const result = executeTextSelector(editor, index, query, [], { searchModel: textModel }); const addresses: ResolvedAddress[] = []; @@ -536,9 +559,9 @@ function resolveTextSelector( const candidate = index.candidates.find((c) => c.nodeId === coalesced.blockId); if (!candidate) continue; - const blockText = getBlockText(editor, candidate); + const blockText = getBlockText(editor, candidate, textOffsetOptions); const matchText = blockText.slice(coalesced.from, coalesced.to); - const captured = captureRunsInRange(editor, candidate.pos, coalesced.from, coalesced.to); + const captured = captureRunsInRange(editor, candidate.pos, coalesced.from, coalesced.to, textOffsetOptions); addresses.push({ blockId: coalesced.blockId, @@ -547,6 +570,7 @@ function resolveTextSelector( text: matchText, marks: captured.runs.length > 0 ? captured.runs[0].marks : [], blockPos: candidate.pos, + textModel, }); } } @@ -573,7 +597,7 @@ function resolveTextSelector( if (!candidate) continue; if (isTextBlockCandidate(candidate)) { - const blockText = getBlockText(editor, candidate); + const blockText = getBlockText(editor, candidate, textOffsetOptions); addresses.push({ blockId: match.nodeId, from: 0, @@ -581,6 +605,7 @@ function resolveTextSelector( text: blockText, marks: [], blockPos: candidate.pos, + textModel, }); } else { // Non-text block (table, image wrapper, etc.): no text offsets needed. @@ -592,6 +617,7 @@ function resolveTextSelector( text: '', marks: [], blockPos: candidate.pos, + textModel, }); } } @@ -599,7 +625,14 @@ function resolveTextSelector( return { addresses }; } -function getBlockText(editor: Editor, candidate: { pos: number; end: number }): string { +function getBlockText( + editor: Editor, + candidate: { node?: BlockCandidate['node']; pos: number; end: number }, + options: TextOffsetOptions = { textModel: 'visible' }, +): string { + if (candidate.node && candidate.node.childCount > 0) { + return textContentInBlock(candidate.node, options); + } const blockStart = candidate.pos + 1; const blockEnd = candidate.end - 1; return editor.state.doc.textBetween(blockStart, blockEnd, '\n', '\ufffc'); @@ -655,6 +688,7 @@ function resolveV3TextRef(editor: Editor, index: BlockIndex, step: MutationStep, text: matchText, marks: [], blockPos: candidate.pos, + textModel: 'visible', }; const target = buildRangeTarget(editor, step, addr, candidate); @@ -663,7 +697,7 @@ function resolveV3TextRef(editor: Editor, index: BlockIndex, step: MutationStep, } // Multi-segment match refs → span target - return [buildSpanTarget(editor, index, step, segments, refData.matchId)]; + return [buildSpanTarget(editor, index, step, segments, refData.matchId, 'visible')]; } /** @@ -728,6 +762,7 @@ function resolveV4TextRef( text: matchText, marks: [], blockPos: candidate.pos, + textModel: 'visible', }; const target = buildRangeTarget(editor, step, addr, candidate); @@ -736,7 +771,7 @@ function resolveV4TextRef( } // Multi-segment → span target - return [buildSpanTarget(editor, index, step, segments, refData.matchId ?? `v4:${step.id}`)]; + return [buildSpanTarget(editor, index, step, segments, refData.matchId ?? `v4:${step.id}`, 'visible')]; } function resolveTextRef(editor: Editor, index: BlockIndex, step: MutationStep, ref: string): CompiledTarget[] { @@ -778,6 +813,7 @@ function resolveBlockRef(editor: Editor, index: BlockIndex, step: MutationStep, text: blockText, marks: [], blockPos: candidate.pos, + textModel: 'visible', }; return [buildRangeTarget(editor, step, addr, candidate)]; @@ -902,6 +938,7 @@ function buildWholeBlockRangeTarget( text: blockText, marks: [], blockPos: candidate.pos, + textModel: 'visible', }; return buildRangeTarget(editor, step, addr, candidate); } @@ -1010,7 +1047,12 @@ function captureStyleAtAbsoluteRange(editor: Editor, absFrom: number, absTo: num // Step target resolution // --------------------------------------------------------------------------- -function resolveStepTargets(editor: Editor, index: BlockIndex, step: MutationStep): CompiledTarget[] { +function resolveStepTargets( + editor: Editor, + index: BlockIndex, + step: MutationStep, + options: CompilePlanOptions = {}, +): CompiledTarget[] { const where = step.where; const refWhere = isRefWhere(where) ? where : undefined; const selectWhere = isSelectWhere(where) ? where : undefined; @@ -1030,6 +1072,7 @@ function resolveStepTargets(editor: Editor, index: BlockIndex, step: MutationSte const isStructuralOp = step.op === 'structural.insert' || step.op === 'structural.replace'; const resolved = resolveTextSelector(editor, index, selectWhere.select, selectWhere.within, step.id, { allBlockTypes: isStructuralOp, + textModel: options.selectTextModel ?? 'visible', }); targets = resolved.addresses.map((addr) => { const candidate = index.candidates.find((c) => c.nodeId === addr.blockId); @@ -1518,7 +1561,7 @@ function assertSingleStoryKey(steps: MutationStep[]): void { } } -export function compilePlan(editor: Editor, steps: MutationStep[]): CompiledPlan { +export function compilePlan(editor: Editor, steps: MutationStep[], options: CompilePlanOptions = {}): CompiledPlan { // D8: plan step limit if (steps.length > MAX_PLAN_STEPS) { throw planError('INVALID_INPUT', `plan contains ${steps.length} steps, maximum is ${MAX_PLAN_STEPS}`); @@ -1576,7 +1619,7 @@ export function compilePlan(editor: Editor, steps: MutationStep[]): CompiledPlan validateCreateStepPosition(step); } - const targets = resolveStepTargets(editor, index, step); + const targets = resolveStepTargets(editor, index, step, options); // Validate insertion context for create ops (B0 invariant 5) if (isCreateOp(step.op) && targets.length > 0) { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts index 5a2330695a..20d0439a40 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts @@ -2380,7 +2380,9 @@ export function executePlan(editor: Editor, input: MutationsApplyInput): PlanRec throw planError('INVALID_INPUT', 'plan must contain at least one step'); } - const compiled = compilePlan(editor, input.steps); + const compiled = compilePlan(editor, input.steps, { + selectTextModel: input.changeMode === 'tracked' ? 'raw' : 'visible', + }); return executeCompiledPlan(editor, compiled, { changeMode: input.changeMode ?? 'direct', diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/query-match-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/query-match-adapter.ts index 8dec1af097..eb0742e9b9 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/query-match-adapter.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/query-match-adapter.ts @@ -26,6 +26,7 @@ import type { PageInfo, StoryLocator, } from '@superdoc/document-api'; +import type { Node as ProseMirrorNode } from 'prosemirror-model'; import { SNIPPET_MAX_LENGTH, SNIPPET_CONTEXT_CHARS, @@ -50,6 +51,7 @@ import type { OoxmlResolverParams, ParagraphProperties } from '@superdoc/style-e import { readTranslatedLinkedStyles } from '../../core/parts/adapters/styles-read.js'; import { resolveStoryRuntime } from '../story-runtime/resolve-story-runtime.js'; import { encodeV4Ref } from '../story-runtime/story-ref-codec.js'; +import { textContentInBlock } from '../helpers/text-offset-resolver.js'; // --------------------------------------------------------------------------- // V3 ref encoding (D6) @@ -142,6 +144,14 @@ export function buildSelectionTargetFromTextRanges(textRanges: TextAddress[], st return target; } +function readCandidateVisibleText(editor: Editor, candidate: { node?: unknown; pos: number; end: number }): string { + const maybeNode = candidate.node as { childCount?: number } | undefined; + if (maybeNode && typeof maybeNode.childCount === 'number' && maybeNode.childCount > 0) { + return textContentInBlock(maybeNode as ProseMirrorNode, { textModel: 'visible' }); + } + return editor.state.doc.textBetween(candidate.pos + 1, candidate.end - 1, '\n', '\ufffc'); +} + // --------------------------------------------------------------------------- // Block/run builders (D4, D5) // --------------------------------------------------------------------------- @@ -224,9 +234,7 @@ function buildMatchBlocks( } // Get block-level metadata - const blockStart = candidate.pos + 1; - const blockEnd = candidate.end - 1; - const blockText = doc.textBetween(blockStart, blockEnd, '\n', '\ufffc'); + const blockText = readCandidateVisibleText(editor, candidate); const matchedText = blockText.slice(from, to); const node = doc.nodeAt(candidate.pos); const nodeType = node?.type.name ?? 'paragraph'; @@ -243,7 +251,7 @@ function buildMatchBlocks( : undefined; // Capture PM runs within the matched range and coalesce (D4) - const captured = captureRunsInRange(editor, candidate.pos, from, to); + const captured = captureRunsInRange(editor, candidate.pos, from, to, { textModel: 'visible' }); const coalesced = coalesceRuns(captured.runs); // Project to contract MatchRun[] with V4 refs @@ -337,7 +345,6 @@ function buildBlocksSnippet( if (!editor.state?.doc || blocks.length === 0) return undefined; const index = getBlockIndex(editor); - const doc = editor.state.doc; // D11 step 1: join block match texts const matchText = blocks.map((b) => b.text).join('\n'); @@ -359,9 +366,7 @@ function buildBlocksSnippet( const firstBlock = blocks[0]; const firstCandidate = index.candidates.find((c) => c.nodeId === firstBlock.blockId); if (firstCandidate) { - const blockStart = firstCandidate.pos + 1; - const blockEnd = firstCandidate.end - 1; - const fullBlockText = doc.textBetween(blockStart, blockEnd, '\n', '\ufffc'); + const fullBlockText = readCandidateVisibleText(editor, firstCandidate); const contextStart = Math.max(0, firstBlock.range.start - contextEachSide); leftContext = fullBlockText.slice(contextStart, firstBlock.range.start); } @@ -371,9 +376,7 @@ function buildBlocksSnippet( const lastBlock = blocks[blocks.length - 1]; const lastCandidate = index.candidates.find((c) => c.nodeId === lastBlock.blockId); if (lastCandidate) { - const blockStart = lastCandidate.pos + 1; - const blockEnd = lastCandidate.end - 1; - const fullBlockText = doc.textBetween(blockStart, blockEnd, '\n', '\ufffc'); + const fullBlockText = readCandidateVisibleText(editor, lastCandidate); const contextEnd = Math.min(fullBlockText.length, lastBlock.range.end + contextEachSide); rightContext = fullBlockText.slice(lastBlock.range.end, contextEnd); } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/style-resolver.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/style-resolver.test.ts index 872089ef53..808e7b39a6 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/style-resolver.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/style-resolver.test.ts @@ -168,6 +168,29 @@ describe('captureRunsInRange', () => { expect(result.runs[0].marks.map((m) => m.type.name)).toEqual(['bold']); }); + it('excludes tracked deleted text from visible run capture without leaving offset gaps', () => { + const bold = mockMark('bold'); + const trackDelete = mockMark('trackDelete'); + + const paragraph = createNode( + 'paragraph', + [ + createNode('text', [], { text: 'A', marks: [bold] }), + createNode('text', [], { text: 'gone', marks: [trackDelete] }), + createNode('text', [], { text: 'B', marks: [bold] }), + ], + { isBlock: true, inlineContent: true }, + ); + const editor = makeEditor(0, paragraph); + + const result = captureRunsInRange(editor, 0, 0, 2, { textModel: 'visible' }); + + expect(result.runs).toHaveLength(2); + expect(result.runs[0]).toMatchObject({ from: 0, to: 1, charCount: 1 }); + expect(result.runs[1]).toMatchObject({ from: 1, to: 2, charCount: 1 }); + expect(coalesceRuns(result.runs)).toHaveLength(1); + }); + it('returns empty runs when the block node cannot be resolved', () => { const editor = makeEditor(0, null); const result = captureRunsInRange(editor, 0, 0, 5); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/style-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/style-resolver.ts index 75520cf494..e13d08e02f 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/style-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/style-resolver.ts @@ -10,6 +10,8 @@ import { MARK_KEYS } from '@superdoc/document-api'; import type { Editor } from '../../core/Editor.js'; import { planError } from './errors.js'; import { TOGGLE_MARK_SPECS, applyDirectiveToMarks } from './mark-directives.js'; +import type { TextOffsetOptions } from '../helpers/text-offset-resolver.js'; +import { TrackDeleteMarkName } from '../../extensions/track-changes/constants.js'; // --------------------------------------------------------------------------- // Run types — describes contiguous spans sharing identical marks within a block @@ -71,7 +73,13 @@ const METADATA_MARK_NAMES = new Set([ * to the block-relative `from`/`to` offsets, collecting each inline text node * as a run with its marks. */ -export function captureRunsInRange(editor: Editor, blockPos: number, from: number, to: number): CapturedStyle { +export function captureRunsInRange( + editor: Editor, + blockPos: number, + from: number, + to: number, + options?: TextOffsetOptions, +): CapturedStyle { const doc = editor.state.doc; const blockNode = doc.nodeAt(blockPos); if (!blockNode || from < 0 || to < from || from === to) { @@ -98,11 +106,14 @@ export function captureRunsInRange(editor: Editor, blockPos: number, from: numbe if (node.isText) { const text = node.text ?? ''; if (text.length > 0) { - const start = offset; - const end = offset + text.length; const marks = Array.isArray((node as { marks?: unknown }).marks) ? ((node as unknown as { marks: PmMark[] }).marks as readonly PmMark[]) : []; + if (options?.textModel === 'visible' && marks.some((mark) => mark.type.name === TrackDeleteMarkName)) { + return; + } + const start = offset; + const end = offset + text.length; maybePushRun(start, end, marks); offset = end; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/tracked-rewrite.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/tracked-rewrite.integration.test.ts index 6f1aadbb5f..ebcfa6c06f 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/tracked-rewrite.integration.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/tracked-rewrite.integration.test.ts @@ -48,6 +48,41 @@ function paragraphTexts(editor: any): string[] { return paragraphs; } +function findTextRange(editor: any, text: string): { from: number; to: number } { + let range: { from: number; to: number } | null = null; + editor.state.doc.descendants((node: any, pos: number) => { + if (!node.isText || !node.text) return true; + const index = node.text.indexOf(text); + if (index === -1) return true; + range = { from: pos + index, to: pos + index + text.length }; + return false; + }); + if (!range) throw new Error(`Could not find text "${text}"`); + return range; +} + +function markTextAsOtherUserDeletion(editor: any, text: string): void { + const range = findTextRange(editor, text); + const mark = editor.schema.marks[TrackDeleteMarkName].create({ + id: 'alice-delete', + author: 'Alice Reviewer', + authorEmail: 'alice@example.com', + date: '2024-01-01T00:00:00.000Z', + }); + editor.dispatch(editor.state.tr.addMark(range.from, range.to, mark)); +} + +function markedTextByAuthor(editor: any, markName: string, authorEmail: string): string { + const parts: string[] = []; + editor.state.doc.descendants((node: any) => { + if (!node.isText || !node.text) return; + if (node.marks.some((mark: any) => mark.type.name === markName && mark.attrs.authorEmail === authorEmail)) { + parts.push(node.text); + } + }); + return parts.join(''); +} + function compileSingleRewrite(editor: any, pattern: string, text: string) { const step = { id: 'rewrite-step', @@ -221,6 +256,62 @@ describe('doc.replace multi-paragraph integration', () => { expect(insertedTexts).toEqual(expect.arrayContaining(['Alpha', 'Beta'])); }); + it('resolves tracked rewrite selectors against unresolved deletion text without changing public query refs', () => { + editor = makeEditor(['The quick brown fox jumps over the lazy dog.']); + markTextAsOtherUserDeletion(editor, 'lazy '); + + const receipt = editor.doc.mutations.apply({ + atomic: true, + changeMode: 'tracked', + steps: [ + { + id: 'replace-inside-delete', + op: 'text.rewrite', + where: { + by: 'select', + select: { type: 'text', pattern: 'lazy' }, + require: 'first', + }, + args: { + replacement: { text: 'OO' }, + style: { inline: { mode: 'preserve' } }, + }, + }, + ], + }); + + expect(receipt.success).toBe(true); + expect(markedTextByAuthor(editor, TrackInsertMarkName, 'integration@example.com')).toContain('OO'); + expect(markedTextByAuthor(editor, TrackDeleteMarkName, 'integration@example.com')).toContain('lazy'); + expect(markedTextByAuthor(editor, TrackDeleteMarkName, 'alice@example.com')).toBe(' '); + }); + + it('resolves tracked delete selectors against unresolved deletion text as a child deletion', () => { + editor = makeEditor(['The quick brown fox jumps over the lazy dog.']); + markTextAsOtherUserDeletion(editor, 'lazy '); + + const receipt = editor.doc.mutations.apply({ + atomic: true, + changeMode: 'tracked', + steps: [ + { + id: 'delete-inside-delete', + op: 'text.delete', + where: { + by: 'select', + select: { type: 'text', pattern: 'lazy' }, + require: 'first', + }, + args: { behavior: 'exact' }, + }, + ], + }); + + expect(receipt.success).toBe(true); + expect(markedTextByAuthor(editor, TrackDeleteMarkName, 'integration@example.com')).toContain('lazy'); + expect(markedTextByAuthor(editor, TrackDeleteMarkName, 'alice@example.com')).toBe(' '); + }); + it('creates an empty paragraph before the replacement when text has a leading newline (direct)', () => { editor = makeEditor(); const receipt = editor.doc.replace( diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js index 19d81f0346..b27a04d0e6 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js @@ -461,7 +461,7 @@ const buildMutationPlan = ({ state, graph, selections, decision, replacements }) } else if (change.type === CanonicalChangeType.Deletion) { planDeletionDecision({ ops, change, selection, decision, removedRanges, retired }); } else if (change.type === CanonicalChangeType.Replacement) { - const repResult = planReplacementDecision({ ops, change, decision, removedRanges, retired }); + const repResult = planReplacementDecision({ ops, graph, change, decision, removedRanges, retired }); if (!repResult.ok) return { ok: false, failure: repResult.failure }; } else if (change.type === CanonicalChangeType.Formatting) { planFormattingDecision({ ops, change, decision, retired }); @@ -590,7 +590,7 @@ const planDeletionDecision = ({ ops, change, selection, decision, removedRanges, if (isFull) retired.add(change.id); }; -const planReplacementDecision = ({ ops, change, decision, removedRanges, retired }) => { +const planReplacementDecision = ({ ops, graph, change, decision, removedRanges, retired }) => { const inserted = change.insertedSegments; const deleted = change.deletedSegments; if (!inserted.length || !deleted.length) { @@ -618,6 +618,7 @@ const planReplacementDecision = ({ ops, change, decision, removedRanges, retired ops.push({ kind: 'removeContent', from: seg.from, to: seg.to, changeId: change.id, side: SegmentSide.Inserted }); removedRanges.push({ from: seg.from, to: seg.to, cause: `reject-replacement-inserted:${change.id}` }); } + const parentRestore = getParentRestoreContext({ graph, change }); for (const seg of deleted) { pushRemoveMarkOpsForSegment({ ops, @@ -625,12 +626,97 @@ const planReplacementDecision = ({ ops, change, decision, removedRanges, retired changeId: change.id, side: SegmentSide.Deleted, }); + if (parentRestore?.mark) { + pushAddMarkOpsForSegment({ + ops, + segment: seg, + changeId: parentRestore.mark.attrs?.id || change.parent || change.id, + side: parentRestore.mark.type.name === TrackInsertMarkName ? SegmentSide.Inserted : SegmentSide.Deleted, + mark: parentRestore.mark, + }); + } + } + for (const sibling of parentRestore?.siblingSegments ?? []) { + pushRemoveMarkOpsForSegment({ + ops, + segment: sibling, + changeId: sibling.changeId, + side: sibling.side, + }); + pushAddMarkOpsForSegment({ + ops, + segment: sibling, + changeId: parentRestore.mark.attrs?.id || sibling.changeId, + side: parentRestore.mark.type.name === TrackInsertMarkName ? SegmentSide.Inserted : SegmentSide.Deleted, + mark: parentRestore.mark, + }); + retired.add(sibling.changeId); } } retired.add(change.id); return { ok: true }; }; +const getParentRestoreContext = ({ graph, change }) => { + const explicit = getExplicitParentRestoreContext({ graph, change }); + if (explicit) return explicit; + return inferAdjacentParentRestoreContext({ graph, change }); +}; + +const getExplicitParentRestoreContext = ({ graph, change }) => { + if (!change.parent) return null; + const parent = graph.changes.get(change.parent); + if (!parent) return null; + if (parent.type === CanonicalChangeType.Insertion) { + const mark = parent.insertedSegments[0]?.mark ?? null; + return mark ? { mark, siblingSegments: [] } : null; + } + if (parent.type === CanonicalChangeType.Deletion) { + const mark = parent.deletedSegments[0]?.mark ?? null; + return mark ? { mark, siblingSegments: [] } : null; + } + return null; +}; + +const inferAdjacentParentRestoreContext = ({ graph, change }) => { + if (!change.deletedSegments.length || !change.segments.length) return null; + const from = Math.min(...change.segments.map((segment) => segment.from)); + const to = Math.max(...change.segments.map((segment) => segment.to)); + const before = nearestSegmentBefore({ graph, change, from }); + const after = nearestSegmentAfter({ graph, change, to }); + if (!before || !after) return null; + if (!areParentRestorePeers(before, after)) return null; + + return { + mark: before.mark, + siblingSegments: after.changeId === before.changeId ? [] : [after], + }; +}; + +const nearestSegmentBefore = ({ graph, change, from }) => { + return graph.segments + .filter((segment) => segment.changeId !== change.id && segment.to <= from) + .sort((a, b) => b.to - a.to || b.from - a.from)[0]; +}; + +const nearestSegmentAfter = ({ graph, change, to }) => { + return graph.segments + .filter((segment) => segment.changeId !== change.id && segment.from >= to) + .sort((a, b) => a.from - b.from || a.to - b.to)[0]; +}; + +const areParentRestorePeers = (left, right) => { + if (!left || !right) return false; + if (left.side !== right.side) return false; + if (left.side !== SegmentSide.Inserted && left.side !== SegmentSide.Deleted) return false; + if (left.markType !== right.markType) return false; + return ( + left.attrs.author === right.attrs.author && + left.attrs.authorEmail === right.attrs.authorEmail && + left.attrs.date === right.attrs.date + ); +}; + const planFormattingDecision = ({ ops, change, decision, retired }) => { for (const seg of change.formattingSegments) { if (decision === 'accept') { @@ -748,6 +834,22 @@ const pushRemoveMarkOpsForSegment = ({ ops, segment, changeId, side, from = segm } }; +const pushAddMarkOpsForSegment = ({ ops, segment, changeId, side, mark, from = segment.from, to = segment.to }) => { + for (const run of getSegmentMarkRuns(segment)) { + const clippedFrom = Math.max(from, run.from); + const clippedTo = Math.min(to, run.to); + if (clippedFrom >= clippedTo) continue; + ops.push({ + kind: 'addMark', + from: clippedFrom, + to: clippedTo, + changeId, + side, + mark, + }); + } +}; + const getSegmentMarkRuns = (segment) => { return segment.markRuns?.length ? segment.markRuns : [{ from: segment.from, to: segment.to, mark: segment.mark }]; }; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.test.js index cf14ff45cf..018a195122 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.test.js @@ -311,6 +311,66 @@ describe('decideTrackedChanges overlap behavior', () => { expect(state.apply(result.tr).doc.textContent).toBe('a OLD b'); }); + it('reject replacement between split same-author deletion fragments restores the inferred parent deletion', () => { + const schema = createReviewGraphTestSchema(); + const replacementAttrs = { + changeType: 'replacement', + replacementGroupId: 'rep-3', + }; + const parentLeft = deleteAttrs('parent-left', OTHER_USER, { date: '2026-05-20T14:08:00Z' }); + const parentRight = deleteAttrs('parent-right', OTHER_USER, { date: '2026-05-20T14:08:00Z' }); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'a ' }, + { text: 'B', marks: [{ markType: TrackDeleteMarkName, attrs: parentLeft }] }, + { + text: 'B', + marks: [ + { + markType: TrackDeleteMarkName, + attrs: deleteAttrs('rep-3', SAME_USER, { + ...replacementAttrs, + replacementSideId: 'rep-3#deleted', + }), + }, + ], + }, + { + text: 'ZZ', + marks: [ + { + markType: TrackInsertMarkName, + attrs: insertAttrs('rep-3', SAME_USER, { + ...replacementAttrs, + replacementSideId: 'rep-3#inserted', + }), + }, + ], + }, + { text: 'B', marks: [{ markType: TrackDeleteMarkName, attrs: parentRight }] }, + { text: ' b' }, + ], + }); + + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'reject', + target: { kind: 'id', id: 'rep-3' }, + }); + + expect(result.ok).toBe(true); + const next = state.apply(result.tr); + expect(next.doc.textContent).toBe('a BBB b'); + const graph = buildReviewGraph({ state: next }); + expect(graph.changes.size).toBe(1); + const parent = graph.changes.get('parent-left'); + expect(parent).toBeDefined(); + expect(parent.type).toBe('deletion'); + expect(parent.deletedSegments.map((segment) => segment.text).join('')).toBe('BBB'); + }); + it('formatting accept removes the trackFormat mark; reject restores the before snapshot', () => { const schema = createReviewGraphTestSchema(); const beforeSnap = [{ type: TrackInsertMarkName, attrs: insertAttrs('inner-ins') }]; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js index 717e7ead95..fd855d459f 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js @@ -103,6 +103,10 @@ import { getLiveInlineMarksInRange } from '../trackChangesHelpers/getLiveInlineM const SUPPORTED_KINDS = new Set(['text-insert', 'text-delete', 'text-replace', 'format-apply', 'format-remove']); const EMPTY_STRUCTURAL_GAP_REFINEMENT_MAX_DISTANCE = 4; +/** + * @typedef {false|'different-user'|'all'} ExistingDeletionReassignMode + */ + /** * Compile a tracked edit against an accumulated transaction. * @@ -544,6 +548,8 @@ const compileTextDelete = (ctx, intent) => { replacementSideId: '', sharedDeletionId: intent.replacementGroupHint || null, recordSharedDeletionId: Boolean(intent.replacementGroupHint), + reassignExistingDeletions: + intent.source !== 'native' && !intent.preserveExistingReviewState ? 'different-user' : false, }); if (result.ok === false) return result; @@ -570,7 +576,7 @@ const compileTextDelete = (ctx, intent) => { * @param {*} ctx * @param {number} from * @param {number} to - * @param {{ replacementGroupId: string, replacementSideId: string, sharedDeletionId: string | null, recordSharedDeletionId?: boolean, recordCollapsedIds?: boolean, reassignExistingDeletions?: boolean }} options + * @param {{ replacementGroupId: string, replacementSideId: string, sharedDeletionId: string | null, recordSharedDeletionId?: boolean, recordCollapsedIds?: boolean, reassignExistingDeletions?: ExistingDeletionReassignMode }} options * @returns {{ ok: true, deletionMarks: import('prosemirror-model').Mark[], deletionNodes: import('prosemirror-model').Node[], deletionId: string, mintedThisCall: boolean } | TrackedEditFailure} */ const applyTrackedDelete = ( @@ -647,12 +653,21 @@ const applyTrackedDelete = ( if (existingDelete) { const allExistingDeletes = node.marks.filter((m) => m.type.name === TrackDeleteMarkName); - if (reassignExistingDeletions) { + const deleteOwnership = classifyOwnership({ + currentUser: ctx.currentIdentity, + change: getChangeAuthorIdentity(existingDelete.attrs), + }); + const isDifferentUserDeletion = !isSameUserHighConfidence(deleteOwnership); + const shouldReassignExistingDeletion = + reassignExistingDeletions === 'all' || + (reassignExistingDeletions === 'different-user' && isDifferentUserDeletion); + if (shouldReassignExistingDeletion) { ops.push({ kind: 'reassign', from: segFrom, to: segTo, node, + parentId: existingDelete.attrs.id || existingDelete.attrs.overlapParentId || '', existingDeleteMarks: allExistingDeletes, }); return; @@ -696,7 +711,7 @@ const applyTrackedDelete = ( // deletion mark so the new replacement encloses the prior delete. const mark = makeDeleteMark(ctx, { id: deletionId, - overlapParentId: '', + overlapParentId: op.parentId || '', replacementGroupId, replacementSideId, }); @@ -880,11 +895,9 @@ const compileTextReplace = (ctx, intent) => { * @returns {TrackedEditResult} */ const compileOrdinaryTextReplace = (ctx, intent, sanitizedSlice, replacementParentId) => { - // In paired mode share one id between insert/delete sides so a top-level - // replacement projects as one logical graph change. A replacement nested - // inside another author's open review item must keep each side separately - // reviewable, so those child sides intentionally use distinct ids even when - // the caller's default replacement mode is paired. + // In paired mode ordinary replacements share one id between insert/delete + // sides. Nested replacements inside another author's pending change must + // keep the child insertion and deletion as independently reviewable sides. const shouldPairReplacement = intent.replacements === 'paired' && !replacementParentId; const sharedId = shouldPairReplacement ? intent.replacementGroupHint || uuidv4() : null; const replacementGroupId = sharedId ?? ''; @@ -892,11 +905,11 @@ const compileOrdinaryTextReplace = (ctx, intent, sanitizedSlice, replacementPare // 1. Probe for adjacent tracked-delete span at intent.to - 1 (legacy // behavior). Only applies for single-step user actions — plan-engine // multi-step rewrites must not probe. - let positionTo = intent.to; + let positionTo = replacementParentId ? intent.from : intent.to; if (intent.from !== intent.to && intent.probeForDeletionSpan) { const probePos = Math.max(intent.from, intent.to - 1); const deletionSpan = findMarkPosition(ctx.tr.doc, probePos, TrackDeleteMarkName); - if (deletionSpan && deletionSpan.to > positionTo) positionTo = deletionSpan.to; + if (!replacementParentId && deletionSpan && deletionSpan.to > positionTo) positionTo = deletionSpan.to; } // 2. Build a temp insertion in a throwaway transaction so we can read the @@ -940,9 +953,8 @@ const compileOrdinaryTextReplace = (ctx, intent, sanitizedSlice, replacementPare if (insertion && insertion.insertedFrom !== insertion.insertedTo) { const { tempTr, insertedFrom, insertedTo } = insertion; // Use the legacy markInsertion primitive so id reuse / refinement matches - // existing behavior exactly. Compiler-specific overlap fields - // (overlapParentId, replacementGroupId, replacementSideId) are layered on - // afterward. + // existing behavior exactly for ordinary replacements. Nested replacements + // force a fresh child insertion side under the parent. const forcedInsertId = sharedId || (replacementParentId ? uuidv4() : undefined); insertedMark = markInsertion({ tr: tempTr, @@ -995,11 +1007,10 @@ const compileOrdinaryTextReplace = (ctx, intent, sanitizedSlice, replacementPare insertedLength = insertedToAbs - insertedFromAbs; } - // 4. Apply tracked delete on the original range. The range positions are - // unaffected by the insertion (insertion happened at positionTo which is - // >= intent.to). The delete may collapse own insertions inside the - // range, shifting the doc — we map the inserted position through the - // delete-induced map after. + // 4. Apply tracked delete on the original range. Ordinary replacements + // insert at or after intent.to, so the original range is stable. Nested + // replacements insert at intent.from; in that case remap the selected + // original text past the inserted child side before marking deletion. /** @type {Array} */ let deletionMarks = []; /** @type {Array} */ @@ -1009,11 +1020,13 @@ const compileOrdinaryTextReplace = (ctx, intent, sanitizedSlice, replacementPare if (intent.from !== intent.to) { const stepsBefore = ctx.tr.steps.length; - const delResult = applyTrackedDelete(ctx, intent.from, intent.to, { + const deleteFrom = insertedLength > 0 && positionTo <= intent.from ? intent.from + insertedLength : intent.from; + const deleteTo = insertedLength > 0 && positionTo <= intent.from ? intent.to + insertedLength : intent.to; + const delResult = applyTrackedDelete(ctx, deleteFrom, deleteTo, { replacementGroupId, replacementSideId: sharedId ? `${sharedId}#deleted` : '', sharedDeletionId: sharedId, - reassignExistingDeletions: Boolean(sharedId), + reassignExistingDeletions: sharedId || replacementParentId ? 'all' : false, }); if (delResult.ok === false) return delResult; deletionMarks = delResult.deletionMarks; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js index a1d00645bc..d56d6a1008 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js @@ -688,7 +688,7 @@ describe('overlap-compiler: text-delete', () => { }); describe('overlap-compiler: text-replace inside named no-email insertion', () => { - it('preserves parent insertion and creates child replacement sides', () => { + it('preserves parent insertion and creates child insertion/deletion sides', () => { const parentId = 'ins-alice-no-email'; const { state } = stateFromTrackedSpans({ schema, @@ -718,25 +718,24 @@ describe('overlap-compiler: text-replace inside named no-email insertion', () => }); const result = runCompile({ state, intent }); expect(result.ok).toBe(true); - expect(textOf(result.tr)).toBe('lazyquickly '); + expect(textOf(result.tr)).toBe('quicklylazy '); const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); expect(graph.changes.size).toBe(3); const parent = graph.changes.get(parentId); expect(parent).toBeDefined(); expect(parent.type).toBe(CanonicalChangeType.Insertion); - const childDelete = Array.from(graph.changes.values()).find( - (change) => change.type === CanonicalChangeType.Deletion, - ); - const childInsert = Array.from(graph.changes.values()).find( - (change) => change.type === CanonicalChangeType.Insertion && change.id !== parentId, - ); - expect(childDelete).toBeDefined(); - expect(childDelete.deletedSegments[0].text).toBe('lazy'); - expect(childDelete.deletedSegments[0].attrs.overlapParentId).toBe(parentId); - expect(childInsert).toBeDefined(); - expect(childInsert.insertedSegments.map((segment) => segment.text).join('')).toBe('quickly'); - expect(childInsert.insertedSegments[0].attrs.overlapParentId).toBe(parentId); + + const children = Array.from(graph.changes.values()).filter((change) => change.parent === parentId); + expect(children).toHaveLength(2); + const childDeletion = children.find((change) => change.type === CanonicalChangeType.Deletion); + const childInsertion = children.find((change) => change.type === CanonicalChangeType.Insertion); + expect(childDeletion).toBeDefined(); + expect(childInsertion).toBeDefined(); + expect(childDeletion.deletedSegments.map((segment) => segment.text).join('')).toBe('lazy'); + expect(childDeletion.deletedSegments.every((segment) => segment.attrs.overlapParentId === parentId)).toBe(true); + expect(childInsertion.insertedSegments.map((segment) => segment.text).join('')).toBe('quickly'); + expect(childInsertion.insertedSegments.every((segment) => segment.attrs.overlapParentId === parentId)).toBe(true); }); }); @@ -815,7 +814,7 @@ describe('overlap-compiler: text-replace produces paired replacement metadata', expect(change.replacement?.deleted.length).toBeGreaterThan(0); }); - it('keeps both sides of a child replacement separately reviewable under an other-user insertion parent', () => { + it('keeps child insertion and deletion sides under an other-user insertion parent', () => { const parentId = 'ins-bob'; const { state } = stateFromTrackedSpans({ schema, @@ -838,12 +837,16 @@ describe('overlap-compiler: text-replace produces paired replacement metadata', const graph = buildReviewGraph({ state: { doc: result.tr.doc }, replacementsMode: 'paired' }); const children = Array.from(graph.changes.values()).filter((change) => change.parent === parentId); expect(children).toHaveLength(2); - expect(children.map((change) => change.type).sort()).toEqual([ - CanonicalChangeType.Deletion, - CanonicalChangeType.Insertion, - ]); + const childDeletion = children.find((change) => change.type === CanonicalChangeType.Deletion); + const childInsertion = children.find((change) => change.type === CanonicalChangeType.Insertion); + expect(childDeletion).toBeDefined(); + expect(childInsertion).toBeDefined(); + expect(childDeletion.deletedSegments.map((segment) => segment.text).join('')).toBe('or'); + expect(childInsertion.insertedSegments.map((segment) => segment.text).join('')).toBe('AR'); expect( - children.every((change) => change.segments.every((segment) => segment.attrs.overlapParentId === parentId)), + [...childDeletion.segments, ...childInsertion.segments].every( + (segment) => segment.attrs.overlapParentId === parentId, + ), ).toBe(true); }); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js index e3134c7a8f..1e0e241fb5 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js @@ -264,13 +264,14 @@ const tryCompileStep = ({ let intent; try { const preserveExistingReviewState = tr.getMeta('protectTrackedReviewState') === true; + const source = tr.getMeta('inputType') === 'programmatic' ? 'document-api' : 'native'; if (step.from === step.to && step.slice.content.size > 0) { intent = makeTextInsertIntent({ at: step.from, content: step.slice, user, date, - source: 'native', + source, preserveExistingReviewState, }); } else if (step.from !== step.to && step.slice.content.size === 0) { @@ -279,7 +280,7 @@ const tryCompileStep = ({ to: step.to, user, date, - source: 'native', + source, preserveExistingReviewState, }); } else if (step.from !== step.to && step.slice.content.size > 0) { @@ -290,7 +291,7 @@ const tryCompileStep = ({ replacements, user, date, - source: 'native', + source, preserveExistingReviewState, }); // Single-step user actions (text replace from one ReplaceStep) probe diff --git a/tests/behavior/tests/comments/replace-over-multi-paragraph-tracked-changes.spec.ts b/tests/behavior/tests/comments/replace-over-multi-paragraph-tracked-changes.spec.ts index 20cdf7b6ee..d68e3496fd 100644 --- a/tests/behavior/tests/comments/replace-over-multi-paragraph-tracked-changes.spec.ts +++ b/tests/behavior/tests/comments/replace-over-multi-paragraph-tracked-changes.spec.ts @@ -130,9 +130,23 @@ test('replace over multi-paragraph tracked changes stays coherent', async ({ sup await superdoc.press('Backspace'); await superdoc.waitForStable(); - // Both words should still exist in PM (as tracked deletions, not truly removed) - await superdoc.assertTextContains('tailword2'); - await superdoc.assertTextContains('tailword3'); + // Public text is visible/effective text, so unresolved deletions are hidden. + await superdoc.assertTextNotContains('tailword2'); + await superdoc.assertTextNotContains('tailword3'); + + // Both words should still exist in PM as tracked deletions, not truly removed. + const deletedTextAfterStep2 = await superdoc.page.evaluate(() => { + const editor = (window as any).editor; + let text = ''; + editor.state.doc.descendants((node: any) => { + if (!node.isText || !node.text) return; + const hasDeleteMark = (node.marks ?? []).some((mark: any) => mark.type?.name === 'trackDelete'); + if (hasDeleteMark) text += node.text; + }); + return text; + }); + expect(deletedTextAfterStep2).toContain('tailword2'); + expect(deletedTextAfterStep2).toContain('tailword3'); // Tracked delete marks should exist const deletionCountAfterStep2 = await superdoc.page.evaluate(() => {