From bd431911c322567697df6b86eb51a026c6ac7bcb Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 25 May 2026 19:21:41 -0300 Subject: [PATCH 01/23] chore: add root pickled.yml for agent-legibility checks Pickled (https://docs.pickled.dev) runs scripted scenarios across a matrix of interfaces, sources, and toolsets and scores answers with deterministic checks. This config covers one scenario today (the custom React toolbar question) across two interfaces (Claude Code haiku, OpenAI Responses) and four context-delivery paths (none, web, the official SuperDoc Mintlify docs MCP, Context7 MCP). 16 cells per run. Sits alongside evals/ rather than under it: evals/ is the Promptfoo suite that scores the SuperDoc tool surface; this is the outside-in view of how agents talk about SuperDoc when asked to build with it. Run with: bunx @pickled-dev/cli check . --- pickled.yml | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 pickled.yml diff --git a/pickled.yml b/pickled.yml new file mode 100644 index 0000000000..9b0da92b69 --- /dev/null +++ b/pickled.yml @@ -0,0 +1,91 @@ +# πŸ₯’ pickled.yml - measure what agents understand about SuperDoc +# +# Pickled runs the scenarios below across a matrix of interfaces, +# sources, and toolsets, then scores answers with deterministic checks. +# Each cell isolates one context-delivery path (docs link / web tools / +# MCP server) so the report shows where agents do well and where they +# do not. +# +# Quick start: +# bunx @pickled-dev/cli check . +# +# Docs: https://docs.pickled.dev + +tool: + name: superdoc + description: "Document engine for the modern web (.docx-native editor + SDK + MCP)" + +docs: + sources: + # Official SuperDoc docs bundle. Injected only in cells where + # `source: superdoc_docs` is selected; the MCP and web cells use + # `source: none` so the toolset is the only delivery path. + superdoc_docs: https://docs.superdoc.dev/llms-full.txt + +targets: + # Claude Code via the Agent SDK. Cheap, fast, matches how most + # external users first try SuperDoc inside their IDE. + quick: + category: cli + provider: claude-code + model: claude-haiku-4-5 + maxTurns: 10 + + # OpenAI Responses API. The other interface that today supports both + # `web` and `mcp` toolsets, so the matrix can cover the same context + # modes across two providers. + openai_api: + category: api + provider: openai + model: gpt-5.2 + temperature: 0 + maxTokens: 4096 + +toolsets: + none: {} + + # Each interface's built-in web tools. On Claude Code this scopes to + # WebSearch + WebFetch; on OpenAI it uses the server-side web_search. + web: + webSearch: true + webFetch: true + + # SuperDoc's official Mintlify docs MCP server. Public HTTP endpoint, + # no auth. Exposes search_super_doc + query_docs_filesystem_super_doc + # so the agent can search docs and read pages as files. + superdoc_mintlify_mcp: + mcpServers: + superdoc: + type: http + url: https://docs.superdoc.dev/mcp + + # Third-party Context7 index. Requires CONTEXT7_API_KEY in the env. + # Kept as a comparison surface alongside the official Mintlify server. + context7_mcp: + mcpServers: + context7: + type: http + url: https://mcp.context7.com/mcp + headers: + CONTEXT7_API_KEY: ${CONTEXT7_API_KEY} + +scenarios: + # Custom React toolbar. The correct answer names SuperDocUIProvider + # and useSuperDocUI from the superdoc/ui/react surface. The wrong + # answers name the legacy headless toolbar (createHeadlessToolbar) + # or reach for activeEditor.commands. + - name: "Custom React toolbar surface" + prompt: "I am building with SuperDoc in React and want to add a custom toolbar. Which SuperDoc surface should I use, what should I import, and what should I avoid?" + matrix: + interfaces: [quick, openai_api] + sources: [none, superdoc_docs] + toolsets: [none, web, superdoc_mintlify_mcp, context7_mcp] + expected: + symbols: + - "SuperDocUIProvider" + - "useSuperDocUI" + paths: + - "superdoc/ui/react" + excludes: + - "createHeadlessToolbar" + - "activeEditor.commands" From 8e768fc355b25bf93fdf2556dddf02e604b06664 Mon Sep 17 00:00:00 2001 From: aorlov Date: Wed, 27 May 2026 23:10:25 +0200 Subject: [PATCH 02/23] 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 03/23] 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 04/23] 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 09/23] 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 10/23] 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 11/23] 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 12/23] 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(() => { From ba1f8490d6ae67fa0d685e2f0b67b51f63e39e3b Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 30 May 2026 09:04:33 -0300 Subject: [PATCH 13/23] feat(painter-dom): custom SDT styling variables under chrome:'none' (SD-3322) Under modules.contentControls.chrome:'none' the painter erased the SDT look entirely, so a consumer who wanted a custom field/clause appearance had to target the painted wrapper with !important and reach into internal state classes (.ProseMirror-selectednode, .sdt-group-hover) to keep it stable across hover and selection. That's the wrong "best practice" to teach. Make the chrome-none reset read a --sd-content-controls-custom-* variable layer with default-preserving fallbacks (0-width transparent border, no background / radius / padding). chrome:'none' stays visually empty by default - existing consumers see no change - but a consumer can now paint inline and block controls by setting variables on a data-sdt-* selector. The painter applies them across rest, hover, and selected, so the box stays stable (no jitter) and no !important or state-class selectors are needed. `border` is a full shorthand; block adds a `-border-left` accent rail; background vars cascade (hover from rest, selected from hover). - variables.css: document the custom-* surface; note the built-in chrome still uses the existing --sd-content-controls-* variables. - docs: add a "Style the controls in place" section to the custom-UI content controls guide. - test: assert the surface is wired and default-preserving; existing chrome-none selector + source-order tests are unchanged and still pass (painter-dom 1178/1178). --- .../editor/custom-ui/content-controls.mdx | 27 +++++++++ .../painters/dom/src/styles.test.ts | 30 ++++++++++ .../layout-engine/painters/dom/src/styles.ts | 56 ++++++++++++++----- .../src/assets/styles/helpers/variables.css | 19 ++++++- 4 files changed, 116 insertions(+), 16 deletions(-) diff --git a/apps/docs/editor/custom-ui/content-controls.mdx b/apps/docs/editor/custom-ui/content-controls.mdx index c0a94bcce8..9f3d73f734 100644 --- a/apps/docs/editor/custom-ui/content-controls.mdx +++ b/apps/docs/editor/custom-ui/content-controls.mdx @@ -30,6 +30,33 @@ new SuperDoc({ The event tells you *what* is active; `getRect` tells you *where* to draw. `active` is an `SdtRef` with `id`, `tag`, `alias`, `controlType`, and `scope`. +## Style the controls in place + +Turning off chrome erases the built-in look, including hover and selection. To paint your own field and clause look, set `--sd-content-controls-custom-*` variables on the painted wrapper. Target it by your own `data-sdt-*` attributes. No `!important`, and no need to touch SuperDoc's internal state classes: the painter applies your variables across rest, hover, and selected, so the box stays stable and you never write `.ProseMirror-selectednode` or hover rules yourself. + +```css +/* A field your app tagged { kind: 'smartField', ... } */ +.superdoc-cc-chrome-none .superdoc-structured-content-inline[data-sdt-tag*='smartField'] { + --sd-content-controls-custom-inline-border: 1px solid #1355ff; + --sd-content-controls-custom-inline-bg: color-mix(in srgb, #1355ff 12%, transparent); + --sd-content-controls-custom-inline-hover-bg: color-mix(in srgb, #1355ff 20%, transparent); + --sd-content-controls-custom-inline-radius: 4px; + --sd-content-controls-custom-inline-padding: 1px 6px; +} + +/* A clause your app tagged { kind: 'reusableSection', ... } */ +.superdoc-cc-chrome-none .superdoc-structured-content-block[data-sdt-tag*='reusableSection'] { + --sd-content-controls-custom-block-border: 1px solid #d6e0ff; + --sd-content-controls-custom-block-border-left: 4px solid #1355ff; /* accent rail */ + --sd-content-controls-custom-block-bg: color-mix(in srgb, #1355ff 4%, transparent); + --sd-content-controls-custom-block-radius: 6px; +} +``` + +`border` is a full CSS shorthand; `border-left` is an optional accent rail for block clauses. The background variables cascade, so set only what changes: `-hover-bg` defaults to `-bg`, and `-selected-bg` defaults to `-hover-bg`. + +This is the path for `chrome: 'none'`. To theme the **built-in** chrome instead (`chrome: 'default'`), use the `--sd-content-controls-*` variables (without `custom`). + ## Pick the right surface | Goal | API | diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index fec200adcd..c0f1632c66 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -322,6 +322,36 @@ describe('ensureSdtContainerStyles', () => { expect(lastChromeShowing).toBeGreaterThan(-1); expect(chromeNoneSuppression).toBeGreaterThan(lastChromeShowing); }); + + it('exposes a --sd-content-controls-custom-* styling surface under chrome-none (SD-3322)', () => { + ensureSdtContainerStyles(document); + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + + // Inline rest reads the custom vars; the default-preserving fallbacks + // (0-width transparent border, no background/radius/padding) keep + // chrome-none visually empty when no variable is set. + expect(cssText).toContain('background: var(--sd-content-controls-custom-inline-bg, none);'); + expect(cssText).toContain('border: var(--sd-content-controls-custom-inline-border, 0 solid transparent);'); + expect(cssText).toContain('padding: var(--sd-content-controls-custom-inline-padding, 0);'); + expect(cssText).toContain('border-radius: var(--sd-content-controls-custom-inline-radius, 0);'); + + // Hover and selected re-assert the SAME border var (constant box, no jitter) + // and read the background vars, which cascade from the rest background. + expect(cssText).toContain( + 'background: var(--sd-content-controls-custom-inline-hover-bg, var(--sd-content-controls-custom-inline-bg, none));', + ); + expect(cssText).toContain( + 'background: var(--sd-content-controls-custom-inline-selected-bg, var(--sd-content-controls-custom-inline-hover-bg, var(--sd-content-controls-custom-inline-bg, none)));', + ); + + // Block exposes the same set plus an accent rail (-border-left) that falls + // back to the regular border. + expect(cssText).toContain('background: var(--sd-content-controls-custom-block-bg, none);'); + expect(cssText).toContain( + 'border-left: var(--sd-content-controls-custom-block-border-left, var(--sd-content-controls-custom-block-border, 0 solid transparent));', + ); + }); }); describe('ensureTrackChangeStyles', () => { diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index e86fb474a0..0672bfce21 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -787,30 +787,56 @@ const SDT_CONTAINER_STYLES = ` /* Global content-control chrome opt-out: preserve SDT wrappers/datasets while * suppressing built-in visual chrome on structured-content controls. Their * label elements are not emitted by renderer/helpers when this class is - * present (DOM non-emission), and these rules neutralize - * border/padding/hover/selection visuals. documentSection chrome (e.g. the - * locked-section tooltip) is intentionally preserved and not in scope. */ -.superdoc-cc-chrome-none .superdoc-structured-content-inline, + * present (DOM non-emission). documentSection chrome (e.g. the locked-section + * tooltip) is intentionally preserved and not in scope. + * + * Custom styling surface (SD-3322): instead of fully erasing the look, these + * rules read --sd-content-controls-custom-* variables whose defaults reproduce + * the empty look (0-width transparent border, no background, no radius/padding). + * So chrome:'none' stays visually empty by default, but a consumer can paint + * their own field/clause look by setting those variables on the painted wrapper + * (target it via data-sdt-* attributes) - no !important, and no need to fight + * the .ProseMirror-selectednode / .sdt-group-hover state classes, because the + * painter reads the variables across rest, hover, and selected. The border is a + * full shorthand (e.g. "1px solid #1355ff"); its default "0 solid transparent" + * is identical in layout to no border. It's re-asserted in every state so the + * box never shifts (no jitter); only the background changes on hover/selected. + * Block controls add a -border-left override for an accent rail. */ +.superdoc-cc-chrome-none .superdoc-structured-content-inline { + padding: var(--sd-content-controls-custom-inline-padding, 0); + border: var(--sd-content-controls-custom-inline-border, 0 solid transparent); + border-radius: var(--sd-content-controls-custom-inline-radius, 0); + background: var(--sd-content-controls-custom-inline-bg, none); +} .superdoc-cc-chrome-none .superdoc-structured-content-block { - border: none; - padding: 0; - border-radius: 0; - background: none; + padding: var(--sd-content-controls-custom-block-padding, 0); + border: var(--sd-content-controls-custom-block-border, 0 solid transparent); + border-left: var(--sd-content-controls-custom-block-border-left, var(--sd-content-controls-custom-block-border, 0 solid transparent)); + border-radius: var(--sd-content-controls-custom-block-radius, 0); + background: var(--sd-content-controls-custom-block-bg, none); } .superdoc-cc-chrome-none .superdoc-structured-content-inline:hover, +.superdoc-cc-chrome-none .superdoc-structured-content-inline[data-lock-mode]:hover { + border: var(--sd-content-controls-custom-inline-border, 0 solid transparent); + background: var(--sd-content-controls-custom-inline-hover-bg, var(--sd-content-controls-custom-inline-bg, none)); +} .superdoc-cc-chrome-none .superdoc-structured-content-block:hover, .superdoc-cc-chrome-none .superdoc-structured-content-block.sdt-group-hover, -.superdoc-cc-chrome-none .superdoc-structured-content-block[data-lock-mode].sdt-group-hover, -.superdoc-cc-chrome-none .superdoc-structured-content-inline[data-lock-mode]:hover { - border: none; - background: none; +.superdoc-cc-chrome-none .superdoc-structured-content-block[data-lock-mode].sdt-group-hover { + border: var(--sd-content-controls-custom-block-border, 0 solid transparent); + border-left: var(--sd-content-controls-custom-block-border-left, var(--sd-content-controls-custom-block-border, 0 solid transparent)); + background: var(--sd-content-controls-custom-block-hover-bg, var(--sd-content-controls-custom-block-bg, none)); } -.superdoc-cc-chrome-none .superdoc-structured-content-inline.ProseMirror-selectednode, +.superdoc-cc-chrome-none .superdoc-structured-content-inline.ProseMirror-selectednode { + border: var(--sd-content-controls-custom-inline-border, 0 solid transparent); + background: var(--sd-content-controls-custom-inline-selected-bg, var(--sd-content-controls-custom-inline-hover-bg, var(--sd-content-controls-custom-inline-bg, none))); +} .superdoc-cc-chrome-none .superdoc-structured-content-block.ProseMirror-selectednode { - border-color: transparent; - background: none; + border: var(--sd-content-controls-custom-block-border, 0 solid transparent); + border-left: var(--sd-content-controls-custom-block-border-left, var(--sd-content-controls-custom-block-border, 0 solid transparent)); + background: var(--sd-content-controls-custom-block-selected-bg, var(--sd-content-controls-custom-block-hover-bg, var(--sd-content-controls-custom-block-bg, none))); } /* Hover highlight for SDT containers. diff --git a/packages/superdoc/src/assets/styles/helpers/variables.css b/packages/superdoc/src/assets/styles/helpers/variables.css index d8ec54adb3..1da9f2d8bc 100644 --- a/packages/superdoc/src/assets/styles/helpers/variables.css +++ b/packages/superdoc/src/assets/styles/helpers/variables.css @@ -199,7 +199,8 @@ --sd-tracked-changes-delete-background-focused: #cb0e4744; --sd-tracked-changes-format-background-focused: #ffd70033; - /* Styles: content controls (SDT) β€” blue accent, intentionally standalone */ + /* Styles: content controls (SDT) β€” blue accent, intentionally standalone. + These theme the BUILT-IN chrome (modules.contentControls.chrome: 'default'). */ --sd-content-controls-block-border: #629be7; --sd-content-controls-block-hover-border: transparent; --sd-content-controls-block-hover-bg: var(--sd-ui-hover-bg); @@ -211,6 +212,22 @@ --sd-content-controls-label-text: #ffffff; --sd-content-controls-lock-hover-bg: rgba(98, 155, 231, 0.08); + /* Custom SDT styling under chrome:'none' (SD-3322). The built-in chrome is + off, so set these on the painted wrapper (target via data-sdt-* attributes) + to paint your own field/clause look. The painter applies them across rest, + hover, and selected, so no !important and no state-class selectors are + needed. Unset by default (chrome:'none' stays visually empty). `border` is a + full shorthand, e.g. `1px solid #1355ff`; block adds a `-border-left` for an + accent rail. Inline: + --sd-content-controls-custom-inline-bg + --sd-content-controls-custom-inline-border + --sd-content-controls-custom-inline-radius + --sd-content-controls-custom-inline-padding + --sd-content-controls-custom-inline-hover-bg + --sd-content-controls-custom-inline-selected-bg + Block (same set, plus): + --sd-content-controls-custom-block-border-left */ + /* UI: surface system β€” dialog and floating overlays */ --sd-ui-surface-bg: var(--sd-popover-bg, var(--sd-ui-bg)); --sd-ui-surface-border: var(--sd-ui-border); From d01af4744d34be7fb8dd2dd876fc070d2da801a9 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 30 May 2026 10:10:55 -0300 Subject: [PATCH 14/23] fix(painter-dom): custom hover wins on locked SDTs under chrome:'none' (SD-3322) The custom hover background was overridden for LOCKED controls under chrome:'none'. The base lock-hover rules (a built-in tint on inline, transparent on block) have equal specificity to the plain custom hover rules but come later in source order, so they won; the chrome-none lock-hover reset only reset z-index, not background. Re-assert the custom hover background in that reset block - it carries the extra .superdoc-cc-chrome-none class, so it outranks the base lock-hover rules. A locked control now follows --sd-content-controls-custom-*-hover-bg. With no custom var set the default is empty, so the built-in lock-hover tint no longer leaks under chrome:'none' for locked controls (consistently empty). Only the contract-templates demo has locked chrome-none controls, and it wants the custom hover, not the tint. Add a regression test asserting the custom hover vars are re-asserted after the base lock-hover rules (source order = it wins). painter-dom 1179/1179 green. --- .../painters/dom/src/styles.test.ts | 24 +++++++++++++++++++ .../layout-engine/painters/dom/src/styles.ts | 17 +++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index c0f1632c66..b8eb27442e 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -352,6 +352,30 @@ describe('ensureSdtContainerStyles', () => { 'border-left: var(--sd-content-controls-custom-block-border-left, var(--sd-content-controls-custom-block-border, 0 solid transparent));', ); }); + + it('locked-hover under chrome-none follows the custom hover background, not the built-in lock-hover (SD-3322)', () => { + ensureSdtContainerStyles(document); + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + + // The base lock-hover rules (built-in tint on inline, transparent on block) + // come first and have equal specificity to the plain custom hover rules, so + // they would otherwise win for locked controls. + const baseInlineLockHover = cssText.indexOf('background-color: var(--sd-content-controls-lock-hover-bg'); + const baseBlockLockHover = cssText.indexOf( + '.superdoc-structured-content-block[data-lock-mode].sdt-group-hover:not(.ProseMirror-selectednode) {', + ); + expect(baseInlineLockHover).toBeGreaterThan(-1); + expect(baseBlockLockHover).toBeGreaterThan(-1); + + // The chrome-none lock-hover reset re-asserts the custom hover background + // AFTER them (extra .superdoc-cc-chrome-none class + later source order wins), + // so a locked control under chrome:'none' uses the custom variable. + const customInlineHoverReassert = cssText.lastIndexOf('--sd-content-controls-custom-inline-hover-bg'); + const customBlockHoverReassert = cssText.lastIndexOf('--sd-content-controls-custom-block-hover-bg'); + expect(customInlineHoverReassert).toBeGreaterThan(baseInlineLockHover); + expect(customBlockHoverReassert).toBeGreaterThan(baseBlockLockHover); + }); }); describe('ensureTrackChangeStyles', () => { diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 0672bfce21..0a6198befc 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -885,11 +885,20 @@ const SDT_CONTAINER_STYLES = ` border: none; } -/* Reset the lock-hover z-index boost so a suppressed SDT does not stack - * above host-attached custom UI. Mirrors the base lock-hover selectors with - * the chrome-none prefix so specificity stays above the boost rule. */ -.superdoc-cc-chrome-none .superdoc-structured-content-block[data-lock-mode].sdt-group-hover:not(.ProseMirror-selectednode), +/* Chrome opt-out for the lock-hover affordance. The base lock-hover rules above + * paint a built-in tint and boost z-index on hovered locked controls; under + * chrome:'none' that would override the custom hover background and stack above + * host-attached UI. Re-assert the custom hover background (so a locked control + * follows --sd-content-controls-custom-*-hover-bg, defaulting to empty - no tint + * leaks) and reset the z-index. Mirrors the base lock-hover selectors with the + * chrome-none prefix, so the extra class wins over the base rules. Split inline + * vs block because each reads its own hover variable. */ .superdoc-cc-chrome-none .superdoc-structured-content-inline[data-lock-mode]:hover:not(.ProseMirror-selectednode, [data-appearance='hidden']) { + background: var(--sd-content-controls-custom-inline-hover-bg, var(--sd-content-controls-custom-inline-bg, none)); + z-index: auto; +} +.superdoc-cc-chrome-none .superdoc-structured-content-block[data-lock-mode].sdt-group-hover:not(.ProseMirror-selectednode) { + background: var(--sd-content-controls-custom-block-hover-bg, var(--sd-content-controls-custom-block-bg, none)); z-index: auto; } From 928475bc10a312b09d6a54fc29f00491875c0740 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 30 May 2026 10:20:16 -0300 Subject: [PATCH 15/23] docs(theming): point chrome:'none' styling at the custom SDT variables (SD-3322) The content-controls theming table themes the built-in chrome. Add a one-line note that under chrome:'none' you style controls with the --sd-content-controls-custom-* variables instead, linking the custom UI guide. --- apps/docs/editor/theming/custom-themes.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/docs/editor/theming/custom-themes.mdx b/apps/docs/editor/theming/custom-themes.mdx index f21cbcfab9..1f3eda8d8a 100644 --- a/apps/docs/editor/theming/custom-themes.mdx +++ b/apps/docs/editor/theming/custom-themes.mdx @@ -318,6 +318,8 @@ If you also want tracked-change text inside comment threads to match, set `--sd- DOCX content controls (SDTs): form fields, dropdowns, date pickers. +These theme the **built-in** chrome (`modules.contentControls.chrome: 'default'`). If you turn the chrome off (`chrome: 'none'`) to draw your own field/clause look, style the controls with the `--sd-content-controls-custom-*` variables instead. See [Custom UI > Content controls](/editor/custom-ui/content-controls). + | Variable | Default | Controls | |----------|---------|----------| | `--sd-content-controls-block-border` | `#629be7` | Block control border | From 2acccf1aada5545736b4699de46fbe949d6f082c Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 30 May 2026 11:01:24 -0300 Subject: [PATCH 16/23] demo/docs: contract-templates use the custom SDT styling variables (SD-3322) Rewrite the contract-templates demo's SDT styling onto SuperDoc's public --sd-content-controls-custom-* variables (from #3590), proving the new API in the real legal-template use case. The demo now styles its inline fields and block clauses with zero !important and zero internal state selectors (.ProseMirror-selectednode, .sdt-group-hover); the painter applies the variables across rest, hover, selected, and locked-hover. This is the copy-pasteable pattern for styling custom SDTs under chrome:'none'. - style.css: replace the per-state !important rules with one variable-setting rule per tag (inline + block); update the host-owned-styling comment. - test: add state coverage - the custom hover background drives a painted field (and wins over the built-in lock-hover tint), the border stays constant across states (no jitter), and no built-in label/chrome leaks. Demo suite 13/13. - docs (Document API > Content controls): correct the contentLocked wording (it rejects Document API content writes too, not just the editor); document the locked-template pattern (unlock -> write -> relock, incl. a locked parent for nested fields); add the single-use governed clause-library pattern alongside versioned reusable sections (kept - it's a valid pattern). - docs (Custom UI > Content controls): add a "Build a custom field system" walkthrough; describe the demo as a full custom contract-template UI. - README: note the demo styles through the public custom variables. Stacked on #3590 (the painter variable layer); retarget to main once it merges. --- .../features/content-controls.mdx | 10 ++- .../editor/custom-ui/content-controls.mdx | 13 ++- .../contract-templates-smart-tags.spec.ts | 45 ++++++++++ demos/contract-templates/README.md | 2 + demos/contract-templates/src/style.css | 83 +++++++------------ 5 files changed, 98 insertions(+), 55 deletions(-) diff --git a/apps/docs/document-api/features/content-controls.mdx b/apps/docs/document-api/features/content-controls.mdx index 2f48d35d3c..ee7a34850d 100644 --- a/apps/docs/document-api/features/content-controls.mdx +++ b/apps/docs/document-api/features/content-controls.mdx @@ -66,7 +66,9 @@ for (const control of items) { } ``` -Composed runtime: [`demos/contract-templates`](https://github.com/superdoc-dev/superdoc/tree/main/demos/contract-templates). +Or keep clauses **single-use and governed**: a clause is either in the contract or available to add from a library, and it appears once. Track inclusion by querying `contentControls.list` for the `sectionId` instead of comparing versions, and lock each placed clause (`contentLocked`) so its prose is fixed. A clause can also carry nested smart fields - inline controls inside the block - that fill from one place. + +The [`demos/contract-templates`](https://github.com/superdoc-dev/superdoc/tree/main/demos/contract-templates) runtime composes the single-use approach: a clause library that inserts locked block clauses (some with nested fields), each filled by tag from a form. ## Why `tag`, not `nodeId` @@ -134,10 +136,12 @@ Set `lockMode` when you create a control to govern which changes are allowed. |---|---| | `unlocked` | Content and properties can be updated through the Document API. | | `sdtLocked` | The wrapper is preserved through user edits. | -| `contentLocked` | The content can't be modified through the editor surface. | +| `contentLocked` | The user can't edit the content, **and** content writes through the Document API (`text.setValue`, `replaceContent`) are rejected too - they return a `LOCK_VIOLATION`. | | `sdtContentLocked` | Both wrapper and content are preserved. | -For controls your app drives with `text.setValue`, `replaceContent`, or `patch`, use `lockMode: 'unlocked'`. +For controls your app drives freely with `text.setValue` or `replaceContent`, use `lockMode: 'unlocked'`. + +For a **locked template** - controls the user can't touch, but your app still updates - keep them `contentLocked` and unlock around each write: `setLockMode({ lockMode: 'unlocked' })`, write, then `setLockMode({ lockMode: 'contentLocked' })`. Use `try`/`finally` so a failed write never leaves a control unlocked. `setLockMode` and `patch` are not blocked by `contentLocked`, so only the content write needs the unlock window. A smart field nested inside a locked block control needs the **parent** unlocked for the write too, since the parent's content lock vetoes writes to anything inside it. ## Data binding diff --git a/apps/docs/editor/custom-ui/content-controls.mdx b/apps/docs/editor/custom-ui/content-controls.mdx index 9f3d73f734..2ff5e09fce 100644 --- a/apps/docs/editor/custom-ui/content-controls.mdx +++ b/apps/docs/editor/custom-ui/content-controls.mdx @@ -82,8 +82,19 @@ This is the path for `chrome: 'none'`. To theme the **built-in** chrome instead You build your UI *over* the control, not inside it. SuperDoc owns how the control's content is painted in the document; you turn off its built-in chrome and draw your own (chips, badges, panels) anchored with `getRect`, react with the events, and change content through `editor.doc.contentControls.*`. Custom field types are expressed as a `tag` - for example `{ kind: 'smartField', key: 'party_name' }`, interpreted by your own UI - the underlying control stays a standard Word SDT so it round-trips to `.docx`. +## Build a custom field system + +Putting it together into a fillable template, the way the contract-templates demo does: + +1. **Define a tag schema.** Give each control a JSON `tag` your app owns - e.g. `{ kind: 'smartField', key }` for inline variables and `{ kind: 'reusableSection', sectionId }` for clauses. +2. **Insert from a palette.** Drop a control at a point with `editor.doc.create.contentControl({ kind, at, content, tag, lockMode })`, resolving the drop point with `ui.viewport.positionAt({ x, y })`. A clause can wrap its `{ field }` slots as nested inline controls. +3. **Style it.** Set the `--sd-content-controls-custom-*` variables on a `data-sdt-tag` selector (see [Style the controls in place](#style-the-controls-in-place)). The sidebar chips can reuse the same tokens, so palette and document match. +4. **React.** Highlight the active control from `content-control:active-change` / `:click`, and anchor overlays with `getRect` + `ui.viewport.observe`. +5. **Fill by tag.** A form edits a value and fans it to every occurrence: `editor.doc.contentControls.selectByTag({ tag })`, then `text.setValue` per occurrence. +6. **Govern with locks.** Keep controls `contentLocked` so users can't edit them, and have the form unlock β†’ write β†’ relock (see [Lock modes](/document-api/features/content-controls#lock-modes)). For a field nested in a locked clause, unlock the parent for the write. + ## See also -- [Contract templates demo](https://github.com/superdoc-dev/superdoc/tree/main/demos/contract-templates) - a working field chip built on these APIs. +- [Contract templates demo](https://github.com/superdoc-dev/superdoc/tree/main/demos/contract-templates) - a full custom contract-template UI: a field + clause library, custom SDT styling, locks, form-driven values, events, insert, and export. - [Configuration](/editor/superdoc/configuration) - the `modules.contentControls.chrome` option. - [Document API: content controls](/document-api/features/content-controls) - read and change controls. diff --git a/demos/__tests__/contract-templates-smart-tags.spec.ts b/demos/__tests__/contract-templates-smart-tags.spec.ts index face61bf61..a2c44d2e4b 100644 --- a/demos/__tests__/contract-templates-smart-tags.spec.ts +++ b/demos/__tests__/contract-templates-smart-tags.spec.ts @@ -397,3 +397,48 @@ test('adding the Return of Materials clause nests a real smart field that fills .poll(async () => (await receivingPartyControls()).filter((t) => t === 'Beacon Bio').length, { timeout: 6_000 }) .toBe(before + 1); }); + +test('the public custom SDT variables drive the painted fields across states (no !important)', 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 sel = ".superdoc-structured-content-inline[data-sdt-tag*='smartField']"; + await page.waitForSelector(sel); + const field = page.locator(sel).first(); + + const bg = () => field.evaluate((el) => getComputedStyle(el).backgroundColor); + const borderTop = () => + field.evaluate((el) => `${getComputedStyle(el).borderTopWidth} ${getComputedStyle(el).borderTopColor}`); + + const restBg = await bg(); + const restBorder = await borderTop(); + await field.hover(); + await page.waitForTimeout(250); + const hoverBg = await bg(); + const hoverBorder = await borderTop(); + + // The custom hover background applies (the fill changes)... + expect(hoverBg).not.toBe(restBg); + // ...and it is NOT the built-in lock-hover tint. Fields carry data-lock-mode, + // which matches SuperDoc's lock-hover path; the custom variable must win. + expect(hoverBg).not.toBe('rgba(98, 155, 231, 0.08)'); + // The border is constant across states (no jitter) - achieved with variables + // alone: the demo CSS has no !important and no .ProseMirror-selectednode / + // .sdt-group-hover state selectors. + expect(hoverBorder).toBe(restBorder); + expect(restBorder.startsWith('1px ')).toBe(true); + + // No built-in label / chrome leaks under chrome:'none'. + const leakedLabels = await page + .locator('.superdoc-structured-content__label, .superdoc-structured-content-inline__label') + .count(); + expect(leakedLabels).toBe(0); +}); diff --git a/demos/contract-templates/README.md b/demos/contract-templates/README.md index d2d974512d..062eedc38f 100644 --- a/demos/contract-templates/README.md +++ b/demos/contract-templates/README.md @@ -15,6 +15,8 @@ The starting document is `public/nda-template.docx`: inline plain-text fields an - 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). +**Custom styling.** With chrome off, the field and clause look is set entirely through SuperDoc's public `--sd-content-controls-custom-*` CSS variables, on a `data-sdt-tag` selector. SuperDoc applies them across rest, hover, selected, and locked-hover, so the demo's CSS has no `!important` and no internal state classes (`.ProseMirror-selectednode`, `.sdt-group-hover`) - copy these rules to style your own SDTs. See [Custom UI > Content controls](https://docs.superdoc.dev/editor/custom-ui/content-controls). + 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. diff --git a/demos/contract-templates/src/style.css b/demos/contract-templates/src/style.css index ae499069a1..3348d77676 100644 --- a/demos/contract-templates/src/style.css +++ b/demos/contract-templates/src/style.css @@ -263,12 +263,12 @@ input:focus { /* ----------------------------------------------------------------------- Host-owned SDT styling. This demo turns off SuperDoc's built-in content-control chrome - (`modules.contentControls.chrome: 'none'` in main.ts) and paints its - own. The painter adds `.superdoc-cc-chrome-none` to the mount and resets - border/padding/radius/background on the SDT wrappers; scoping under that - class keeps these rules above the reset in specificity and cascade, and - restores the box properties the reset strips. No painter label element - exists under chrome-none, so there is nothing to style for it. + (`modules.contentControls.chrome: 'none'` in main.ts) and paints its own, + driving it entirely through SuperDoc's public --sd-content-controls-custom-* + variables. We set those variables per tag (on the data-sdt-tag selector) and + the painter applies them across rest, hover, selected, and locked-hover. So + there is no !important, and no .ProseMirror-selectednode / .sdt-group-hover + state selectors - this is the copy-pasteable pattern for styling custom SDTs. ----------------------------------------------------------------------- */ /* Smart-tag token look, shared by the in-editor inline SDT and the Smart-tags @@ -290,36 +290,23 @@ input:focus { --tag-block-bg-hover: color-mix(in srgb, var(--tag-color) 8%, var(--demo-bg)); --tag-radius: 6px; } -/* Inline smart fields: token pill (painted SDT wrapper under chrome:'none'). - The box (border width + padding) is identical in every state so clicking / - hovering a field never shifts layout. Hover changes only the fill. */ +/* Inline smart fields: token pill, painted by SuperDoc under chrome:'none'. We + set the public --sd-content-controls-custom-inline-* variables, and SuperDoc + applies them across rest, hover, selected, and locked-hover. No !important, no + .ProseMirror-selectednode / .sdt-group-hover state selectors, and the box + (border + padding) stays identical in every state, so a field never shifts on + hover or click. Only the background changes; the border is constant. */ .superdoc-cc-chrome-none .superdoc-structured-content-inline[data-sdt-tag*='smartField'] { - padding: 1px 6px; - border: 1px solid var(--tag-border); - border-radius: var(--tag-radius); - background-color: var(--tag-bg); + --sd-content-controls-custom-inline-border: 1px solid var(--tag-border); + --sd-content-controls-custom-inline-radius: var(--tag-radius); + --sd-content-controls-custom-inline-padding: 1px 6px; + --sd-content-controls-custom-inline-bg: var(--tag-bg); + --sd-content-controls-custom-inline-hover-bg: var(--tag-bg-hover); + --sd-content-controls-custom-inline-selected-bg: var(--tag-bg-hover); + /* Text colour is not part of the custom border/fill layer and chrome:'none' + does not reset it, so it can be set directly and stays stable across states. */ color: var(--tag-fg); } -.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 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). */ - border: 1px solid var(--tag-border) !important; - background-color: var(--tag-bg-hover) !important; -} -/* 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 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. */ - border: 1px solid var(--tag-border) !important; - background-color: var(--tag-bg-hover) !important; - color: var(--tag-fg) !important; -} /* Smart-tags palette (sidebar). Chips reuse the --tag-* token look above, so a palette chip and the field it inserts are visually identical. */ @@ -364,23 +351,17 @@ 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 a blue left rail, same field language as - the inline pills, but a region not a token: soft border, faint fill, a 4px - blue spine. */ +/* 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 blue + spine). Set the public --sd-content-controls-custom-block-* variables; SuperDoc + applies them across rest, hover, selected, and locked-hover - so, like the + inline fields, there's no !important and no .sdt-group-hover / + .ProseMirror-selectednode state selectors, and the box stays constant. */ .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); - border-radius: var(--tag-radius); - background-color: var(--tag-block-bg); -} -/* Under chrome:'none' SuperDoc resets the block's border + fill on hover - (.sdt-group-hover) and select (.ProseMirror-selectednode) β€” via ::before/ - ::after pseudo-elements, different mechanics than inline. Re-assert the exact - box (no jitter) and lift the fill slightly to show activity. !important wins - over the reset; same custom-UI rough edge as the inline rules above. */ -.superdoc-cc-chrome-none .superdoc-structured-content-block[data-sdt-tag*='reusableSection'].sdt-group-hover, -.superdoc-cc-chrome-none .superdoc-structured-content-block[data-sdt-tag*='reusableSection'].ProseMirror-selectednode { - border: 1px solid var(--tag-block-border) !important; - border-left: 4px solid var(--tag-color) !important; - background-color: var(--tag-block-bg-hover) !important; + --sd-content-controls-custom-block-border: 1px solid var(--tag-block-border); + --sd-content-controls-custom-block-border-left: 4px solid var(--tag-color); + --sd-content-controls-custom-block-radius: var(--tag-radius); + --sd-content-controls-custom-block-bg: var(--tag-block-bg); + --sd-content-controls-custom-block-hover-bg: var(--tag-block-bg-hover); + --sd-content-controls-custom-block-selected-bg: var(--tag-block-bg-hover); } From 402a67d97241f8e70251af3389974090f44071ea Mon Sep 17 00:00:00 2001 From: VladaHarbour <114763039+VladaHarbour@users.noreply.github.com> Date: Sat, 30 May 2026 21:49:50 +0300 Subject: [PATCH 17/23] refactor: move pm adapter out of layout engine (#3530) * refactor: move pm adapter out of layout engine * fix: review comments and build issue * fix: add pm adapter config ref to superdoc config * fix: bring missing file back after merging * fix: move pm layout adapter --- .github/scripts/risk-label.mjs | 2 +- .github/scripts/risk-label.test.mjs | 2 +- AGENTS.md | 18 +- CONTRIBUTING.md | 10 +- apps/cli/package.json | 1 - .../lib/cli-import-boundaries.test.ts | 2 +- docs/architecture/package-boundaries.md | 4 +- package.json | 2 +- .../src/contracts.test.ts | 2 +- packages/layout-engine/AGENTS.md | 49 +-- packages/layout-engine/contracts/README.md | 2 +- .../layout-engine/layout-bridge/package.json | 1 - .../test/headerFooterLayout.test.ts | 4 +- packages/layout-engine/package.json | 4 +- .../__mocks__/@converter/styles.d.ts | 19 -- .../pm-adapter/__mocks__/@converter/styles.js | 34 -- .../pm-adapter/debug-sections.js | 42 --- .../layout-engine/pm-adapter/package.json | 53 --- .../layout-engine/pm-adapter/tsconfig.json | 17 - .../pm-adapter/vitest.config.mjs | 13 - .../layout-engine/pm-adapter/vitest.setup.ts | 9 - packages/layout-engine/tests/README.md | 6 +- packages/layout-engine/tests/package.json | 1 - .../tests/src/architecture-boundaries.test.ts | 92 +++-- .../tests/src/collaboration-stress.test.ts | 2 +- .../tests/src/comments-integration.test.ts | 2 +- .../tests/src/editor-parity.test.ts | 2 +- .../src/header-footer-integration.test.ts | 2 +- .../tests/src/memory-profile.test.ts | 2 +- .../multi-section-page-count-simple.test.ts | 8 +- .../src/multi-section-page-count.test.ts | 2 +- .../tests/src/performance.bench.ts | 4 +- .../tests/src/sd-1495-auto-page-break.test.ts | 3 +- .../tests/src/sdt-metadata.test.ts | 2 +- .../src/section-breaks-regression.test.ts | 5 +- .../tests/src/section-refs-merging.test.ts | 2 +- .../src/test-helpers/section-test-utils.ts | 2 +- .../tests/src/test-helpers/to-flow-blocks.ts | 6 + .../tests/src/toolbar-integration.test.ts | 2 +- packages/super-editor/package.json | 4 +- .../HeaderFooterRegistry.test.ts | 11 +- .../header-footer/HeaderFooterRegistry.ts | 4 +- .../editors/v1/core/layout-adapter}/README.md | 10 +- .../layout-adapter}/attributes/bidi.test.ts | 0 .../core/layout-adapter}/attributes/bidi.ts | 0 .../attributes/borders.test.ts | 0 .../layout-adapter}/attributes/borders.ts | 2 +- .../core/layout-adapter}/attributes/index.ts | 0 .../attributes/paragraph.test.ts | 0 .../layout-adapter}/attributes/paragraph.ts | 0 .../attributes/spacing-indent.test.ts | 0 .../attributes/spacing-indent.ts | 0 .../layout-adapter}/attributes/tabs.test.ts | 0 .../core/layout-adapter}/attributes/tabs.ts | 0 .../v1/core/layout-adapter}/cache.test.ts | 0 .../editors/v1/core/layout-adapter}/cache.ts | 0 .../v1/core/layout-adapter}/constants.ts | 0 .../layout-adapter}/converter-context.test.ts | 0 .../core/layout-adapter}/converter-context.ts | 0 .../core/layout-adapter}/converters/break.ts | 0 .../layout-adapter}/converters/chart.test.ts | 0 .../core/layout-adapter}/converters/chart.ts | 0 .../converters/content-block.test.ts | 0 .../converters/content-block.ts | 0 .../layout-adapter}/converters/image.test.ts | 0 .../core/layout-adapter}/converters/image.ts | 0 .../core/layout-adapter}/converters/index.ts | 0 .../inline-converters/authority-entry.ts | 0 .../inline-converters/bookmark-end.ts | 0 .../bookmark-markers.test.ts | 0 .../inline-converters/bookmark-start.ts | 0 .../converters/inline-converters/citation.ts | 0 .../inline-converters/common.test.ts | 0 .../converters/inline-converters/common.ts | 0 .../inline-converters/content-block.ts | 0 .../inline-converters/cross-reference.test.ts | 0 .../inline-converters/cross-reference.ts | 0 .../document-stat-field.test.ts | 0 .../inline-converters/document-stat-field.ts | 0 .../endnote-reference.test.ts | 0 .../inline-converters/endnote-reference.ts | 0 .../inline-converters/field-annotation.ts | 0 .../footnote-reference.test.ts | 0 .../inline-converters/footnote-reference.ts | 0 .../inline-converters/generic-token.test.ts | 0 .../inline-converters/generic-token.ts | 0 .../converters/inline-converters/image.ts | 0 .../inline-converters/line-break.ts | 0 .../converters/inline-converters/math.test.ts | 0 .../converters/inline-converters/math.ts | 0 .../inline-converters/no-break-hyphen.ts | 0 .../inline-converters/page-reference.test.ts | 0 .../inline-converters/page-reference.ts | 0 .../reference-marker.test.ts | 0 .../inline-converters/reference-marker.ts | 0 .../converters/inline-converters/run.ts | 0 .../inline-converters/sequence-field.ts | 0 .../converters/inline-converters/smart-tag.ts | 0 .../inline-converters/structured-content.ts | 0 .../converters/inline-converters/tab.test.ts | 0 .../converters/inline-converters/tab.ts | 0 .../inline-converters/text-run.test.ts | 0 .../converters/inline-converters/text-run.ts | 0 .../converters/math-block.test.ts | 0 .../layout-adapter}/converters/math-block.ts | 0 .../converters/math-constants.test.ts | 0 .../converters/math-constants.ts | 0 .../converters/paragraph.test.ts | 0 .../layout-adapter}/converters/paragraph.ts | 0 .../layout-adapter}/converters/shapes.test.ts | 0 .../core/layout-adapter}/converters/shapes.ts | 0 .../converters/table-styles.test.ts | 0 .../converters/table-styles.ts | 0 .../layout-adapter}/converters/table.test.ts | 0 .../core/layout-adapter}/converters/table.ts | 0 .../core/layout-adapter}/direction/README.md | 10 +- .../core/layout-adapter}/direction/index.ts | 0 .../layout-adapter}/direction/logicalSides.ts | 0 .../direction/non-collapse.test.ts | 0 .../direction/resolveCellDirection.ts | 0 .../direction/resolveParagraphDirection.ts | 0 .../direction/resolveSectionDirection.ts | 0 .../direction/resolveTableDirection.ts | 0 .../fixtures/basic-paragraph.json | 0 .../layout-adapter}/fixtures/bold-demo.json | 0 .../layout-adapter}/fixtures/edge-cases.json | 0 .../layout-adapter}/fixtures/hummingbird.json | 0 .../fixtures/image-inline-and-block.json | 0 .../layout-adapter}/fixtures/lists-basic.json | 0 .../layout-adapter}/fixtures/lists-docx.json | 0 .../fixtures/multi_section_doc.json | 0 .../fixtures/paragraph_pPr_variations.json | 0 .../fixtures/tabs-center-end.json | 0 .../fixtures/tabs-decimal.json | 0 .../fixtures/two-column-two-page.json | 0 .../v1/core/layout-adapter}/index.test.ts | 0 .../editors/v1/core/layout-adapter}/index.ts | 3 + .../core/layout-adapter}/integration.test.ts | 9 + .../v1/core/layout-adapter}/internal.test.ts | 0 .../v1/core/layout-adapter}/internal.ts | 0 .../v1/core/layout-adapter}/list-helpers.ts | 0 .../marks/__tests__/sanitization.test.ts | 0 .../__tests__/theme-color-resolution.test.ts | 0 .../layout-adapter}/marks/application.test.ts | 0 .../core/layout-adapter}/marks/application.ts | 0 .../v1/core/layout-adapter}/marks/index.ts | 0 .../core/layout-adapter}/marks/links.test.ts | 0 .../v1/core/layout-adapter}/marks/links.ts | 0 .../layout-adapter}/marks/theme-color.test.ts | 0 .../core/layout-adapter}/marks/theme-color.ts | 0 .../scripts/extract-pm-json.mjs | 34 +- .../core/layout-adapter}/sdt/bibliography.ts | 0 .../sdt/document-index.test.ts | 0 .../layout-adapter}/sdt/document-index.ts | 0 .../sdt/document-part-object.test.ts | 0 .../sdt/document-part-object.ts | 0 .../sdt/document-section.test.ts | 0 .../layout-adapter}/sdt/document-section.ts | 0 .../v1/core/layout-adapter}/sdt/index.test.ts | 0 .../v1/core/layout-adapter}/sdt/index.ts | 0 .../core/layout-adapter}/sdt/metadata.test.ts | 0 .../v1/core/layout-adapter}/sdt/metadata.ts | 0 .../sdt/structured-content-block.test.ts | 0 .../sdt/structured-content-block.ts | 0 .../sdt/table-of-authorities.ts | 0 .../v1/core/layout-adapter}/sdt/toc.test.ts | 0 .../v1/core/layout-adapter}/sdt/toc.ts | 0 .../layout-adapter}/sections/analysis.test.ts | 0 .../core/layout-adapter}/sections/analysis.ts | 0 .../core/layout-adapter}/sections/breaks.ts | 0 .../sections/end-tagged.test.ts | 0 .../sections/extraction.test.ts | 0 .../layout-adapter}/sections/extraction.ts | 0 .../v1/core/layout-adapter}/sections/index.ts | 0 .../v1/core/layout-adapter}/sections/types.ts | 0 .../layout-adapter}/source-anchor.test.ts | 0 .../layout-adapter}/tracked-changes.test.ts | 0 .../core/layout-adapter}/tracked-changes.ts | 0 .../editors/v1/core/layout-adapter}/types.ts | 0 .../v1/core/layout-adapter}/utilities.test.ts | 0 .../v1/core/layout-adapter}/utilities.ts | 0 .../presentation-editor/PresentationEditor.ts | 12 +- .../layout/EndnotesBuilder.ts | 6 +- .../layout/FootnotesBuilder.ts | 6 +- .../tests/FootnotesBuilder.test.ts | 63 ++-- .../PresentationEditor.decorationSync.test.ts | 9 +- .../PresentationEditor.draggableFocus.test.ts | 9 +- .../PresentationEditor.focusWrapping.test.ts | 9 +- ...sentationEditor.footnotesPmMarkers.test.ts | 9 +- ...entationEditor.getCurrentPageIndex.test.ts | 9 +- ...PresentationEditor.getElementAtPos.test.ts | 9 +- .../PresentationEditor.goToAnchor.test.ts | 9 +- .../tests/PresentationEditor.media.test.ts | 9 +- ...resentationEditor.scrollToPosition.test.ts | 9 +- ...esentationEditor.sectionPageStyles.test.ts | 10 +- .../tests/PresentationEditor.test.ts | 10 +- .../tests/PresentationEditor.zoom.test.ts | 9 +- .../mock-layout-document-adapter-vitest.ts | 27 ++ .../helpers/sections-resolver.ts | 15 +- ...structured-content-image-roundtrip.test.js | 2 +- .../v1/tests/parity/adapter-parity.test.js | 2 +- .../editors/v1/tests/parity/debug-parity.js | 2 +- .../v1/tests/parity/marker-styling.test.js | 2 +- .../v1/tests/parity/spacing-rendering.test.js | 2 +- .../v1/tests/parity/tabs-hanging.test.js | 2 +- packages/super-editor/src/index.types.test.ts | 8 +- .../superdoc/scripts/type-surface.config.cjs | 70 ++-- packages/superdoc/tsconfig.json | 6 +- packages/superdoc/tsconfig.types.json | 3 +- pnpm-lock.yaml | 315 +++++++++++------- vitest.config.mjs | 1 - 211 files changed, 583 insertions(+), 593 deletions(-) delete mode 100644 packages/layout-engine/pm-adapter/__mocks__/@converter/styles.d.ts delete mode 100644 packages/layout-engine/pm-adapter/__mocks__/@converter/styles.js delete mode 100644 packages/layout-engine/pm-adapter/debug-sections.js delete mode 100644 packages/layout-engine/pm-adapter/package.json delete mode 100644 packages/layout-engine/pm-adapter/tsconfig.json delete mode 100644 packages/layout-engine/pm-adapter/vitest.config.mjs delete mode 100644 packages/layout-engine/pm-adapter/vitest.setup.ts create mode 100644 packages/layout-engine/tests/src/test-helpers/to-flow-blocks.ts rename packages/{layout-engine/pm-adapter => super-editor/src/editors/v1/core/layout-adapter}/README.md (85%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/attributes/bidi.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/attributes/bidi.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/attributes/borders.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/attributes/borders.ts (99%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/attributes/index.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/attributes/paragraph.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/attributes/paragraph.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/attributes/spacing-indent.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/attributes/spacing-indent.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/attributes/tabs.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/attributes/tabs.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/cache.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/cache.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/constants.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converter-context.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converter-context.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/break.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/chart.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/chart.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/content-block.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/content-block.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/image.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/image.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/index.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/authority-entry.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/bookmark-end.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/bookmark-markers.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/bookmark-start.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/citation.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/common.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/common.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/content-block.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/cross-reference.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/cross-reference.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/document-stat-field.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/document-stat-field.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/endnote-reference.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/endnote-reference.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/field-annotation.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/footnote-reference.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/footnote-reference.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/generic-token.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/generic-token.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/image.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/line-break.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/math.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/math.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/no-break-hyphen.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/page-reference.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/page-reference.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/reference-marker.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/reference-marker.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/run.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/sequence-field.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/smart-tag.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/structured-content.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/tab.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/tab.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/text-run.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/inline-converters/text-run.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/math-block.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/math-block.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/math-constants.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/math-constants.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/paragraph.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/paragraph.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/shapes.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/shapes.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/table-styles.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/table-styles.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/table.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/converters/table.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/direction/README.md (94%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/direction/index.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/direction/logicalSides.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/direction/non-collapse.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/direction/resolveCellDirection.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/direction/resolveParagraphDirection.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/direction/resolveSectionDirection.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/direction/resolveTableDirection.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/fixtures/basic-paragraph.json (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/fixtures/bold-demo.json (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/fixtures/edge-cases.json (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/fixtures/hummingbird.json (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/fixtures/image-inline-and-block.json (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/fixtures/lists-basic.json (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/fixtures/lists-docx.json (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/fixtures/multi_section_doc.json (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/fixtures/paragraph_pPr_variations.json (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/fixtures/tabs-center-end.json (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/fixtures/tabs-decimal.json (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/fixtures/two-column-two-page.json (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/index.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/index.ts (91%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/integration.test.ts (98%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/internal.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/internal.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/list-helpers.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/marks/__tests__/sanitization.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/marks/__tests__/theme-color-resolution.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/marks/application.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/marks/application.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/marks/index.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/marks/links.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/marks/links.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/marks/theme-color.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/marks/theme-color.ts (100%) rename packages/{layout-engine/pm-adapter => super-editor/src/editors/v1/core/layout-adapter}/scripts/extract-pm-json.mjs (71%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sdt/bibliography.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sdt/document-index.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sdt/document-index.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sdt/document-part-object.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sdt/document-part-object.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sdt/document-section.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sdt/document-section.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sdt/index.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sdt/index.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sdt/metadata.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sdt/metadata.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sdt/structured-content-block.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sdt/structured-content-block.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sdt/table-of-authorities.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sdt/toc.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sdt/toc.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sections/analysis.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sections/analysis.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sections/breaks.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sections/end-tagged.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sections/extraction.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sections/extraction.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sections/index.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/sections/types.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/source-anchor.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/tracked-changes.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/tracked-changes.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/types.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/utilities.test.ts (100%) rename packages/{layout-engine/pm-adapter/src => super-editor/src/editors/v1/core/layout-adapter}/utilities.ts (100%) create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/tests/mock-layout-document-adapter-vitest.ts diff --git a/.github/scripts/risk-label.mjs b/.github/scripts/risk-label.mjs index 236407cd02..badd044763 100644 --- a/.github/scripts/risk-label.mjs +++ b/.github/scripts/risk-label.mjs @@ -7,7 +7,7 @@ import { readFileSync } from 'node:fs'; const CRITICAL_PATHS = [ 'packages/layout-engine/style-engine/', 'packages/layout-engine/layout-engine/', - 'packages/layout-engine/pm-adapter/', + 'packages/pm-adapter/', 'packages/layout-engine/layout-bridge/', 'packages/layout-engine/measuring/', 'packages/layout-engine/painters/', diff --git a/.github/scripts/risk-label.test.mjs b/.github/scripts/risk-label.test.mjs index 608420b4de..cb13fce77a 100644 --- a/.github/scripts/risk-label.test.mjs +++ b/.github/scripts/risk-label.test.mjs @@ -85,7 +85,7 @@ describe('classify', () => { it('critical: pm-adapter', () => { assert.equal( - classify(['packages/layout-engine/pm-adapter/src/foo.js']).level, + classify(['packages/pm-adapter/src/foo.js']).level, 'critical', ); }); diff --git a/AGENTS.md b/AGENTS.md index bf219229e6..3a29083be5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,16 +9,22 @@ SuperDoc uses its own rendering pipeline. ProseMirror stores document state; it ``` .docx β†’ super-converter parses OOXML into the hidden PM doc - β†’ pm-adapter reads PM state and resolved styles + β†’ v1 layout-adapter (super-editor: src/editors/v1/core/layout-adapter) + reads PM state and resolved styles β†’ FlowBlock[] β†’ layout-engine paginates β†’ ResolvedLayout β†’ DomPainter paints DOM ``` +- The v1 ProseMirror β†’ `FlowBlock[]` adapter is owned by `@superdoc/super-editor` + (`src/editors/v1/core/layout-adapter`). It is v1 SuperEditor's projection from + hidden ProseMirror state into layout data. v2 owns its own projection adapter. + `layout-engine` runtime packages consume `FlowBlock[]` and layout contracts + only; they must never import either concrete adapter. - `PresentationEditor` wraps a hidden ProseMirror `Editor`. Its contenteditable DOM is never shown. PresentationEditor bridges editor events into layout/paint state; do not resolve OOXML semantics there. - **DomPainter** (`layout-engine/painters/dom/`) owns all visual rendering. -- Style-resolved properties flow `pm-adapter` β†’ DomPainter. Do not style document content with PM decorations. +- Style-resolved properties flow `layout-adapter` β†’ DomPainter. Do not style document content with PM decorations. ### Where To Put Your Change @@ -26,8 +32,8 @@ SuperDoc uses its own rendering pipeline. ProseMirror stores document state; it |---|---|---| | DOCX import/export | `super-editor/src/editors/v1/core/super-converter/` | Parse and preserve OOXML, style refs, inline properties. Do not bake resolved formatting into direct attrs. | | Style cascade | `layout-engine/style-engine/` | Single source of truth for defaults, styles, conditional formatting, inline overrides. | -| Static document visuals | `pm-adapter/` data + `layout-engine/painters/dom/` rendering | Feed typed data into DomPainter. Do not style static content with PM decorations. | -| Direction-aware properties | `layout-engine/painters/dom/` | DomPainter mirrors at paint time for `w:bidiVisual`. pm-adapter stores logical sides LTR-default. Pre-mirroring upstream is a double-swap. See `packages/layout-engine/pm-adapter/src/direction/README.md`. | +| Static document visuals | v1 `core/layout-adapter/` data + `layout-engine/painters/dom/` rendering | Feed typed data into DomPainter. Do not style static content with PM decorations. | +| Direction-aware properties | `layout-engine/painters/dom/` | DomPainter mirrors at paint time for `w:bidiVisual`. The v1 layout-adapter stores logical sides LTR-default. Pre-mirroring upstream is a double-swap. See `packages/super-editor/src/editors/v1/core/layout-adapter/direction/README.md`. | | Editing behavior | `super-editor/src/editors/v1/extensions/` | Commands, keybindings, editor plugins. Do not duplicate cascade or render document visuals here. | | Final DOM rendering | `layout-engine/painters/dom/` | Render `ResolvedLayout`. Paint-time transforms (e.g. RTL mirror) live here. | | New doc-api operation | `packages/document-api/src/contract/operation-definitions.ts` | Contract-first; touches 4 files. See `packages/document-api/README.md`. | @@ -39,8 +45,8 @@ For specialized boundaries (interaction mapping, geometry/pagination, ephemeral Before adding a visual or direction-aware path, run: ```bash -# Painter must not import upstream packages. -rg "@superdoc/(pm-adapter|style-engine|layout-bridge|layout-resolved)" packages/layout-engine/painters/dom/src +# Painter must not import upstream packages or the concrete v1 adapter. +rg "@superdoc/(super-editor|style-engine|layout-bridge|layout-resolved)" packages/layout-engine/painters/dom/src ``` More checks in `packages/layout-engine/AGENTS.md`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2f450127e7..4f24f28780 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,13 +47,17 @@ SuperDoc uses its own rendering pipeline -- ProseMirror is NOT used for visual o ``` DOCX File β†’ super-converter (parse OOXML into ProseMirror document) - β†’ pm-adapter (convert PM nodes into FlowBlocks) + β†’ v1 layout-adapter (super-editor: convert PM nodes into FlowBlocks) β†’ layout-engine (paginate FlowBlocks into Layouts) β†’ DomPainter (render Layouts to DOM) ``` A hidden ProseMirror `Editor` instance manages document state and editing commands, but its DOM is never shown to the user. All visual rendering goes through DomPainter. +The PM β†’ FlowBlock adapter is owned by `super-editor` +(`src/editors/v1/core/layout-adapter`), not by `layout-engine`. The layout +engine packages consume `FlowBlock[]` and shared layout contracts only. + ### Project Structure ``` @@ -64,9 +68,9 @@ packages/ src/editors/v1/ core/ super-converter/ DOCX import/export (OOXML ↔ ProseMirror) + layout-adapter/ ProseMirror β†’ FlowBlock[] projection (v1-owned) extensions/ Editing behaviors (bold, lists, tables, etc.) layout-engine/ Layout & pagination pipeline - pm-adapter/ ProseMirror β†’ Layout bridge layout-engine/ Pagination algorithms painters/dom/ DOM rendering (DomPainter) style-engine/ OOXML style resolution & cascade @@ -84,7 +88,7 @@ tests/visual/ Visual regression tests (Playwright) |--------------------------|---------------| | How something looks (visual rendering) | `layout-engine/painters/dom/` | | Style resolution (fonts, colors, borders) | `layout-engine/style-engine/` | -| Data flowing from editor to renderer | `layout-engine/pm-adapter/` | +| Data flowing from editor to renderer | `super-editor/src/editors/v1/core/layout-adapter/` | | Editing behavior (keyboard, commands) | `super-editor/src/editors/v1/extensions/` | | DOCX import/export | `super-editor/src/editors/v1/core/super-converter/` | | React integration | `packages/react/` | diff --git a/apps/cli/package.json b/apps/cli/package.json index 5f4736c3d3..32655354b3 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -46,7 +46,6 @@ }, "devDependencies": { "@superdoc/document-api": "workspace:*", - "@superdoc/pm-adapter": "workspace:*", "@superdoc/super-editor": "workspace:*", "@types/bun": "catalog:", "@types/node": "catalog:", diff --git a/apps/cli/src/__tests__/lib/cli-import-boundaries.test.ts b/apps/cli/src/__tests__/lib/cli-import-boundaries.test.ts index 6cf42dfd6c..27145fe693 100644 --- a/apps/cli/src/__tests__/lib/cli-import-boundaries.test.ts +++ b/apps/cli/src/__tests__/lib/cli-import-boundaries.test.ts @@ -20,7 +20,7 @@ const BANNED_IMPORT_PATTERNS: ReadonlyArray<{ pattern: RegExp; reason: string }> reason: 'CLI modules must not import super-editor source internals directly.', }, { - pattern: /(?:^|\/)layout-engine\/(?:pm-adapter|layout-engine|painters|style-engine)\//, + pattern: /(?:^|\/)layout-engine\/(?:layout-engine|painters|style-engine)\//, reason: 'CLI modules must not import layout-engine internals directly.', }, { diff --git a/docs/architecture/package-boundaries.md b/docs/architecture/package-boundaries.md index 74e3f52963..d96e061d92 100644 --- a/docs/architecture/package-boundaries.md +++ b/docs/architecture/package-boundaries.md @@ -65,7 +65,7 @@ For any entry classified as legacy public: | `packages/layout-engine/dom-contract` | `@superdoc/dom-contract` | Internal implementation | DOM rendering contracts | | `packages/layout-engine/painters/dom` | `@superdoc/painter-dom` | Internal implementation | DOM rendering pipeline | | `packages/layout-engine/measuring/dom` | `@superdoc/measuring-dom` | Internal implementation | Measurement pipeline | -| `packages/layout-engine/pm-adapter` | `@superdoc/pm-adapter` | Internal implementation | ProseMirror to FlowBlock bridge | +| `packages/super-editor/src/editors/v1/core/layout-adapter` | (internal to `@superdoc/super-editor`) | Internal implementation | v1 ProseMirror β†’ FlowBlock projection; owned by super-editor, not a standalone package | | `packages/layout-engine/style-engine` | `@superdoc/style-engine` | Internal implementation | OOXML cascade resolution | | `packages/layout-engine/layout-bridge` | `@superdoc/layout-bridge` | Internal implementation | Pipeline orchestration | | `packages/layout-engine/layout-engine` | `@superdoc/layout-engine` | Internal implementation | Pagination algorithms | @@ -174,7 +174,7 @@ The relocation pattern is what `superdoc` currently uses for several internal-bu ### Decision 3. The layout-engine sub-packages stay separate. -**Context.** `packages/layout-engine/` contains ten sub-packages (`contracts`, `dom-contract`, `geometry-utils`, `layout-bridge`, `layout-engine`, `layout-resolved`, `pm-adapter`, `style-engine`, `painters/dom`, `measuring/dom`), all private, all internal implementation. +**Context.** `packages/layout-engine/` contains nine sub-packages (`contracts`, `dom-contract`, `geometry-utils`, `layout-bridge`, `layout-engine`, `layout-resolved`, `style-engine`, `painters/dom`, `measuring/dom`), all private, all internal implementation. (The v1 ProseMirror β†’ FlowBlock adapter is no longer here; it is owned by `@superdoc/super-editor` at `src/editors/v1/core/layout-adapter`.) **Decision.** Keep as-is. The audit gate (SD-2832) plus the type ownership rules remove the customer-visible cost of the split. Restructuring without a strong forcing function is scope creep. Revisit only if the audit gate proves expensive to maintain because of the package count. diff --git a/package.json b/package.json index ac9a86ab93..3243b24b45 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "test:behavior:html": "pnpm --filter @superdoc-testing/behavior test:html", "type-check": "pnpm run check:types", "type-check:force": "tsc -b --force tsconfig.references.json", - "rebuild:types": "pnpm --workspace-concurrency=1 run --filter=@superdoc/common --filter=@superdoc/word-layout --filter=@superdoc/contracts --filter=@superdoc/dom-contract --filter=@superdoc/layout-resolved --filter=@superdoc/geometry-utils --filter=@superdoc/style-engine --filter=@superdoc/pm-adapter --filter=@superdoc/measuring-dom --filter=@superdoc/layout-engine --filter=@superdoc/layout-bridge build && pnpm --filter=@superdoc/painter-dom build", + "rebuild:types": "pnpm --workspace-concurrency=1 run --filter=@superdoc/common --filter=@superdoc/word-layout --filter=@superdoc/contracts --filter=@superdoc/dom-contract --filter=@superdoc/layout-resolved --filter=@superdoc/geometry-utils --filter=@superdoc/style-engine --filter=@superdoc/measuring-dom --filter=@superdoc/layout-engine --filter=@superdoc/layout-bridge build && pnpm --filter=@superdoc/painter-dom build", "validate:commands": "node scripts/validate-command-types.mjs", "unzip": "bash packages/super-editor/src/editors/v1/tests/helpers/unzip.sh", "dev": "pnpm --prefix packages/superdoc run dev", diff --git a/packages/docx-evidence-contracts/src/contracts.test.ts b/packages/docx-evidence-contracts/src/contracts.test.ts index 860251d666..4b1b7613ee 100644 --- a/packages/docx-evidence-contracts/src/contracts.test.ts +++ b/packages/docx-evidence-contracts/src/contracts.test.ts @@ -188,7 +188,7 @@ describe('public DOCX evidence contracts', () => { expect(text).not.toMatch(/from ['"]node:/); expect(text).not.toMatch(/from ['"].*\.\.\/\.\.\/\.\.\/labs/); - expect(text).not.toMatch(/from ['"]@superdoc\/(super-editor|painter-dom|layout-engine|pm-adapter)/); + expect(text).not.toMatch(/from ['"]@superdoc\/(super-editor|painter-dom|layout-engine)/); } }); }); diff --git a/packages/layout-engine/AGENTS.md b/packages/layout-engine/AGENTS.md index 3d6a3a73de..2f17e5173f 100644 --- a/packages/layout-engine/AGENTS.md +++ b/packages/layout-engine/AGENTS.md @@ -5,32 +5,37 @@ Pagination and rendering pipeline for SuperDoc's presentation/viewing mode. ## Pipeline Overview ``` -ProseMirror Doc β†’ pm-adapter β†’ FlowBlock[] β†’ layout-engine β†’ Layout[] β†’ painter-dom β†’ DOM +ProseMirror Doc β†’ v1 layout-adapter (super-editor) β†’ FlowBlock[] β†’ layout-engine β†’ Layout[] β†’ painter-dom β†’ DOM ``` +The PM β†’ `FlowBlock[]` adapter is owned by `@superdoc/super-editor` +(`src/editors/v1/core/layout-adapter`), not by this package. The layout-engine +packages consume `FlowBlock[]` and the shared layout contracts only and must +never import the concrete adapter or `@superdoc/super-editor`. + ## Sub-packages | Package | Purpose | Key Entry | |---------|---------|-----------| -| `contracts/` | Shared types (FlowBlock, Layout, etc.) | `src/index.ts` | -| `pm-adapter/` | PM document β†’ FlowBlocks conversion | `src/internal.ts` | -| `layout-engine/` | Pagination algorithms | `src/index.ts` | -| `layout-bridge/` | Layout orchestration & bridge utilities | `src/incrementalLayout.ts` | -| `painters/dom/` | DOM rendering | `src/renderer.ts` | -| `style-engine/` | OOXML style resolution | `src/index.ts` | -| `geometry-utils/` | Math utilities for layout | `src/index.ts` | +| `contracts/` | Shared types (FlowBlock, Layout, etc.) | `contracts/src/index.ts` | +| v1 layout-adapter (super-editor) | PM document β†’ FlowBlocks conversion | `../super-editor/src/editors/v1/core/layout-adapter/internal.ts` | +| `layout-engine/` | Pagination algorithms | `layout-engine/src/index.ts` | +| `layout-bridge/` | Layout orchestration & bridge utilities | `layout-bridge/src/incrementalLayout.ts` | +| `painters/dom/` | DOM rendering | `painters/dom/src/renderer.ts` | +| `style-engine/` | OOXML style resolution | `style-engine/src/index.ts` | +| `geometry-utils/` | Math utilities for layout | `geometry-utils/src/index.ts` | ## Key Insight: DomPainter Receives Paint-Ready Data DomPainter receives a single paint-ready input β€” `ResolvedLayout` β€” and -renders it to DOM. It does not do layout logic, measurement, or pm-adapter +renders it to DOM. It does not do layout logic, measurement, or PM β†’ FlowBlock conversion. Those decisions happen upstream in `layout-engine/`, -`layout-resolved/`, and `pm-adapter/`. +`layout-resolved/`, and the v1 layout-adapter (super-editor). This is enforced as two hard invariants, not aspirational language: 1. **No upstream package imports.** The painter has zero runtime imports - from `@superdoc/pm-adapter`, `@superdoc/layout-bridge`, or + from the v1 adapter (`@superdoc/super-editor`), `@superdoc/layout-bridge`, or `@superdoc/layout-resolved`. Guard D in `tests/src/architecture-boundaries.test.ts` enforces this (SD-2836). 2. **No paint-time DOM measurement.** The painter never reads @@ -53,11 +58,11 @@ reads. | Change how OOXML element renders | `painters/dom/src/features/feature-registry.ts` β†’ feature module | | Change rendering orchestration | `painters/dom/src/renderer.ts` | | Change pagination/layout | `layout-engine/src/index.ts` | -| Add new block type | `pm-adapter/src/converters/` + `painters/dom/` | +| Add new block type | v1 `core/layout-adapter/converters/` + `painters/dom/` | | Change style resolution | `style-engine/` | | Change text measurement | `measuring-dom/` | -AIDEV-NOTE: `pm-adapter` must preserve shared `SdtMetadata` object identity for sibling blocks in one id-less SDT container; see `contracts/src/sdt-container.ts` before changing SDT imports. +AIDEV-NOTE: the v1 layout-adapter must preserve shared `SdtMetadata` object identity for sibling blocks in one id-less SDT container; see `contracts/src/sdt-container.ts` before changing SDT imports. ## Style Engine (`style-engine/`) @@ -103,20 +108,20 @@ Rendering logic for specific OOXML features is extracted into **feature modules* ### How to find where an OOXML element renders -1. **Search `features/feature-registry.ts`** β€” maps OOXML element names (e.g., `w:pBdr`, `w:shd`) to their feature module +1. **Search `painters/dom/src/features/feature-registry.ts`** β€” maps OOXML element names (e.g., `w:pBdr`, `w:shd`) to their feature module 2. Each entry has: `feature` (folder name), `module` (import path), `handles` (OOXML elements), `spec` (ECMA-376 section) 3. Open the feature's `index.ts` for its public API and `@ooxml`/`@spec` annotations ### Adding a new rendering feature -1. **Add a registry entry** in `features/feature-registry.ts` first β€” this is the source of truth -2. **Create the feature folder** at `features//`: +1. **Add a registry entry** in `painters/dom/src/features/feature-registry.ts` first β€” this is the source of truth +2. **Create the feature folder** at `painters/dom/src/features//`: - `index.ts` β€” barrel exports with `@ooxml` and `@spec` JSDoc annotations - Split logic into focused files (e.g., `group-analysis.ts`, `border-layer.ts`) - `types.ts` β€” shared types if needed 3. **Import from the feature module** in `renderer.ts` β€” renderer calls feature functions, features don't import from renderer 4. **Remove extracted code** from `renderer.ts` β€” don't leave dead copies -5. **Update imports** in any other files that used the old renderer exports (e.g., `table/renderTableCell.ts`) +5. **Update imports** in any other files that used the old renderer exports (e.g., `painters/dom/src/table/renderTableCell.ts`) ### Feature module conventions @@ -130,7 +135,7 @@ Rendering logic for specific OOXML features is extracted into **feature modules* | Feature | OOXML elements | Folder | |---------|---------------|--------| -| Paragraph borders & shading | `w:pBdr`, `w:shd` | `features/paragraph-borders/` | +| Paragraph borders & shading | `w:pBdr`, `w:shd` | `painters/dom/src/paragraph/borders/` | ## Entry Points @@ -138,14 +143,14 @@ Rendering logic for specific OOXML features is extracted into **feature modules* - `painters/dom/src/features/feature-registry.ts` - OOXML element β†’ feature module lookup - `painters/dom/src/styles.ts` - CSS class definitions - `layout-bridge/src/incrementalLayout.ts` - Layout orchestration (called by PresentationEditor) -- `pm-adapter/src/internal.ts` - PM β†’ FlowBlock conversion +- `../super-editor/src/editors/v1/core/layout-adapter/internal.ts` - PM β†’ FlowBlock conversion (super-editor-owned) ## Layer Ownership See root `CLAUDE.md` for the full placement map. This package owns the layout and rendering pipeline. -- Style-resolved properties flow through `style-engine` β†’ `pm-adapter` β†’ +- Style-resolved properties flow through `style-engine` β†’ v1 layout-adapter β†’ DomPainter. - Static document visuals belong in layout data plus DomPainter rendering, not ProseMirror decorations. @@ -154,9 +159,9 @@ layout and rendering pipeline. - `PresentationEditor` bridges editor state into layout and paint state. It should not resolve OOXML semantics. - Direction work keeps OOXML axes separate. `style-engine` resolves cascades, - `pm-adapter` writes typed direction/table attrs, and DomPainter owns + the v1 layout-adapter writes typed direction/table attrs, and DomPainter owns paint-time visual mirroring. For `w:bidiVisual`, upstream layers keep table sides in LTR-default form and DomPainter mirrors once. For the full direction taxonomy, see -`pm-adapter/src/direction/README.md`. +`../super-editor/src/editors/v1/core/layout-adapter/direction/README.md`. diff --git a/packages/layout-engine/contracts/README.md b/packages/layout-engine/contracts/README.md index 5a7547d01e..db8288e510 100644 --- a/packages/layout-engine/contracts/README.md +++ b/packages/layout-engine/contracts/README.md @@ -11,7 +11,7 @@ sync. `TrackedChangeMeta` types - `TextRun` now exposes an optional `trackedChange` payload carrying author/date metadata plus format deltas for track-change marks -- `AdapterOptions` (in `@superdoc/pm-adapter`) accepts `trackedChangesMode` and +- `AdapterOptions` (in the v1 SuperEditor layout adapter) accepts `trackedChangesMode` and `enableTrackedChanges` so callers can opt into the new metadata - Versioned `FlowRunLink` schema with extended metadata (target, rel, anchor, docLocation, etc.) diff --git a/packages/layout-engine/layout-bridge/package.json b/packages/layout-engine/layout-bridge/package.json index b21462d905..7d0fcf4798 100644 --- a/packages/layout-engine/layout-bridge/package.json +++ b/packages/layout-engine/layout-bridge/package.json @@ -31,7 +31,6 @@ "devDependencies": { "@superdoc/layout-resolved": "workspace:*", "@superdoc/painter-dom": "workspace:*", - "@superdoc/pm-adapter": "workspace:*", "@types/node": "catalog:", "tsup": "catalog:", "typescript": "catalog:", diff --git a/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts index 1ed4aa8cff..690f251187 100644 --- a/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts +++ b/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { FlowBlock, Measure } from '@superdoc/contracts'; -import { toFlowBlocks } from '@superdoc/pm-adapter'; +import { toFlowBlocks } from '@core/layout-adapter'; import { layoutHeaderFooterWithCache, HeaderFooterLayoutCache } from '../src/layoutHeaderFooter'; const makeBlock = (id: string, text = 'Hello'): FlowBlock => ({ @@ -141,7 +141,7 @@ describe('layoutHeaderFooterWithCache', () => { ], }; - // 2. Convert PM JSON to FlowBlocks using PM adapter + // 2. Convert PM JSON to FlowBlocks using the v1 layout adapter const { blocks: headerBlocks } = toFlowBlocks(headerPmDoc, { blockIdPrefix: 'header-default-' }); const { blocks: footerBlocks } = toFlowBlocks(footerPmDoc, { blockIdPrefix: 'footer-default-' }); diff --git a/packages/layout-engine/package.json b/packages/layout-engine/package.json index 95f75bdb19..fe563b8d38 100644 --- a/packages/layout-engine/package.json +++ b/packages/layout-engine/package.json @@ -4,7 +4,7 @@ "type": "module", "description": "Layout engine POC - manages layout, pagination, and rendering for SuperDoc", "scripts": { - "build": "pnpm run --filter=@superdoc/contracts --filter=@superdoc/dom-contract --filter=@superdoc/geometry-utils --filter=@superdoc/pm-adapter --filter=@superdoc/measuring-dom --filter=@superdoc/layout-engine --filter=@superdoc/layout-bridge --filter=@superdoc/painter-dom build", - "test": "pnpm run --filter=@superdoc/contracts --filter=@superdoc/dom-contract --filter=@superdoc/geometry-utils --filter=@superdoc/pm-adapter --filter=@superdoc/measuring-dom --filter=@superdoc/layout-engine --filter=@superdoc/layout-bridge --filter=@superdoc/painter-dom test" + "build": "pnpm run --filter=@superdoc/contracts --filter=@superdoc/dom-contract --filter=@superdoc/geometry-utils --filter=@superdoc/measuring-dom --filter=@superdoc/layout-engine --filter=@superdoc/layout-bridge --filter=@superdoc/painter-dom build", + "test": "pnpm run --filter=@superdoc/contracts --filter=@superdoc/dom-contract --filter=@superdoc/geometry-utils --filter=@superdoc/measuring-dom --filter=@superdoc/layout-engine --filter=@superdoc/layout-bridge --filter=@superdoc/painter-dom test" } } diff --git a/packages/layout-engine/pm-adapter/__mocks__/@converter/styles.d.ts b/packages/layout-engine/pm-adapter/__mocks__/@converter/styles.d.ts deleted file mode 100644 index a497db5ef3..0000000000 --- a/packages/layout-engine/pm-adapter/__mocks__/@converter/styles.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -export function resolveParagraphProperties( - docxContext: unknown, - inlineProps: unknown, -): ResolvedParagraphPropertiesExtended; -export function resolveRunProperties(styleId: unknown, context: unknown): Record; -/** - * Mock for @superdoc/converter/internal/styles resolveParagraphProperties - */ -export type ResolvedParagraphPropertiesExtended = { - spacing: unknown; - indent: unknown; - borders: unknown; - shading: unknown; - justification: unknown; - tabStops: unknown; - keepLines: boolean; - keepNext: boolean; - numberingProperties: unknown; -}; diff --git a/packages/layout-engine/pm-adapter/__mocks__/@converter/styles.js b/packages/layout-engine/pm-adapter/__mocks__/@converter/styles.js deleted file mode 100644 index aade0753d2..0000000000 --- a/packages/layout-engine/pm-adapter/__mocks__/@converter/styles.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Mock for @superdoc/converter/internal/styles.js - * This module is part of super-editor and not available in pm-adapter tests - * - * @typedef {Object} ResolvedParagraphPropertiesExtended - * @property {unknown} spacing - * @property {unknown} indent - * @property {unknown} borders - * @property {unknown} shading - * @property {unknown} justification - * @property {unknown} tabStops - * @property {boolean} keepLines - * @property {boolean} keepNext - * @property {unknown} numberingProperties - */ - -/** - * @param {unknown} _docxContext - * @param {unknown} _inlineProps - * @returns {ResolvedParagraphPropertiesExtended} - */ -export const resolveParagraphProperties = (_docxContext, _inlineProps) => ({ - spacing: null, - indent: null, - borders: null, - shading: null, - justification: null, - tabStops: null, - keepLines: false, - keepNext: false, - numberingProperties: null, -}); - -export const resolveRunProperties = (_styleId, _context) => ({}); diff --git a/packages/layout-engine/pm-adapter/debug-sections.js b/packages/layout-engine/pm-adapter/debug-sections.js deleted file mode 100644 index c69e4e8724..0000000000 --- a/packages/layout-engine/pm-adapter/debug-sections.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Debug script to analyze section break emission in multi-section document - */ - -console.log('\n=== MULTI-SECTION DOCUMENT ANALYSIS ===\n'); -console.log('Expected sections:'); -console.log(' Section 0: paras 0-2 (portrait, ends at para 2 sectPr)'); -console.log(' Section 1: paras 3-5 (portrait + 2 cols, ends at para 5 sectPr)'); -console.log(' Section 2: paras 6-8 (portrait, ends at para 8 sectPr)'); -console.log(' Section 3: paras 9-10 (landscape, uses body sectPr)'); - -console.log('\n=== CURRENT EMISSION LOGIC ===\n'); -console.log('1. Emit FIRST section break BEFORE content (line 510-533)'); -console.log(' - Type: continuous'); -console.log(' - isFirstSection: true'); -console.log('2. For each paragraph (line 562-589):'); -console.log(' - IF currentSectionIndex > 0: emit section break AFTER paragraph'); -console.log(' - SKIP if currentSectionIndex === 0'); - -console.log('\n=== PROBLEMS ===\n'); -console.log('Issue 1: Page 4 not landscape'); -console.log(' - Section 3 should use body sectPr (landscape)'); -console.log(' - Check if 4th section range created for body sectPr'); - -console.log('\nIssue 2: Page 1 has sections 1 AND 2 content'); -console.log(' - Section 0 ends at para 2'); -console.log(' - Skip logic prevents emit at para 2'); -console.log(' - First break is continuous (no page break)'); -console.log(' - Result: no page break between sections 0 and 1!'); - -console.log('\n=== ROOT CAUSE ===\n'); -console.log('Line 563: if (currentSectionIndex > 0)'); -console.log(' - Skips section 0 end emission'); -console.log(' - But section 0 has type=nextPage - should force break!'); - -console.log('\n=== FIX ===\n'); -console.log('Remove skip logic - emit ALL section breaks at paragraph end'); -console.log(' - First break (at start): continuous, sets properties'); -console.log(' - Section 0 (at para 2): nextPage, forces break'); -console.log(' - Section 1 (at para 5): nextPage, forces break'); -console.log(' - Section 2 (at para 8): nextPage, forces break'); -console.log(' - Total: 4 breaks'); diff --git a/packages/layout-engine/pm-adapter/package.json b/packages/layout-engine/pm-adapter/package.json deleted file mode 100644 index 0a8744c986..0000000000 --- a/packages/layout-engine/pm-adapter/package.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "name": "@superdoc/pm-adapter", - "version": "0.0.0", - "description": "ProseMirror to FlowBlocks adapter for the SuperDoc layout pipeline.", - "type": "module", - "private": true, - "main": "./src/index.ts", - "types": "./src/index.ts", - "exports": { - ".": { - "types": "./src/index.ts", - "default": "./src/index.ts" - }, - "./*.js": { - "types": "./src/*.ts", - "source": "./src/*.ts", - "default": "./src/*.ts" - }, - "./*": { - "types": "./src/*", - "source": "./src/*", - "default": "./src/*" - } - }, - "scripts": { - "build": "tsc --project tsconfig.json --noEmit", - "test": "vitest run", - "test:debug": "vitest --inspect-brk --pool threads --poolOptions.threads.singleThread", - "extract:docx": "vite-node --config ../../super-editor/vite.config.js --mode test scripts/extract-pm-json.mjs", - "format": "prettier --write \"src/**/*.{ts,tsx}\"", - "format:check": "prettier --check \"src/**/*.{ts,tsx}\"", - "lint": "eslint \"src/**/*.{ts,tsx}\"", - "lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix", - "type-check": "tsc --noEmit" - }, - "devDependencies": { - "vitest": "catalog:", - "@superdoc/layout-engine": "workspace:*", - "@superdoc/layout-resolved": "workspace:*", - "@superdoc/painter-dom": "workspace:*" - }, - "dependencies": { - "@superdoc/super-editor": "workspace:*", - "@superdoc/common": "workspace:*", - "@superdoc/contracts": "workspace:*", - "@superdoc/locale-utils": "workspace:*", - "@superdoc/measuring-dom": "workspace:*", - "@superdoc/style-engine": "workspace:*", - "@superdoc/word-layout": "workspace:*", - "@superdoc/url-validation": "workspace:*", - "@superdoc/font-utils": "workspace:*" - } -} diff --git a/packages/layout-engine/pm-adapter/tsconfig.json b/packages/layout-engine/pm-adapter/tsconfig.json deleted file mode 100644 index 31fdb8ae79..0000000000 --- a/packages/layout-engine/pm-adapter/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "extends": "../tsconfig.base.json", - "compilerOptions": { - "composite": true, - "declaration": true, - "declarationMap": true, - "outDir": "dist", - "baseUrl": ".", - "paths": { - "@superdoc/super-editor/converter/internal/*.js": [ - "../../super-editor/dist/src/editors/v1/core/super-converter/*.d.ts" - ], - "@translator": ["../../super-editor/dist/src/editors/v1/core/super-converter/v3/node-translator/index.d.ts"] - } - }, - "include": ["src/**/*.ts", "src/fixtures/**/*.json"] -} diff --git a/packages/layout-engine/pm-adapter/vitest.config.mjs b/packages/layout-engine/pm-adapter/vitest.config.mjs deleted file mode 100644 index 09c648b909..0000000000 --- a/packages/layout-engine/pm-adapter/vitest.config.mjs +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from 'vitest/config'; -import { resolve } from 'node:path'; -import baseConfig from '../../../vitest.baseConfig'; - -export default defineConfig({ - ...baseConfig, - test: { - // Use happy-dom for faster tests (set VITEST_DOM=jsdom to use jsdom) - environment: process.env.VITEST_DOM || 'happy-dom', - include: ['src/**/*.test.ts'], - setupFiles: [resolve(__dirname, './vitest.setup.ts')], - }, -}); diff --git a/packages/layout-engine/pm-adapter/vitest.setup.ts b/packages/layout-engine/pm-adapter/vitest.setup.ts deleted file mode 100644 index a9985c2ba7..0000000000 --- a/packages/layout-engine/pm-adapter/vitest.setup.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { resolveCanvas } from '@superdoc/measuring-dom/canvas-resolver'; -import { installNodeCanvasPolyfill } from '@superdoc/measuring-dom'; - -const { Canvas } = resolveCanvas(); - -installNodeCanvasPolyfill({ - document, - Canvas, -}); diff --git a/packages/layout-engine/tests/README.md b/packages/layout-engine/tests/README.md index e801419a10..1ebf00ab87 100644 --- a/packages/layout-engine/tests/README.md +++ b/packages/layout-engine/tests/README.md @@ -54,7 +54,7 @@ Key settings: ## Dependencies -- Relies on `@superdoc/pm-adapter`, `@superdoc/style-engine`, `@superdoc/layout-engine`, `@superdoc/painter-dom`. +- Relies on `@core/layout-adapter`, `@superdoc/style-engine`, `@superdoc/layout-engine`, `@superdoc/painter-dom`. - Runner: Vitest (`happy-dom` by default). ## Debugging @@ -63,12 +63,12 @@ If tests fail after SDT schema changes: 1. **Check contracts** (`@superdoc/contracts`) - ensure `SdtMetadata` union types are up to date 2. **Check style-engine** (`@superdoc/style-engine/src/index.ts`) - verify normalization helpers match new attrs -3. **Check PM adapter** (`@superdoc/pm-adapter/src/index.ts`) - confirm SDT unwrapping assigns metadata to blocks/runs +3. **Check v1 layout adapter** (`super-editor`'s layout-adapter index) - confirm SDT unwrapping assigns metadata to blocks/runs 4. **Inspect snapshot diffs** - Vitest will show what changed in the summarized output ## Related Documentation - Layout engine contracts: `packages/layout-engine/contracts/src/index.ts` - Style engine SDT parsing: `packages/layout-engine/style-engine/src/index.ts` -- PM adapter SDT handling: `packages/layout-engine/pm-adapter/src/index.ts` (search for `resolveNodeSdtMetadata`) +- v1 layout adapter SDT handling: `packages/super-editor/src/editors/v1/core/layout-adapter/index.ts` (search for `resolveNodeSdtMetadata`) - Planning docs: `packages/layout-engine/plan/fields-annotations-*.md` diff --git a/packages/layout-engine/tests/package.json b/packages/layout-engine/tests/package.json index 369d53a672..b8b4206739 100644 --- a/packages/layout-engine/tests/package.json +++ b/packages/layout-engine/tests/package.json @@ -10,7 +10,6 @@ "test:integration": "vitest run --grep 'Integration'" }, "dependencies": { - "@superdoc/pm-adapter": "workspace:*", "@superdoc/layout-engine": "workspace:*", "@superdoc/measuring-dom": "workspace:*", "@superdoc/contracts": "workspace:*", diff --git a/packages/layout-engine/tests/src/architecture-boundaries.test.ts b/packages/layout-engine/tests/src/architecture-boundaries.test.ts index c5d797fcf7..f5eeb83803 100644 --- a/packages/layout-engine/tests/src/architecture-boundaries.test.ts +++ b/packages/layout-engine/tests/src/architecture-boundaries.test.ts @@ -2,9 +2,9 @@ * Architecture boundary guardrails. * * These tests enforce the one-way import flow of the layout-engine pipeline: - * super-converter β†’ pm-adapter β†’ layout-engine / layout-bridge β†’ painter-dom - * ↑ - * style-engine (consumed ONLY by pm-adapter at runtime) + * super-converter β†’ v1 layout-adapter (super-editor) β†’ FlowBlock[] + * ↓ + * layout-engine / layout-bridge β†’ painter-dom * * Violations mean the pipeline has become circular or rendering logic has * leaked into data preparation (or vice versa). @@ -15,6 +15,10 @@ import fs from 'node:fs'; import path from 'node:path'; const LAYOUT_ENGINE_ROOT = path.resolve(__dirname, '../../'); +// SD-3222: the v1 ProseMirror adapter now lives inside @superdoc/super-editor +// (it is v1 SuperEditor's projection from hidden PM state into FlowBlock[]), +// not in a standalone layout-engine package. +const V1_ADAPTER_ROOT = path.resolve(__dirname, '../../../super-editor/src/editors/v1/core/layout-adapter'); // --------------------------------------------------------------------------- // Helpers @@ -121,7 +125,12 @@ function expectNoViolations(violations: { file: string; line: string }[]) { // --------------------------------------------------------------------------- describe('architecture boundaries', () => { - describe('Guard A: style-engine is only consumed by pm-adapter', () => { + it('sanity check: architecture guard source roots exist', () => { + expect(fs.existsSync(LAYOUT_ENGINE_ROOT)).toBe(true); + expect(fs.existsSync(V1_ADAPTER_ROOT)).toBe(true); + }); + + describe('Guard A: style-engine does not leak into layout runtime packages', () => { it('painter-dom runtime src does not import @superdoc/style-engine', () => { const srcDir = path.join(LAYOUT_ENGINE_ROOT, 'painters/dom/src'); expectNoViolations(findImportViolations(srcDir, '@superdoc/style-engine')); @@ -153,45 +162,39 @@ describe('architecture boundaries', () => { }); }); - describe('Guard B: painter-dom internals are not imported by pm-adapter', () => { - it('pm-adapter runtime src does not import @superdoc/painter-dom', () => { - const srcDir = path.join(LAYOUT_ENGINE_ROOT, 'pm-adapter/src'); - expectNoViolations(findImportViolations(srcDir, '@superdoc/painter-dom')); + describe('Guard B: painter-dom internals are not imported by the v1 adapter', () => { + it('v1 adapter runtime src does not import @superdoc/painter-dom', () => { + expectNoViolations(findImportViolations(V1_ADAPTER_ROOT, '@superdoc/painter-dom')); }); - it('pm-adapter runtime src does not import relative painter paths', () => { - const srcDir = path.join(LAYOUT_ENGINE_ROOT, 'pm-adapter/src'); + it('v1 adapter runtime src does not import relative painter paths', () => { // Catch any relative import reaching into painters/ directory - expectNoViolations(findRelativeImportViolations(srcDir, /from\s+['"].*painters\//)); + expectNoViolations(findRelativeImportViolations(V1_ADAPTER_ROOT, /from\s+['"].*painters\//)); }); }); - describe('Guard C: data flows one direction β€” pm-adapter does not import downstream', () => { - it('pm-adapter runtime src does not import @superdoc/layout-bridge', () => { - const srcDir = path.join(LAYOUT_ENGINE_ROOT, 'pm-adapter/src'); - expectNoViolations(findImportViolations(srcDir, '@superdoc/layout-bridge')); + describe('Guard C: data flows one direction β€” the v1 adapter does not import downstream', () => { + it('v1 adapter runtime src does not import @superdoc/layout-bridge', () => { + expectNoViolations(findImportViolations(V1_ADAPTER_ROOT, '@superdoc/layout-bridge')); }); - it('pm-adapter runtime src does not import @superdoc/layout-engine', () => { - const srcDir = path.join(LAYOUT_ENGINE_ROOT, 'pm-adapter/src'); - expectNoViolations(findImportViolations(srcDir, '@superdoc/layout-engine')); + it('v1 adapter runtime src does not import @superdoc/layout-engine', () => { + expectNoViolations(findImportViolations(V1_ADAPTER_ROOT, '@superdoc/layout-engine')); }); - it('pm-adapter runtime src does not import relative layout-bridge paths', () => { - const srcDir = path.join(LAYOUT_ENGINE_ROOT, 'pm-adapter/src'); - expectNoViolations(findRelativeImportViolations(srcDir, /from\s+['"].*layout-bridge\//)); + it('v1 adapter runtime src does not import relative layout-bridge paths', () => { + expectNoViolations(findRelativeImportViolations(V1_ADAPTER_ROOT, /from\s+['"].*layout-bridge\//)); }); - it('pm-adapter runtime src does not import relative layout-engine paths', () => { - const srcDir = path.join(LAYOUT_ENGINE_ROOT, 'pm-adapter/src'); - expectNoViolations(findRelativeImportViolations(srcDir, /from\s+['"].*layout-engine\//)); + it('v1 adapter runtime src does not import relative layout-engine paths', () => { + expectNoViolations(findRelativeImportViolations(V1_ADAPTER_ROOT, /from\s+['"].*layout-engine\//)); }); }); describe('Guard D: painter-dom is a dumb final renderer with no upstream dependencies', () => { - it('painter-dom runtime src does not import @superdoc/pm-adapter', () => { + it('painter-dom runtime src does not import @superdoc/super-editor', () => { const srcDir = path.join(LAYOUT_ENGINE_ROOT, 'painters/dom/src'); - expectNoViolations(findImportViolations(srcDir, '@superdoc/pm-adapter')); + expectNoViolations(findImportViolations(srcDir, '@superdoc/super-editor')); }); it('painter-dom runtime src does not import @superdoc/layout-bridge', () => { @@ -220,9 +223,9 @@ describe('architecture boundaries', () => { expectNoViolations(violations); }); - it('painter-dom runtime src does not import relative pm-adapter paths', () => { + it('painter-dom runtime src does not import the relative v1 layout-adapter path', () => { const srcDir = path.join(LAYOUT_ENGINE_ROOT, 'painters/dom/src'); - expectNoViolations(findRelativeImportViolations(srcDir, /from\s+['"].*pm-adapter\//)); + expectNoViolations(findRelativeImportViolations(srcDir, /from\s+['"].*super-editor\/.*layout-adapter/)); }); it('painter-dom runtime src does not import relative layout-bridge paths', () => { @@ -353,4 +356,37 @@ describe('architecture boundaries', () => { } }); }); + + describe('Guard H: layout runtime packages do not import concrete adapters (SD-3222)', () => { + const LAYOUT_RUNTIME_DIRS = [ + 'layout-engine/src', + 'layout-bridge/src', + 'painters/dom/src', + 'contracts/src', + 'dom-contract/src', + 'layout-resolved/src', + ]; + + for (const dir of LAYOUT_RUNTIME_DIRS) { + // The v1 ProseMirror adapter is owned by @superdoc/super-editor. Layout + // runtime packages consume FlowBlock[] and layout contracts only β€” they + // must never reach back into the concrete editor adapter, whether via the + // package specifier, source alias, or a relative path into super-editor's + // adapter source. + it(`${dir} does not import @superdoc/super-editor`, () => { + const srcDir = path.join(LAYOUT_ENGINE_ROOT, dir); + expectNoViolations(findImportViolations(srcDir, '@superdoc/super-editor')); + }); + + it(`${dir} does not import @core/layout-adapter`, () => { + const srcDir = path.join(LAYOUT_ENGINE_ROOT, dir); + expectNoViolations(findImportViolations(srcDir, '@core/layout-adapter')); + }); + + it(`${dir} does not import the relative v1 super-editor layout-adapter path`, () => { + const srcDir = path.join(LAYOUT_ENGINE_ROOT, dir); + expectNoViolations(findRelativeImportViolations(srcDir, /from\s+['"].*super-editor\/.*layout-adapter/)); + }); + } + }); }); diff --git a/packages/layout-engine/tests/src/collaboration-stress.test.ts b/packages/layout-engine/tests/src/collaboration-stress.test.ts index eff7abe997..f449771b57 100644 --- a/packages/layout-engine/tests/src/collaboration-stress.test.ts +++ b/packages/layout-engine/tests/src/collaboration-stress.test.ts @@ -13,7 +13,7 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { toFlowBlocks } from '@superdoc/pm-adapter'; +import { toFlowBlocks } from './test-helpers/to-flow-blocks.js'; import type { FlowBlock, PMNode, TrackedChangesMode } from '@superdoc/contracts'; import fs from 'fs'; import path from 'path'; diff --git a/packages/layout-engine/tests/src/comments-integration.test.ts b/packages/layout-engine/tests/src/comments-integration.test.ts index 43c4b61da3..6957194a7e 100644 --- a/packages/layout-engine/tests/src/comments-integration.test.ts +++ b/packages/layout-engine/tests/src/comments-integration.test.ts @@ -8,7 +8,7 @@ */ import { describe, it, expect } from 'vitest'; -import { toFlowBlocks } from '@superdoc/pm-adapter'; +import { toFlowBlocks } from './test-helpers/to-flow-blocks.js'; import type { FlowBlock, PMNode } from '@superdoc/contracts'; import fs from 'fs'; import path from 'path'; diff --git a/packages/layout-engine/tests/src/editor-parity.test.ts b/packages/layout-engine/tests/src/editor-parity.test.ts index 416351b39b..b8cfe24fa7 100644 --- a/packages/layout-engine/tests/src/editor-parity.test.ts +++ b/packages/layout-engine/tests/src/editor-parity.test.ts @@ -16,7 +16,7 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { toFlowBlocks } from '@superdoc/pm-adapter'; +import { toFlowBlocks } from './test-helpers/to-flow-blocks.js'; import type { FlowBlock, PMNode } from '@superdoc/contracts'; import fs from 'fs'; import path from 'path'; diff --git a/packages/layout-engine/tests/src/header-footer-integration.test.ts b/packages/layout-engine/tests/src/header-footer-integration.test.ts index c1201b8b6b..c97b310b79 100644 --- a/packages/layout-engine/tests/src/header-footer-integration.test.ts +++ b/packages/layout-engine/tests/src/header-footer-integration.test.ts @@ -8,7 +8,7 @@ */ import { describe, it, expect } from 'vitest'; -import { toFlowBlocks } from '@superdoc/pm-adapter'; +import { toFlowBlocks } from './test-helpers/to-flow-blocks.js'; import type { FlowBlock, PMNode } from '@superdoc/contracts'; import fs from 'fs'; import path from 'path'; diff --git a/packages/layout-engine/tests/src/memory-profile.test.ts b/packages/layout-engine/tests/src/memory-profile.test.ts index 9afcd6d754..cd3fa0ca93 100644 --- a/packages/layout-engine/tests/src/memory-profile.test.ts +++ b/packages/layout-engine/tests/src/memory-profile.test.ts @@ -11,7 +11,7 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { toFlowBlocks } from '@superdoc/pm-adapter'; +import { toFlowBlocks } from './test-helpers/to-flow-blocks.js'; import type { FlowBlock, PMNode } from '@superdoc/contracts'; import fs from 'fs'; import path from 'path'; diff --git a/packages/layout-engine/tests/src/multi-section-page-count-simple.test.ts b/packages/layout-engine/tests/src/multi-section-page-count-simple.test.ts index aae464740a..6d8d13eb45 100644 --- a/packages/layout-engine/tests/src/multi-section-page-count-simple.test.ts +++ b/packages/layout-engine/tests/src/multi-section-page-count-simple.test.ts @@ -15,7 +15,7 @@ */ import { describe, it, expect } from 'vitest'; -import { toFlowBlocks } from '@superdoc/pm-adapter'; +import { toFlowBlocks } from './test-helpers/to-flow-blocks.js'; import { layoutDocument } from '@superdoc/layout-engine'; import { measureBlock } from '@superdoc/measuring-dom'; import type { FlowBlock, PMNode, SectionBreakBlock, Measure } from '@superdoc/contracts'; @@ -33,7 +33,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); * @returns ProseMirror document */ function loadPMJsonFixture(fixtureName: string): PMNode { - const fixturePath = path.join(__dirname, '../../pm-adapter/src/fixtures', fixtureName); + const fixturePath = path.join( + __dirname, + '../../../super-editor/src/editors/v1/core/layout-adapter/fixtures', + fixtureName, + ); if (!fs.existsSync(fixturePath)) { throw new Error(`Fixture not found: ${fixturePath}`); diff --git a/packages/layout-engine/tests/src/multi-section-page-count.test.ts b/packages/layout-engine/tests/src/multi-section-page-count.test.ts index 22a90b5d8d..36956be0c4 100644 --- a/packages/layout-engine/tests/src/multi-section-page-count.test.ts +++ b/packages/layout-engine/tests/src/multi-section-page-count.test.ts @@ -15,7 +15,7 @@ */ import { describe, it, expect, beforeAll } from 'vitest'; -import { toFlowBlocks } from '@superdoc/pm-adapter'; +import { toFlowBlocks } from './test-helpers/to-flow-blocks.js'; import { layoutDocument } from '@superdoc/layout-engine'; import { measureBlocks } from './test-helpers/section-test-utils.js'; import type { FlowBlock, PMNode, SectionBreakBlock } from '@superdoc/contracts'; diff --git a/packages/layout-engine/tests/src/performance.bench.ts b/packages/layout-engine/tests/src/performance.bench.ts index 0ffc371f91..6b28afe399 100644 --- a/packages/layout-engine/tests/src/performance.bench.ts +++ b/packages/layout-engine/tests/src/performance.bench.ts @@ -12,8 +12,8 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { performance } from 'perf_hooks'; -import { toFlowBlocks } from '@superdoc/pm-adapter'; -import type { PMNode } from '../../pm-adapter/src/index.js'; +import { toFlowBlocks } from './test-helpers/to-flow-blocks.js'; +import type { PMNode } from '@core/layout-adapter'; import type { FlowBlock, Measure, Layout } from '@superdoc/contracts'; import fs from 'fs'; import path from 'path'; diff --git a/packages/layout-engine/tests/src/sd-1495-auto-page-break.test.ts b/packages/layout-engine/tests/src/sd-1495-auto-page-break.test.ts index 9bb5bf6d3f..b51f8b20f6 100644 --- a/packages/layout-engine/tests/src/sd-1495-auto-page-break.test.ts +++ b/packages/layout-engine/tests/src/sd-1495-auto-page-break.test.ts @@ -1,6 +1,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { layoutDocument } from '@superdoc/layout-engine'; -import { toFlowBlocks, type ConverterContext } from '@superdoc/pm-adapter'; +import type { ConverterContext } from '@core/layout-adapter'; +import { toFlowBlocks } from './test-helpers/to-flow-blocks.js'; import { measureBlock } from '@superdoc/measuring-dom'; import type { FlowBlock, PMNode } from '@superdoc/contracts'; import fs from 'fs'; diff --git a/packages/layout-engine/tests/src/sdt-metadata.test.ts b/packages/layout-engine/tests/src/sdt-metadata.test.ts index 01bdb657de..d5213d5bef 100644 --- a/packages/layout-engine/tests/src/sdt-metadata.test.ts +++ b/packages/layout-engine/tests/src/sdt-metadata.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { toFlowBlocks } from '@superdoc/pm-adapter'; +import { toFlowBlocks } from './test-helpers/to-flow-blocks.js'; import type { FlowBlock, SdtMetadata } from '@superdoc/contracts'; import docFixture from '../fixtures/sdt-flow-input.json' assert { type: 'json' }; diff --git a/packages/layout-engine/tests/src/section-breaks-regression.test.ts b/packages/layout-engine/tests/src/section-breaks-regression.test.ts index cbf7427b97..a795c28f50 100644 --- a/packages/layout-engine/tests/src/section-breaks-regression.test.ts +++ b/packages/layout-engine/tests/src/section-breaks-regression.test.ts @@ -461,7 +461,10 @@ describe('Section Breaks - Regression Tests', () => { describe('Verification against real DOCX fixture', () => { it('should match expected output for multi_section_doc.json fixture', () => { // Load the actual fixture - const fixturePath = path.join(__dirname, '../../pm-adapter/src/fixtures/multi_section_doc.json'); + const fixturePath = path.join( + __dirname, + '../../../super-editor/src/editors/v1/core/layout-adapter/fixtures/multi_section_doc.json', + ); if (!fs.existsSync(fixturePath)) { console.warn(`Fixture not found: ${fixturePath}, skipping test`); diff --git a/packages/layout-engine/tests/src/section-refs-merging.test.ts b/packages/layout-engine/tests/src/section-refs-merging.test.ts index 83f1b67625..0325f9b8a2 100644 --- a/packages/layout-engine/tests/src/section-refs-merging.test.ts +++ b/packages/layout-engine/tests/src/section-refs-merging.test.ts @@ -13,7 +13,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import type { PMNode, FlowBlock, SectionBreakBlock } from '@superdoc/contracts'; -import { toFlowBlocks } from '@superdoc/pm-adapter'; +import { toFlowBlocks } from './test-helpers/to-flow-blocks.js'; import { layoutDocument } from '@superdoc/layout-engine'; import { measureBlock } from '@superdoc/measuring-dom'; import { DEFAULT_CONVERTER_CONTEXT, resetBlockIdCounter, PAGE_SIZES } from './test-helpers/section-test-utils.js'; diff --git a/packages/layout-engine/tests/src/test-helpers/section-test-utils.ts b/packages/layout-engine/tests/src/test-helpers/section-test-utils.ts index 12be635f46..d52cf554ca 100644 --- a/packages/layout-engine/tests/src/test-helpers/section-test-utils.ts +++ b/packages/layout-engine/tests/src/test-helpers/section-test-utils.ts @@ -8,7 +8,7 @@ */ import type { PMNode, FlowBlock, SectionBreakBlock, Measure, Layout, Page } from '@superdoc/contracts'; -import { toFlowBlocks } from '@superdoc/pm-adapter'; +import { toFlowBlocks } from './to-flow-blocks.js'; import { layoutDocument } from '@superdoc/layout-engine'; import { measureBlock } from '@superdoc/measuring-dom'; import type { NumberingProperties, StylesDocumentProperties } from '@superdoc/style-engine/ooxml'; diff --git a/packages/layout-engine/tests/src/test-helpers/to-flow-blocks.ts b/packages/layout-engine/tests/src/test-helpers/to-flow-blocks.ts new file mode 100644 index 0000000000..85b3076108 --- /dev/null +++ b/packages/layout-engine/tests/src/test-helpers/to-flow-blocks.ts @@ -0,0 +1,6 @@ +import { toFlowBlocks as adapterToFlowBlocks } from '@core/layout-adapter'; +import type { AdapterOptions, FlowBlocksResult, PMNode } from '@core/layout-adapter'; + +export function toFlowBlocks(input: PMNode | object, options?: AdapterOptions): FlowBlocksResult { + return adapterToFlowBlocks(input, options); +} diff --git a/packages/layout-engine/tests/src/toolbar-integration.test.ts b/packages/layout-engine/tests/src/toolbar-integration.test.ts index c950ec260a..aa362f4e3f 100644 --- a/packages/layout-engine/tests/src/toolbar-integration.test.ts +++ b/packages/layout-engine/tests/src/toolbar-integration.test.ts @@ -8,7 +8,7 @@ */ import { describe, it, expect } from 'vitest'; -import { toFlowBlocks } from '@superdoc/pm-adapter'; +import { toFlowBlocks } from './test-helpers/to-flow-blocks.js'; import type { FlowBlock, PMNode } from '@superdoc/contracts'; import fs from 'fs'; import path from 'path'; diff --git a/packages/super-editor/package.json b/packages/super-editor/package.json index d5ce53bfa6..d7b820bbc2 100644 --- a/packages/super-editor/package.json +++ b/packages/super-editor/package.json @@ -103,6 +103,7 @@ "dev": "vite", "build": "vite build", "build:watch": "vite build --watch", + "extract:docx": "tsx src/editors/v1/core/layout-adapter/scripts/extract-pm-json.mjs", "types:check": "tsc --noEmit", "types:build": "tsc -p tsconfig.build.json", "test": "vitest", @@ -118,11 +119,11 @@ "@superdoc/contracts": "workspace:*", "@superdoc/dom-contract": "workspace:*", "@superdoc/document-api": "workspace:*", + "@superdoc/font-utils": "workspace:*", "@superdoc/layout-bridge": "workspace:*", "@superdoc/layout-resolved": "workspace:*", "@superdoc/measuring-dom": "workspace:*", "@superdoc/painter-dom": "workspace:*", - "@superdoc/pm-adapter": "workspace:*", "@superdoc/preset-geometry": "workspace:*", "@superdoc/style-engine": "workspace:*", "@superdoc/url-validation": "workspace:*", @@ -171,6 +172,7 @@ }, "devDependencies": { "@floating-ui/dom": "catalog:", + "@superdoc/layout-engine": "workspace:*", "@testing-library/react": "catalog:", "@types/mdast": "catalog:", "@types/react": "catalog:", diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts index 622f0d8bfd..d5959c3dd8 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts @@ -99,12 +99,11 @@ vi.mock('@extensions/pagination/pagination-helpers.js', () => ({ onHeaderFooterDataUpdate: mockOnHeaderFooterDataUpdate, })); -vi.mock('@superdoc/pm-adapter', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - toFlowBlocks: mockToFlowBlocks, - }; +vi.mock('@core/layout-adapter', async (importOriginal) => { + const { buildLayoutDocumentAdapterVitestMock } = await import( + '../presentation-editor/tests/mock-layout-document-adapter-vitest.js' + ); + return buildLayoutDocumentAdapterVitestMock(importOriginal, { toFlowBlocks: mockToFlowBlocks }); }); const createConverter = () => ({ diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts index 3e6c2d6f24..edfb9ad782 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts @@ -1,11 +1,11 @@ -import { toFlowBlocks } from '@superdoc/pm-adapter'; +import { toFlowBlocks } from '@core/layout-adapter'; import { getAtomNodeTypes as getAtomNodeTypesFromSchema } from '../presentation-editor/utils/SchemaNodeTypes.js'; import type { FlowBlock, TrackedChangesMode } from '@superdoc/contracts'; import type { HeaderFooterBatch } from '@superdoc/layout-bridge'; import type { Editor } from '@core/Editor.js'; import { EventEmitter } from '@core/EventEmitter.js'; import { createHeaderFooterEditor, onHeaderFooterDataUpdate } from '@extensions/pagination/pagination-helpers.js'; -import type { ConverterContext } from '@superdoc/pm-adapter/converter-context.js'; +import type { ConverterContext } from '@core/layout-adapter/converter-context.js'; import { buildStoryKey } from '../../document-api-adapters/story-runtime/story-key.js'; const HEADER_FOOTER_VARIANTS = ['default', 'first', 'even', 'odd'] as const; diff --git a/packages/layout-engine/pm-adapter/README.md b/packages/super-editor/src/editors/v1/core/layout-adapter/README.md similarity index 85% rename from packages/layout-engine/pm-adapter/README.md rename to packages/super-editor/src/editors/v1/core/layout-adapter/README.md index e0653df917..322e8a356f 100644 --- a/packages/layout-engine/pm-adapter/README.md +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/README.md @@ -1,11 +1,15 @@ -# @superdoc/pm-adapter +# v1 layout-adapter (ProseMirror β†’ FlowBlock[]) + +The v1 ProseMirror adapter, owned by `@superdoc/super-editor`. It projects the +hidden ProseMirror document and resolved styles into `FlowBlock[]` for the +layout-engine pipeline. Internal consumers import it via `@core/layout-adapter`. ## DOCX β†’ PM JSON fixtures -Use the shared Vite configuration from Super Editor to extract ProseMirror JSON directly from DOCX files: +Use the Super Editor extraction script to extract ProseMirror JSON directly from DOCX files. From `packages/super-editor`: ```bash -pnpm run extract:docx --workspace=@superdoc/pm-adapter -- --input ../../super-editor/src/editors/v1/tests/data/restart-numbering-sub-list.docx --output lists-docx.json +pnpm run extract:docx -- --input src/editors/v1/tests/data/restart-numbering-sub-list.docx --output src/editors/v1/core/layout-adapter/fixtures/lists-docx.json ``` Pass `--input` and `--output` to control which DOCX file is converted and where the fixture is written. diff --git a/packages/layout-engine/pm-adapter/src/attributes/bidi.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/bidi.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/attributes/bidi.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/attributes/bidi.test.ts diff --git a/packages/layout-engine/pm-adapter/src/attributes/bidi.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/bidi.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/attributes/bidi.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/attributes/bidi.ts diff --git a/packages/layout-engine/pm-adapter/src/attributes/borders.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/borders.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/attributes/borders.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/attributes/borders.test.ts diff --git a/packages/layout-engine/pm-adapter/src/attributes/borders.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/borders.ts similarity index 99% rename from packages/layout-engine/pm-adapter/src/attributes/borders.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/attributes/borders.ts index b689c2e5e6..8f80e5bbe9 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/borders.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/borders.ts @@ -17,7 +17,7 @@ import type { } from '@superdoc/contracts'; import type { OoxmlBorder } from '../types.js'; import { normalizeColor, pickNumber, isFiniteNumber, normalizeCellPaddingTopBottom } from '../utilities.js'; -import { eighthPointsToPixels } from '@superdoc/super-editor/converter/internal/helpers.js'; +import { eighthPointsToPixels } from '@converter/helpers.js'; const MIN_BORDER_SIZE_PX = 0.5; // Minimum visible border const MAX_BORDER_SIZE_PX = 100; // Reasonable maximum diff --git a/packages/layout-engine/pm-adapter/src/attributes/index.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/index.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/attributes/index.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/attributes/index.ts diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/paragraph.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/attributes/paragraph.test.ts diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/paragraph.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/attributes/paragraph.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/attributes/paragraph.ts diff --git a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/spacing-indent.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/attributes/spacing-indent.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/attributes/spacing-indent.test.ts diff --git a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/spacing-indent.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/attributes/spacing-indent.ts diff --git a/packages/layout-engine/pm-adapter/src/attributes/tabs.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/tabs.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/attributes/tabs.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/attributes/tabs.test.ts diff --git a/packages/layout-engine/pm-adapter/src/attributes/tabs.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/tabs.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/attributes/tabs.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/attributes/tabs.ts diff --git a/packages/layout-engine/pm-adapter/src/cache.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/cache.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/cache.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/cache.test.ts diff --git a/packages/layout-engine/pm-adapter/src/cache.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/cache.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/cache.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/cache.ts diff --git a/packages/layout-engine/pm-adapter/src/constants.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/constants.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/constants.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/constants.ts diff --git a/packages/layout-engine/pm-adapter/src/converter-context.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converter-context.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converter-context.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converter-context.test.ts diff --git a/packages/layout-engine/pm-adapter/src/converter-context.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converter-context.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converter-context.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converter-context.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/break.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/break.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/break.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/break.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/chart.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/chart.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/chart.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/chart.test.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/chart.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/chart.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/chart.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/chart.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/content-block.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/content-block.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/content-block.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/content-block.test.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/content-block.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/content-block.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/content-block.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/content-block.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/image.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/image.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/image.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/image.test.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/image.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/image.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/image.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/image.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/index.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/index.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/index.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/index.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/authority-entry.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/authority-entry.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/authority-entry.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/authority-entry.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-end.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/bookmark-end.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-end.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/bookmark-end.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-markers.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/bookmark-markers.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-markers.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/bookmark-markers.test.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-start.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/bookmark-start.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/bookmark-start.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/bookmark-start.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/citation.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/citation.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/citation.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/citation.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/common.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/common.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/common.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/common.test.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/common.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/common.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/common.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/common.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/content-block.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/content-block.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/content-block.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/content-block.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/cross-reference.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/cross-reference.test.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/cross-reference.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/cross-reference.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/cross-reference.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/document-stat-field.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/document-stat-field.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/document-stat-field.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/document-stat-field.test.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/document-stat-field.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/document-stat-field.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/document-stat-field.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/document-stat-field.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/endnote-reference.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/endnote-reference.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/endnote-reference.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/endnote-reference.test.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/endnote-reference.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/endnote-reference.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/endnote-reference.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/endnote-reference.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/field-annotation.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/field-annotation.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/field-annotation.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/field-annotation.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/footnote-reference.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/footnote-reference.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/footnote-reference.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/footnote-reference.test.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/footnote-reference.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/footnote-reference.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/footnote-reference.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/footnote-reference.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.test.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/image.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/image.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/line-break.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/line-break.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/line-break.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/line-break.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/math.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/math.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/math.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/math.test.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/math.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/math.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/math.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/math.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/no-break-hyphen.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/no-break-hyphen.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/no-break-hyphen.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/no-break-hyphen.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/page-reference.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/page-reference.test.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/page-reference.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/page-reference.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/page-reference.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/reference-marker.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/reference-marker.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/reference-marker.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/reference-marker.test.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/reference-marker.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/reference-marker.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/reference-marker.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/reference-marker.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/run.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/run.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/run.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/run.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/sequence-field.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/sequence-field.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/sequence-field.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/sequence-field.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/smart-tag.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/smart-tag.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/smart-tag.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/smart-tag.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/structured-content.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/structured-content.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/structured-content.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/structured-content.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/tab.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/tab.test.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/tab.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/tab.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/text-run.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/text-run.test.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/text-run.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/text-run.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/math-block.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/math-block.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/math-block.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/math-block.test.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/math-block.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/math-block.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/math-block.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/math-block.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/math-constants.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/math-constants.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/math-constants.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/math-constants.test.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/math-constants.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/math-constants.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/math-constants.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/math-constants.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.test.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/paragraph.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/shapes.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/shapes.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.test.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/shapes.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/shapes.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/table-styles.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table-styles.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/table-styles.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/table-styles.test.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/table-styles.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table-styles.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/table-styles.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/table-styles.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/table.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/table.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.test.ts diff --git a/packages/layout-engine/pm-adapter/src/converters/table.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/converters/table.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts diff --git a/packages/layout-engine/pm-adapter/src/direction/README.md b/packages/super-editor/src/editors/v1/core/layout-adapter/direction/README.md similarity index 94% rename from packages/layout-engine/pm-adapter/src/direction/README.md rename to packages/super-editor/src/editors/v1/core/layout-adapter/direction/README.md index 509d66c2e8..f002878a8a 100644 --- a/packages/layout-engine/pm-adapter/src/direction/README.md +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/direction/README.md @@ -34,7 +34,7 @@ import { resolveLogicalAlignment, resolveLogicalIndent, isRtl, -} from '@superdoc/pm-adapter/direction'; +} from '@core/layout-adapter/direction'; ``` Each resolver consumes its parent context and returns its own: @@ -89,7 +89,7 @@ Use the helpers instead of mapping sides inline: ```ts import { resolveLogicalAlignment, resolveLogicalIndent } from - '@superdoc/pm-adapter/direction'; + '@core/layout-adapter/direction'; const physicalAlignment = resolveLogicalAlignment( resolvedJustification, // 'start' | 'end' | 'left' | ... @@ -187,16 +187,16 @@ Use these searches before adding a new direction-aware path: ```bash # Suspicious: upstream table-side pre-mirroring. rg "rightToLeft.*\\?.*'(left|right)'|rightToLeft.*\\?.*\\\"(left|right)\\\"" \ - packages/layout-engine/pm-adapter/src packages/super-editor/src/editors/v1/core/super-converter + packages/super-editor/src/editors/v1/core/layout-adapter packages/super-editor/src/editors/v1/core/super-converter # Review: downstream consumers reading raw direction fields. rg "sectionDirection|rightToLeft" \ packages/layout-engine/layout-bridge/src packages/layout-engine/painters/dom/src # Suspicious: painter importing direction logic from upstream packages. -rg "@superdoc/(pm-adapter|style-engine)" packages/layout-engine/painters/dom/src +rg "@superdoc/(super-editor|style-engine)" packages/layout-engine/painters/dom/src ``` -Resolver files under `pm-adapter/src/direction/` are expected to read raw +Resolver files under `super-editor/src/editors/v1/core/layout-adapter/direction/` are expected to read raw direction fields. The checks above are for new local direction decisions outside the resolver. diff --git a/packages/layout-engine/pm-adapter/src/direction/index.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/direction/index.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/direction/index.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/direction/index.ts diff --git a/packages/layout-engine/pm-adapter/src/direction/logicalSides.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/direction/logicalSides.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/direction/logicalSides.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/direction/logicalSides.ts diff --git a/packages/layout-engine/pm-adapter/src/direction/non-collapse.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/direction/non-collapse.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/direction/non-collapse.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/direction/non-collapse.test.ts diff --git a/packages/layout-engine/pm-adapter/src/direction/resolveCellDirection.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/direction/resolveCellDirection.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/direction/resolveCellDirection.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/direction/resolveCellDirection.ts diff --git a/packages/layout-engine/pm-adapter/src/direction/resolveParagraphDirection.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/direction/resolveParagraphDirection.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/direction/resolveParagraphDirection.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/direction/resolveParagraphDirection.ts diff --git a/packages/layout-engine/pm-adapter/src/direction/resolveSectionDirection.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/direction/resolveSectionDirection.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/direction/resolveSectionDirection.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/direction/resolveSectionDirection.ts diff --git a/packages/layout-engine/pm-adapter/src/direction/resolveTableDirection.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/direction/resolveTableDirection.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/direction/resolveTableDirection.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/direction/resolveTableDirection.ts diff --git a/packages/layout-engine/pm-adapter/src/fixtures/basic-paragraph.json b/packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/basic-paragraph.json similarity index 100% rename from packages/layout-engine/pm-adapter/src/fixtures/basic-paragraph.json rename to packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/basic-paragraph.json diff --git a/packages/layout-engine/pm-adapter/src/fixtures/bold-demo.json b/packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/bold-demo.json similarity index 100% rename from packages/layout-engine/pm-adapter/src/fixtures/bold-demo.json rename to packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/bold-demo.json diff --git a/packages/layout-engine/pm-adapter/src/fixtures/edge-cases.json b/packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/edge-cases.json similarity index 100% rename from packages/layout-engine/pm-adapter/src/fixtures/edge-cases.json rename to packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/edge-cases.json diff --git a/packages/layout-engine/pm-adapter/src/fixtures/hummingbird.json b/packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/hummingbird.json similarity index 100% rename from packages/layout-engine/pm-adapter/src/fixtures/hummingbird.json rename to packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/hummingbird.json diff --git a/packages/layout-engine/pm-adapter/src/fixtures/image-inline-and-block.json b/packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/image-inline-and-block.json similarity index 100% rename from packages/layout-engine/pm-adapter/src/fixtures/image-inline-and-block.json rename to packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/image-inline-and-block.json diff --git a/packages/layout-engine/pm-adapter/src/fixtures/lists-basic.json b/packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/lists-basic.json similarity index 100% rename from packages/layout-engine/pm-adapter/src/fixtures/lists-basic.json rename to packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/lists-basic.json diff --git a/packages/layout-engine/pm-adapter/src/fixtures/lists-docx.json b/packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/lists-docx.json similarity index 100% rename from packages/layout-engine/pm-adapter/src/fixtures/lists-docx.json rename to packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/lists-docx.json diff --git a/packages/layout-engine/pm-adapter/src/fixtures/multi_section_doc.json b/packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/multi_section_doc.json similarity index 100% rename from packages/layout-engine/pm-adapter/src/fixtures/multi_section_doc.json rename to packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/multi_section_doc.json diff --git a/packages/layout-engine/pm-adapter/src/fixtures/paragraph_pPr_variations.json b/packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/paragraph_pPr_variations.json similarity index 100% rename from packages/layout-engine/pm-adapter/src/fixtures/paragraph_pPr_variations.json rename to packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/paragraph_pPr_variations.json diff --git a/packages/layout-engine/pm-adapter/src/fixtures/tabs-center-end.json b/packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/tabs-center-end.json similarity index 100% rename from packages/layout-engine/pm-adapter/src/fixtures/tabs-center-end.json rename to packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/tabs-center-end.json diff --git a/packages/layout-engine/pm-adapter/src/fixtures/tabs-decimal.json b/packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/tabs-decimal.json similarity index 100% rename from packages/layout-engine/pm-adapter/src/fixtures/tabs-decimal.json rename to packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/tabs-decimal.json diff --git a/packages/layout-engine/pm-adapter/src/fixtures/two-column-two-page.json b/packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/two-column-two-page.json similarity index 100% rename from packages/layout-engine/pm-adapter/src/fixtures/two-column-two-page.json rename to packages/super-editor/src/editors/v1/core/layout-adapter/fixtures/two-column-two-page.json diff --git a/packages/layout-engine/pm-adapter/src/index.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/index.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/index.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/index.test.ts diff --git a/packages/layout-engine/pm-adapter/src/index.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/index.ts similarity index 91% rename from packages/layout-engine/pm-adapter/src/index.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/index.ts index d0c626282e..792ebd476b 100644 --- a/packages/layout-engine/pm-adapter/src/index.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/index.ts @@ -40,6 +40,9 @@ export { SectionType } from './types.js'; // Re-export public API functions from internal implementation export { toFlowBlocks, toFlowBlocksMap } from './internal.js'; +// Re-export section range analysis for direct (registry-free) consumers +export { analyzeSectionRanges } from './sections/analysis.js'; + // Re-export run type guards and run utilities export { isTextRun } from './converters/paragraph.js'; export { expandRunsForInlineNewlines } from '@superdoc/contracts'; diff --git a/packages/layout-engine/pm-adapter/src/integration.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/integration.test.ts similarity index 98% rename from packages/layout-engine/pm-adapter/src/integration.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/integration.test.ts index ccbbd142f7..c866f0fea2 100644 --- a/packages/layout-engine/pm-adapter/src/integration.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/integration.test.ts @@ -9,6 +9,8 @@ import { describe, it, expect } from 'vitest'; import { toFlowBlocks as baseToFlowBlocks, toFlowBlocksMap } from './index.js'; import type { PMNode, AdapterOptions, PMDocumentMap } from './index.js'; import { measureBlock } from '@superdoc/measuring-dom'; +import { resolveCanvas } from '@superdoc/measuring-dom/canvas-resolver'; +import { installNodeCanvasPolyfill } from '@superdoc/measuring-dom'; import { layoutDocument } from '@superdoc/layout-engine'; import { createDomPainter } from '@superdoc/painter-dom'; import { resolveLayout } from '@superdoc/layout-resolved'; @@ -22,6 +24,13 @@ import tabsCenterEndFixture from './fixtures/tabs-center-end.json'; import paragraphPPrVariationsFixture from './fixtures/paragraph_pPr_variations.json'; import { twipsToPx } from './utilities.js'; +// This suite drives the real measurer (no mock), so the moved adapter tests +// need node-canvas wired into the happy-dom document. The old standalone adapter +// package did this via its vitest.setup.ts; super-editor has no global canvas +// setup, so install it locally for this integration suite. +const { Canvas } = resolveCanvas(); +installNodeCanvasPolyfill({ document, Canvas }); + const DEFAULT_CONVERTER_CONTEXT = { docx: {}, translatedLinkedStyles: { diff --git a/packages/layout-engine/pm-adapter/src/internal.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/internal.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/internal.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/internal.test.ts diff --git a/packages/layout-engine/pm-adapter/src/internal.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/internal.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/internal.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/internal.ts diff --git a/packages/layout-engine/pm-adapter/src/list-helpers.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/list-helpers.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/list-helpers.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/list-helpers.ts diff --git a/packages/layout-engine/pm-adapter/src/marks/__tests__/sanitization.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/marks/__tests__/sanitization.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/marks/__tests__/sanitization.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/marks/__tests__/sanitization.test.ts diff --git a/packages/layout-engine/pm-adapter/src/marks/__tests__/theme-color-resolution.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/marks/__tests__/theme-color-resolution.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/marks/__tests__/theme-color-resolution.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/marks/__tests__/theme-color-resolution.test.ts diff --git a/packages/layout-engine/pm-adapter/src/marks/application.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/marks/application.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/marks/application.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/marks/application.test.ts diff --git a/packages/layout-engine/pm-adapter/src/marks/application.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/marks/application.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/marks/application.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/marks/application.ts diff --git a/packages/layout-engine/pm-adapter/src/marks/index.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/marks/index.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/marks/index.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/marks/index.ts diff --git a/packages/layout-engine/pm-adapter/src/marks/links.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/marks/links.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/marks/links.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/marks/links.test.ts diff --git a/packages/layout-engine/pm-adapter/src/marks/links.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/marks/links.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/marks/links.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/marks/links.ts diff --git a/packages/layout-engine/pm-adapter/src/marks/theme-color.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/marks/theme-color.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/marks/theme-color.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/marks/theme-color.test.ts diff --git a/packages/layout-engine/pm-adapter/src/marks/theme-color.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/marks/theme-color.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/marks/theme-color.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/marks/theme-color.ts diff --git a/packages/layout-engine/pm-adapter/scripts/extract-pm-json.mjs b/packages/super-editor/src/editors/v1/core/layout-adapter/scripts/extract-pm-json.mjs similarity index 71% rename from packages/layout-engine/pm-adapter/scripts/extract-pm-json.mjs rename to packages/super-editor/src/editors/v1/core/layout-adapter/scripts/extract-pm-json.mjs index b04d9c0007..d73e4e82b0 100644 --- a/packages/layout-engine/pm-adapter/scripts/extract-pm-json.mjs +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/scripts/extract-pm-json.mjs @@ -2,9 +2,8 @@ /** * Extract ProseMirror JSON from DOCX input. * - * Run via Vite's Node runner so Super Editor's path aliases resolve: - * npx vite-node --config ../../super-editor/vite.config.js --mode test \ - * scripts/extract-pm-json.mjs --input ../../super-editor/src/editors/v1/tests/data/your.docx + * Run from packages/super-editor: + * pnpm run extract:docx -- --input src/editors/v1/tests/data/your.docx * * The script loads the DOCX file using the Super Editor import machinery * and writes a ProseMirror JSON fixture file. @@ -13,9 +12,9 @@ import { readFile, writeFile, mkdir } from 'fs/promises'; import { join, dirname, resolve, basename } from 'path'; import { fileURLToPath } from 'url'; -import { createDocumentJson } from "@superdoc/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js"; -import DocxZipper from "@superdoc/super-editor/src/editors/v1/core/DocxZipper.js"; -import { parseXmlToJson } from "@superdoc/super-editor/src/editors/v1/core/super-converter/v2/docxHelper.js"; +import { createDocumentJson } from '@core/super-converter/v2/importer/docxImporter.js'; +import DocxZipper from '@core/DocxZipper.js'; +import { parseXmlToJson } from '@core/super-converter/v2/docxHelper.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -62,17 +61,16 @@ function parseArgs(argv) { async function extractPMJson() { const { input, output } = parseArgs(process.argv.slice(2)); + const fixturesDir = join(__dirname, '../fixtures'); - const defaultDocxPath = join( - __dirname, - '../../../super-editor/src/editors/v1/tests/data/basic-paragraph.docx' - ); + const defaultDocxPath = join(__dirname, '../../../tests/data/basic-paragraph.docx'); const docxPath = resolve(input ?? defaultDocxPath); - const fixtureName = - output ?? - (docxPath.endsWith('.docx') - ? `${basename(docxPath, '.docx')}.json` - : 'basic-paragraph.json'); + const fixtureName = docxPath.endsWith('.docx') ? `${basename(docxPath, '.docx')}.json` : 'basic-paragraph.json'; + const outputPath = output + ? output.includes('/') || output.includes('\\') + ? resolve(output) + : join(fixturesDir, output) + : join(fixturesDir, fixtureName); console.log(`Loading DOCX from ${docxPath}...`); @@ -104,10 +102,8 @@ async function extractPMJson() { console.log('Extracted PM document with', result.pmDoc.content?.length || 0, 'nodes'); // Write to fixtures directory - const fixturesDir = join(__dirname, '../src/fixtures'); - await mkdir(fixturesDir, { recursive: true }); + await mkdir(dirname(outputPath), { recursive: true }); - const outputPath = join(fixturesDir, fixtureName); await writeFile(outputPath, JSON.stringify(result.pmDoc, null, 2)); console.log('βœ“ Written to:', outputPath); @@ -117,7 +113,7 @@ async function extractPMJson() { console.log(JSON.stringify(result.pmDoc, null, 2).slice(0, 500) + '...'); } -extractPMJson().catch(err => { +extractPMJson().catch((err) => { console.error('Error:', err); process.exit(1); }); diff --git a/packages/layout-engine/pm-adapter/src/sdt/bibliography.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/bibliography.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sdt/bibliography.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sdt/bibliography.ts diff --git a/packages/layout-engine/pm-adapter/src/sdt/document-index.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-index.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sdt/document-index.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-index.test.ts diff --git a/packages/layout-engine/pm-adapter/src/sdt/document-index.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-index.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sdt/document-index.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-index.ts diff --git a/packages/layout-engine/pm-adapter/src/sdt/document-part-object.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-part-object.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sdt/document-part-object.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-part-object.test.ts diff --git a/packages/layout-engine/pm-adapter/src/sdt/document-part-object.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-part-object.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sdt/document-part-object.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-part-object.ts diff --git a/packages/layout-engine/pm-adapter/src/sdt/document-section.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-section.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sdt/document-section.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-section.test.ts diff --git a/packages/layout-engine/pm-adapter/src/sdt/document-section.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-section.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sdt/document-section.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-section.ts diff --git a/packages/layout-engine/pm-adapter/src/sdt/index.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/index.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sdt/index.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sdt/index.test.ts diff --git a/packages/layout-engine/pm-adapter/src/sdt/index.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/index.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sdt/index.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sdt/index.ts diff --git a/packages/layout-engine/pm-adapter/src/sdt/metadata.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/metadata.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sdt/metadata.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sdt/metadata.test.ts diff --git a/packages/layout-engine/pm-adapter/src/sdt/metadata.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/metadata.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sdt/metadata.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sdt/metadata.ts diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/structured-content-block.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sdt/structured-content-block.test.ts diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/structured-content-block.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sdt/structured-content-block.ts diff --git a/packages/layout-engine/pm-adapter/src/sdt/table-of-authorities.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/table-of-authorities.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sdt/table-of-authorities.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sdt/table-of-authorities.ts diff --git a/packages/layout-engine/pm-adapter/src/sdt/toc.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/toc.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sdt/toc.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sdt/toc.test.ts diff --git a/packages/layout-engine/pm-adapter/src/sdt/toc.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/toc.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sdt/toc.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sdt/toc.ts diff --git a/packages/layout-engine/pm-adapter/src/sections/analysis.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/analysis.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sections/analysis.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sections/analysis.test.ts diff --git a/packages/layout-engine/pm-adapter/src/sections/analysis.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/analysis.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sections/analysis.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sections/analysis.ts diff --git a/packages/layout-engine/pm-adapter/src/sections/breaks.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/breaks.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sections/breaks.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sections/breaks.ts diff --git a/packages/layout-engine/pm-adapter/src/sections/end-tagged.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/end-tagged.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sections/end-tagged.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sections/end-tagged.test.ts diff --git a/packages/layout-engine/pm-adapter/src/sections/extraction.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sections/extraction.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts diff --git a/packages/layout-engine/pm-adapter/src/sections/extraction.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sections/extraction.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts diff --git a/packages/layout-engine/pm-adapter/src/sections/index.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/index.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sections/index.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sections/index.ts diff --git a/packages/layout-engine/pm-adapter/src/sections/types.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/types.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/sections/types.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/sections/types.ts diff --git a/packages/layout-engine/pm-adapter/src/source-anchor.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/source-anchor.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/source-anchor.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/source-anchor.test.ts diff --git a/packages/layout-engine/pm-adapter/src/tracked-changes.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/tracked-changes.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/tracked-changes.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/tracked-changes.test.ts diff --git a/packages/layout-engine/pm-adapter/src/tracked-changes.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/tracked-changes.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/tracked-changes.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/tracked-changes.ts diff --git a/packages/layout-engine/pm-adapter/src/types.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/types.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/types.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/types.ts diff --git a/packages/layout-engine/pm-adapter/src/utilities.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.test.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/utilities.test.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/utilities.test.ts diff --git a/packages/layout-engine/pm-adapter/src/utilities.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.ts similarity index 100% rename from packages/layout-engine/pm-adapter/src/utilities.ts rename to packages/super-editor/src/editors/v1/core/layout-adapter/utilities.ts diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 60dfe2a0b3..83c8b945f9 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -108,8 +108,8 @@ import { resolveStoryRuntime } from '../../document-api-adapters/story-runtime/r import { BODY_STORY_KEY, buildStoryKey, parseStoryKey } from '../../document-api-adapters/story-runtime/story-key.js'; import { createStoryEditor } from '../story-editor-factory.js'; import { buildEndnoteBlocks } from './layout/EndnotesBuilder.js'; -import { toFlowBlocks, FlowBlockCache } from '@superdoc/pm-adapter'; -import type { ConverterContext } from '@superdoc/pm-adapter/converter-context.js'; +import { toFlowBlocks, FlowBlockCache } from '@core/layout-adapter'; +import type { ConverterContext } from '@core/layout-adapter/converter-context.js'; import { readSettingsRoot, readDefaultTableStyle } from '../../document-api-adapters/document-settings.js'; import { incrementalLayout, @@ -4607,7 +4607,7 @@ export class PresentationEditor extends EventEmitter { transaction.docChanged && (ySyncMeta?.isChangeOrigin || inputType === 'historyUndo' || inputType === 'historyRedo'); if (shouldBypassFastRevision) { - this.#flowBlockCache?.setHasExternalChanges(true); + this.#flowBlockCache?.setHasExternalChanges?.(true); } } if (trackedChangesChanged || transaction?.docChanged) { @@ -4738,7 +4738,7 @@ export class PresentationEditor extends EventEmitter { // These modify the OOXML part and derived cache but don't change the PM document, // so the normal 'update' event won't trigger a layout refresh. const handleNotesPartChanged = (event?: { source?: unknown }) => { - this.#flowBlockCache.setHasExternalChanges(true); + this.#flowBlockCache.setHasExternalChanges?.(true); this.#pendingDocChange = true; this.#selectionSync.onLayoutStart(); this.#scheduleRerender(); @@ -5342,7 +5342,7 @@ export class PresentationEditor extends EventEmitter { refId: headerId, }); this.#headerFooterSession?.invalidateLayoutForRefs([headerId]); - this.#flowBlockCache.setHasExternalChanges(true); + this.#flowBlockCache.setHasExternalChanges?.(true); this.#pendingDocChange = true; this.#selectionSync.onLayoutStart(); this.#scheduleRerender(); @@ -6181,7 +6181,7 @@ export class PresentationEditor extends EventEmitter { */ #refreshHeaderFooterStructureThenRerender(options?: { purgeCachedEditors?: boolean }): void { this.#headerFooterSession?.refreshStructure(options); - this.#flowBlockCache.setHasExternalChanges(true); + this.#flowBlockCache.setHasExternalChanges?.(true); this.#pendingDocChange = true; this.#selectionSync.onLayoutStart(); this.#scheduleRerender(); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts index 3d1643e854..26f1df9dcc 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/EndnotesBuilder.ts @@ -1,8 +1,8 @@ import type { EditorState } from 'prosemirror-state'; import type { FlowBlock, Run as LayoutRun, TextRun } from '@superdoc/contracts'; -import { toFlowBlocks } from '@superdoc/pm-adapter'; -import type { ConverterContext } from '@superdoc/pm-adapter/converter-context.js'; -import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '@superdoc/pm-adapter/constants.js'; +import { toFlowBlocks } from '@core/layout-adapter'; +import type { ConverterContext } from '@core/layout-adapter/converter-context.js'; +import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '@core/layout-adapter/constants.js'; import type { ProseMirrorJSON } from '../../types/EditorTypes.js'; import { findNoteEntryById } from '../../../document-api-adapters/helpers/note-entry-lookup.js'; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts index 256d2dc826..8290ff8300 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts @@ -20,9 +20,9 @@ import type { EditorState } from 'prosemirror-state'; import type { FlowBlock } from '@superdoc/contracts'; -import { toFlowBlocks } from '@superdoc/pm-adapter'; -import type { ConverterContext } from '@superdoc/pm-adapter/converter-context.js'; -import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '@superdoc/pm-adapter/constants.js'; +import { toFlowBlocks } from '@core/layout-adapter'; +import type { ConverterContext } from '@core/layout-adapter/converter-context.js'; +import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '@core/layout-adapter/constants.js'; import type { ProseMirrorJSON } from '../../types/EditorTypes.js'; import type { FootnoteReference, FootnotesLayoutInput } from '../types.js'; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts index 1766f4271b..4ec7f4f70c 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts @@ -1,32 +1,30 @@ import { describe, it, expect, vi } from 'vitest'; import type { EditorState } from 'prosemirror-state'; import { buildFootnotesInput, type ConverterLike } from '../layout/FootnotesBuilder.js'; -import type { ConverterContext } from '@superdoc/pm-adapter/converter-context.js'; -import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '@superdoc/pm-adapter/constants.js'; -import { toFlowBlocks } from '@superdoc/pm-adapter'; - -// Mock toFlowBlocks -vi.mock('@superdoc/pm-adapter', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - toFlowBlocks: vi.fn((_doc: unknown, opts?: { blockIdPrefix?: string }) => { - // Return mock blocks based on blockIdPrefix - if (typeof opts?.blockIdPrefix === 'string') { - const id = opts.blockIdPrefix.replace('footnote-', '').replace('-', ''); - return { - blocks: [ - { - kind: 'paragraph', - runs: [{ kind: 'text', text: `Footnote ${id} text`, pmStart: 0, pmEnd: 10 }], - }, - ], - bookmarks: new Map(), - }; - } - return { blocks: [], bookmarks: new Map() }; - }), - }; +import type { ConverterContext } from '@core/layout-adapter/converter-context.js'; +import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '@core/layout-adapter/constants.js'; + +const { mockFootnoteToFlowBlocks } = vi.hoisted(() => ({ + mockFootnoteToFlowBlocks: vi.fn((_doc: unknown, opts?: { blockIdPrefix?: string }) => { + if (typeof opts?.blockIdPrefix === 'string') { + const id = opts.blockIdPrefix.replace('footnote-', '').replace('-', ''); + return { + blocks: [ + { + kind: 'paragraph', + runs: [{ kind: 'text', text: `Footnote ${id} text`, pmStart: 0, pmEnd: 10 }], + }, + ], + bookmarks: new Map(), + }; + } + return { blocks: [], bookmarks: new Map() }; + }), +})); + +vi.mock('@core/layout-adapter', async (importOriginal) => { + const { buildLayoutDocumentAdapterVitestMock } = await import('./mock-layout-document-adapter-vitest.js'); + return buildLayoutDocumentAdapterVitestMock(importOriginal, { toFlowBlocks: mockFootnoteToFlowBlocks }); }); // ============================================================================= @@ -157,8 +155,9 @@ describe('buildFootnotesInput', () => { buildFootnotesInput(editorState, converter, undefined, undefined); const [, options] = - (toFlowBlocks as unknown as { mock: { calls: Array<[unknown, Record]> } }).mock.calls.at(-1) ?? - []; + ( + mockFootnoteToFlowBlocks as unknown as { mock: { calls: Array<[unknown, Record]> } } + ).mock.calls.at(-1) ?? []; expect(options?.storyKey).toBe('fn:1'); }); @@ -179,7 +178,7 @@ describe('buildFootnotesInput', () => { }, }); - const docArg = (toFlowBlocks as unknown as { mock: { calls: Array<[any]> } }).mock.calls.at(-1)?.[0]; + const docArg = (mockFootnoteToFlowBlocks as unknown as { mock: { calls: Array<[any]> } }).mock.calls.at(-1)?.[0]; expect(docArg).toEqual({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Live note' }] }], @@ -247,7 +246,7 @@ describe('buildFootnotesInput', () => { buildFootnotesInput(editorState, converter, undefined, undefined); - const docArg = (toFlowBlocks as unknown as { mock: { calls: Array<[any]> } }).mock.calls.at(-1)?.[0]; + const docArg = (mockFootnoteToFlowBlocks as unknown as { mock: { calls: Array<[any]> } }).mock.calls.at(-1)?.[0]; expect(docArg?.content?.[0]?.content).toEqual([ { type: 'run', @@ -278,7 +277,7 @@ describe('buildFootnotesInput', () => { buildFootnotesInput(editorState, converter, undefined, undefined); - const docArg = (toFlowBlocks as unknown as { mock: { calls: Array<[any]> } }).mock.calls.at(-1)?.[0]; + const docArg = (mockFootnoteToFlowBlocks as unknown as { mock: { calls: Array<[any]> } }).mock.calls.at(-1)?.[0]; expect(docArg?.content?.[0]?.content).toEqual([ { type: 'run', @@ -316,7 +315,7 @@ describe('buildFootnotesInput', () => { buildFootnotesInput(editorState, converter, undefined, undefined); - const docArg = (toFlowBlocks as unknown as { mock: { calls: Array<[any]> } }).mock.calls.at(-1)?.[0]; + const docArg = (mockFootnoteToFlowBlocks as unknown as { mock: { calls: Array<[any]> } }).mock.calls.at(-1)?.[0]; expect(docArg?.content?.[0]?.content).toEqual([ { type: 'run', diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.decorationSync.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.decorationSync.test.ts index 6a1bb1943d..6b583960de 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.decorationSync.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.decorationSync.test.ts @@ -223,12 +223,9 @@ vi.mock('../../Editor.js', () => { }; }); -vi.mock('@superdoc/pm-adapter', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - toFlowBlocks: mockToFlowBlocks, - }; +vi.mock('@core/layout-adapter', async (importOriginal) => { + const { buildLayoutDocumentAdapterVitestMock } = await import('./mock-layout-document-adapter-vitest.js'); + return buildLayoutDocumentAdapterVitestMock(importOriginal, { toFlowBlocks: mockToFlowBlocks }); }); vi.mock('@superdoc/layout-bridge', () => ({ diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts index 07cd2c90a4..ae4ce72ca7 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts @@ -146,12 +146,9 @@ vi.mock('../../Editor.js', () => { }); // Mock pm-adapter functions -vi.mock('@superdoc/pm-adapter', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - toFlowBlocks: mockToFlowBlocks, - }; +vi.mock('@core/layout-adapter', async (importOriginal) => { + const { buildLayoutDocumentAdapterVitestMock } = await import('./mock-layout-document-adapter-vitest.js'); + return buildLayoutDocumentAdapterVitestMock(importOriginal, { toFlowBlocks: mockToFlowBlocks }); }); // Mock layout-bridge functions diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts index 80565667db..10cd63128c 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts @@ -161,12 +161,9 @@ vi.mock('../../Editor.js', () => { }); // Mock pm-adapter functions -vi.mock('@superdoc/pm-adapter', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - toFlowBlocks: mockToFlowBlocks, - }; +vi.mock('@core/layout-adapter', async (importOriginal) => { + const { buildLayoutDocumentAdapterVitestMock } = await import('./mock-layout-document-adapter-vitest.js'); + return buildLayoutDocumentAdapterVitestMock(importOriginal, { toFlowBlocks: mockToFlowBlocks }); }); // Mock layout-bridge functions diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts index d8de2e2331..1184f99ab6 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.footnotesPmMarkers.test.ts @@ -46,10 +46,9 @@ vi.mock('../../Editor', () => ({ })), })); -vi.mock('@superdoc/pm-adapter', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, +vi.mock('@core/layout-adapter', async (importOriginal) => { + const { buildLayoutDocumentAdapterVitestMock } = await import('./mock-layout-document-adapter-vitest.js'); + return buildLayoutDocumentAdapterVitestMock(importOriginal, { toFlowBlocks: vi.fn((_: unknown, opts?: any) => { if (typeof opts?.blockIdPrefix === 'string' && opts.blockIdPrefix.startsWith('footnote-')) { return { @@ -61,7 +60,7 @@ vi.mock('@superdoc/pm-adapter', async (importOriginal) => { } return { blocks: [], bookmarks: new Map() }; }), - }; + }); }); vi.mock('@superdoc/layout-bridge', async (importOriginal) => { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts index d5353572c6..ee740e36ee 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts @@ -196,12 +196,9 @@ vi.mock('../../Editor', () => { }); // Mock pm-adapter functions -vi.mock('@superdoc/pm-adapter', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - toFlowBlocks: mockToFlowBlocks, - }; +vi.mock('@core/layout-adapter', async (importOriginal) => { + const { buildLayoutDocumentAdapterVitestMock } = await import('./mock-layout-document-adapter-vitest.js'); + return buildLayoutDocumentAdapterVitestMock(importOriginal, { toFlowBlocks: mockToFlowBlocks }); }); // Mock layout-bridge functions diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts index 934d3a92d1..c8c1e3d2b9 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts @@ -127,12 +127,9 @@ vi.mock('../../Editor.js', () => { }; }); -vi.mock('@superdoc/pm-adapter', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - toFlowBlocks: mockToFlowBlocks, - }; +vi.mock('@core/layout-adapter', async (importOriginal) => { + const { buildLayoutDocumentAdapterVitestMock } = await import('./mock-layout-document-adapter-vitest.js'); + return buildLayoutDocumentAdapterVitestMock(importOriginal, { toFlowBlocks: mockToFlowBlocks }); }); vi.mock('@superdoc/layout-bridge', () => ({ diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts index c7fd02c315..f61b637c50 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts @@ -188,12 +188,9 @@ vi.mock('../../Editor', () => { }); // Mock pm-adapter functions -vi.mock('@superdoc/pm-adapter', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - toFlowBlocks: mockToFlowBlocks, - }; +vi.mock('@core/layout-adapter', async (importOriginal) => { + const { buildLayoutDocumentAdapterVitestMock } = await import('./mock-layout-document-adapter-vitest.js'); + return buildLayoutDocumentAdapterVitestMock(importOriginal, { toFlowBlocks: mockToFlowBlocks }); }); // Mock layout-bridge functions diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts index 05787f77d2..d761b01579 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts @@ -36,15 +36,14 @@ vi.mock('../../Editor', () => ({ })), })); -vi.mock('@superdoc/pm-adapter', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, +vi.mock('@core/layout-adapter', async (importOriginal) => { + const { buildLayoutDocumentAdapterVitestMock } = await import('./mock-layout-document-adapter-vitest.js'); + return buildLayoutDocumentAdapterVitestMock(importOriginal, { toFlowBlocks: vi.fn((_, opts) => { capturedMediaFiles = opts?.mediaFiles; return { blocks: [], bookmarks: new Map() }; }), - }; + }); }); vi.mock('@superdoc/layout-bridge', () => ({ diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts index 5083952f7a..d2dce39afd 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts @@ -205,12 +205,9 @@ vi.mock('../../Editor', () => { }); // Mock pm-adapter functions -vi.mock('@superdoc/pm-adapter', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - toFlowBlocks: mockToFlowBlocks, - }; +vi.mock('@core/layout-adapter', async (importOriginal) => { + const { buildLayoutDocumentAdapterVitestMock } = await import('./mock-layout-document-adapter-vitest.js'); + return buildLayoutDocumentAdapterVitestMock(importOriginal, { toFlowBlocks: mockToFlowBlocks }); }); // Mock layout-bridge functions diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.sectionPageStyles.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.sectionPageStyles.test.ts index 26d0ad7da5..0927eb8fe0 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.sectionPageStyles.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.sectionPageStyles.test.ts @@ -195,13 +195,9 @@ vi.mock('../../Editor', () => { }; }); -// Mock pm-adapter functions -vi.mock('@superdoc/pm-adapter', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - toFlowBlocks: mockToFlowBlocks, - }; +vi.mock('@core/layout-adapter', async (importOriginal) => { + const { buildLayoutDocumentAdapterVitestMock } = await import('./mock-layout-document-adapter-vitest.js'); + return buildLayoutDocumentAdapterVitestMock(importOriginal, { toFlowBlocks: mockToFlowBlocks }); }); // Mock layout-bridge functions diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index 4e82ce545e..d7c5eb78e8 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts @@ -293,14 +293,12 @@ vi.mock('../../Editor', () => { }; }); -// Mock pm-adapter functions -vi.mock('@superdoc/pm-adapter', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, +vi.mock('@core/layout-adapter', async (importOriginal) => { + const { buildLayoutDocumentAdapterVitestMock } = await import('./mock-layout-document-adapter-vitest.js'); + return buildLayoutDocumentAdapterVitestMock(importOriginal, { toFlowBlocks: mockToFlowBlocks, FlowBlockCache: MockFlowBlockCache, - }; + }); }); // Mock layout-bridge functions diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.zoom.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.zoom.test.ts index 967984eb46..9e06c72dd1 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.zoom.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.zoom.test.ts @@ -214,12 +214,9 @@ vi.mock('../../Editor', () => { }); // Mock pm-adapter functions -vi.mock('@superdoc/pm-adapter', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - toFlowBlocks: mockToFlowBlocks, - }; +vi.mock('@core/layout-adapter', async (importOriginal) => { + const { buildLayoutDocumentAdapterVitestMock } = await import('./mock-layout-document-adapter-vitest.js'); + return buildLayoutDocumentAdapterVitestMock(importOriginal, { toFlowBlocks: mockToFlowBlocks }); }); // Mock layout-bridge functions diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/mock-layout-document-adapter-vitest.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/mock-layout-document-adapter-vitest.ts new file mode 100644 index 0000000000..5e2e18f744 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/mock-layout-document-adapter-vitest.ts @@ -0,0 +1,27 @@ +/** + * Builds a vitest module mock for the v1 layout adapter (`@core/layout-adapter`). + * + * The presentation/header-footer/notes paths now import `toFlowBlocks`, + * `analyzeSectionRanges`, and `FlowBlockCache` directly from the moved v1 + * adapter instead of resolving a process-global registry. Tests mock the + * module surface and override only the functions they care about, preserving + * every other real export via `importOriginal()`. + */ +type LayoutAdapterVitestOverrides = { + toFlowBlocks?: (...args: unknown[]) => unknown; + analyzeSectionRanges?: (...args: unknown[]) => unknown; + FlowBlockCache?: new (...args: unknown[]) => { clear(): void }; +}; + +export async function buildLayoutDocumentAdapterVitestMock( + importOriginal: () => Promise>, + overrides: LayoutAdapterVitestOverrides = {}, +) { + const actual = await importOriginal(); + return { + ...actual, + ...(overrides.toFlowBlocks ? { toFlowBlocks: overrides.toFlowBlocks } : {}), + ...(overrides.analyzeSectionRanges ? { analyzeSectionRanges: overrides.analyzeSectionRanges } : {}), + ...(overrides.FlowBlockCache ? { FlowBlockCache: overrides.FlowBlockCache } : {}), + }; +} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/sections-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/sections-resolver.ts index 1f0b9eabb1..e7f5a8b8a6 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/sections-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/sections-resolver.ts @@ -8,8 +8,8 @@ import type { SectionsListResult, } from '@superdoc/document-api'; import { buildDiscoveryItem, buildDiscoveryResult, buildResolvedHandle } from '@superdoc/document-api'; -import { analyzeSectionRanges } from '@superdoc/pm-adapter/sections/analysis.js'; -import { SectionType, type SectionRange } from '@superdoc/pm-adapter/sections/types.js'; +import { analyzeSectionRanges, type PMNode } from '@core/layout-adapter'; +import { SectionType, type SectionRange } from '@core/layout-adapter/sections/types.js'; import type { Editor } from '../../core/Editor.js'; import { DocumentApiAdapterError } from '../errors.js'; import { getRevision } from '../plan-engine/revision-tracker.js'; @@ -117,7 +117,7 @@ function collectParagraphSnapshots(editor: Editor): ParagraphSnapshot[] { return snapshots; } -function buildAnalysisDocFromParagraphs(paragraphs: ParagraphSnapshot[]): Parameters[0] { +function buildAnalysisDocFromParagraphs(paragraphs: ParagraphSnapshot[]): unknown { return { type: 'doc', content: paragraphs.map((paragraph) => ({ @@ -127,10 +127,7 @@ function buildAnalysisDocFromParagraphs(paragraphs: ParagraphSnapshot[]): Parame }; } -function resolveAnalysisDoc( - editor: Editor, - paragraphs: ParagraphSnapshot[], -): Parameters[0] { +function resolveAnalysisDoc(editor: Editor, paragraphs: ParagraphSnapshot[]): unknown { const maybeJsonDoc = (editor.state.doc as unknown as { toJSON?: () => unknown }).toJSON?.(); if ( maybeJsonDoc && @@ -138,7 +135,7 @@ function resolveAnalysisDoc( typeof (maybeJsonDoc as { type?: unknown }).type === 'string' && Array.isArray((maybeJsonDoc as { content?: unknown[] }).content) ) { - return maybeJsonDoc as Parameters[0]; + return maybeJsonDoc; } return buildAnalysisDocFromParagraphs(paragraphs); @@ -320,7 +317,7 @@ export function resolveSectionProjections(editor: Editor): SectionProjection[] { const bodySectPr = getBodySectPrFromEditor(editor); const oddEvenHeadersFooters = readOddEvenHeadersFlag(editor); const analysisDoc = resolveAnalysisDoc(editor, paragraphs); - const analyzed = analyzeSectionRanges(analysisDoc, bodySectPr ?? undefined); + const analyzed = analyzeSectionRanges(analysisDoc as PMNode, bodySectPr ?? undefined); const ranges = analyzed.length > 0 ? analyzed : [createSyntheticRange(bodySectPr, paragraphs.length)]; return ranges.map((range, index) => { diff --git a/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js b/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js index 9ebe678741..0cf1d35d21 100644 --- a/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js +++ b/packages/super-editor/src/editors/v1/tests/import-export/sd-3116-structured-content-image-roundtrip.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach } from 'vitest'; -import { toFlowBlocks } from '@superdoc/pm-adapter'; +import { toFlowBlocks } from '@core/layout-adapter'; import { createDomPainter } from '@superdoc/painter-dom'; import { resolveLayout } from '@superdoc/layout-resolved'; import { Editor } from '@core/Editor.js'; diff --git a/packages/super-editor/src/editors/v1/tests/parity/adapter-parity.test.js b/packages/super-editor/src/editors/v1/tests/parity/adapter-parity.test.js index 3504905a1f..3d7b397eda 100644 --- a/packages/super-editor/src/editors/v1/tests/parity/adapter-parity.test.js +++ b/packages/super-editor/src/editors/v1/tests/parity/adapter-parity.test.js @@ -5,7 +5,7 @@ import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpe import { computeParagraphReferenceSnapshot } from '@tests/helpers/paragraphReference.js'; import { zipFolderToBuffer } from '@tests/helpers/zipFolderToBuffer.js'; import { Editor } from '@core/Editor.js'; -import { computeParagraphAttrs } from '@superdoc/pm-adapter/attributes/paragraph.js'; +import { computeParagraphAttrs } from '@core/layout-adapter/attributes/paragraph.js'; import { buildConverterContextFromEditor } from '../helpers/adapterTestHelpers.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); diff --git a/packages/super-editor/src/editors/v1/tests/parity/debug-parity.js b/packages/super-editor/src/editors/v1/tests/parity/debug-parity.js index 566aecb7d8..3d277178d8 100644 --- a/packages/super-editor/src/editors/v1/tests/parity/debug-parity.js +++ b/packages/super-editor/src/editors/v1/tests/parity/debug-parity.js @@ -16,7 +16,7 @@ import { resolve } from 'path'; import { Editor } from '../../../core/Editor.js'; import { initTestEditor } from '../helpers/helpers.js'; import { computeParagraphReferenceSnapshot } from '../helpers/paragraphReference.js'; -import { computeParagraphAttrs } from '@superdoc/pm-adapter/attributes/paragraph.js'; +import { computeParagraphAttrs } from '@core/layout-adapter/attributes/paragraph.js'; import { buildStyleContextFromEditor, buildConverterContextFromEditor, diff --git a/packages/super-editor/src/editors/v1/tests/parity/marker-styling.test.js b/packages/super-editor/src/editors/v1/tests/parity/marker-styling.test.js index dd7027945e..befcd6c492 100644 --- a/packages/super-editor/src/editors/v1/tests/parity/marker-styling.test.js +++ b/packages/super-editor/src/editors/v1/tests/parity/marker-styling.test.js @@ -1,7 +1,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; import { computeParagraphReferenceSnapshot } from '@tests/helpers/paragraphReference.js'; -import { computeParagraphAttrs } from '@superdoc/pm-adapter/attributes/paragraph.js'; +import { computeParagraphAttrs } from '@core/layout-adapter/attributes/paragraph.js'; import { buildConverterContextFromEditor } from '../helpers/adapterTestHelpers.js'; const findParagraphAt = (doc, predicate) => { diff --git a/packages/super-editor/src/editors/v1/tests/parity/spacing-rendering.test.js b/packages/super-editor/src/editors/v1/tests/parity/spacing-rendering.test.js index d9c43fa3fb..d3f0fcd53d 100644 --- a/packages/super-editor/src/editors/v1/tests/parity/spacing-rendering.test.js +++ b/packages/super-editor/src/editors/v1/tests/parity/spacing-rendering.test.js @@ -1,7 +1,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; import { computeParagraphReferenceSnapshot } from '@tests/helpers/paragraphReference.js'; -import { computeParagraphAttrs } from '@superdoc/pm-adapter/attributes/paragraph.js'; +import { computeParagraphAttrs } from '@core/layout-adapter/attributes/paragraph.js'; import { buildConverterContextFromEditor } from '../helpers/adapterTestHelpers.js'; const findParagraphAt = (doc, predicate) => { diff --git a/packages/super-editor/src/editors/v1/tests/parity/tabs-hanging.test.js b/packages/super-editor/src/editors/v1/tests/parity/tabs-hanging.test.js index dbc6d15b54..90e1203375 100644 --- a/packages/super-editor/src/editors/v1/tests/parity/tabs-hanging.test.js +++ b/packages/super-editor/src/editors/v1/tests/parity/tabs-hanging.test.js @@ -5,7 +5,7 @@ import { initTestEditor } from '@tests/helpers/helpers.js'; import { computeParagraphReferenceSnapshot } from '@tests/helpers/paragraphReference.js'; import { zipFolderToBuffer } from '@tests/helpers/zipFolderToBuffer.js'; import { Editor } from '@core/Editor.js'; -import { computeParagraphAttrs } from '@superdoc/pm-adapter/attributes/paragraph.js'; +import { computeParagraphAttrs } from '@core/layout-adapter/attributes/paragraph.js'; import { buildConverterContextFromEditor } from '../helpers/adapterTestHelpers.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); diff --git a/packages/super-editor/src/index.types.test.ts b/packages/super-editor/src/index.types.test.ts index 588626bb84..ae8b252998 100644 --- a/packages/super-editor/src/index.types.test.ts +++ b/packages/super-editor/src/index.types.test.ts @@ -632,9 +632,11 @@ vi.mock('./editors/v1/core/Editor', () => ({ })), })); -vi.mock('@superdoc/pm-adapter', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, toFlowBlocks: mockToFlowBlocks }; +vi.mock('@core/layout-adapter', async (importOriginal) => { + const { buildLayoutDocumentAdapterVitestMock } = await import( + './editors/v1/core/presentation-editor/tests/mock-layout-document-adapter-vitest.js' + ); + return buildLayoutDocumentAdapterVitestMock(importOriginal, { toFlowBlocks: mockToFlowBlocks }); }); // Mock PositionHitResolver diff --git a/packages/superdoc/scripts/type-surface.config.cjs b/packages/superdoc/scripts/type-surface.config.cjs index 2dd646ef71..0664ca006e 100644 --- a/packages/superdoc/scripts/type-surface.config.cjs +++ b/packages/superdoc/scripts/type-surface.config.cjs @@ -133,36 +133,41 @@ const relocations = [ viteIncludes: ['../layout-engine/painters/dom/src/**/*'], tsconfigIncludes: ['../layout-engine/painters/dom/src'], }, - // pm-adapter: subpath-only. The full barrel pulls in @superdoc/style-engine - // and other internal packages that would re-expand the shim list. + // SD-3222: the v1 ProseMirror adapter (converter-context, sections/types) + // moved into @superdoc/super-editor (../super-editor/src), so its + // declarations are emitted as part of the super-editor source root in + // `baseTsconfigIncludes`. No standalone pm-adapter relocation is needed. + // style-engine/ooxml: subpath-only. Includes the ooxml subtree plus the + // sibling cascade.ts dependency it imports. { - pkg: '@superdoc/pm-adapter/converter-context.js', - distEntry: 'layout-engine/pm-adapter/src/converter-context.d.ts', + pkg: '@superdoc/style-engine/ooxml', + distEntry: 'layout-engine/style-engine/src/ooxml/index.d.ts', matchSubpaths: false, - viteIncludes: ['../layout-engine/pm-adapter/src/converter-context.ts'], - tsconfigIncludes: ['../layout-engine/pm-adapter/src/converter-context.ts'], + viteIncludes: ['../layout-engine/style-engine/src/ooxml/**/*', '../layout-engine/style-engine/src/cascade.ts'], + tsconfigIncludes: ['../layout-engine/style-engine/src/ooxml', '../layout-engine/style-engine/src/cascade.ts'], }, + // SD-3222: the v1 layout-adapter (now under super-editor/src) surfaces a few + // bare type imports β€” `StyleContext`/`ComputedParagraphStyle` from + // `@superdoc/style-engine`, `ResolvedRunProperties` from + // `@superdoc/word-layout` β€” that the old narrow pm-adapter relocation kept off + // superdoc's emitted surface. Relocate the packages those types come from so + // the published d.ts point at bundled dist paths instead of leaking bare, + // unpublished `@superdoc/*` specifiers. Both have a bounded dependency + // closure: word-layout imports no `@superdoc/*`, and style-engine only pulls + // in already-relocated `@superdoc/contracts` and `@superdoc/style-engine/ooxml`. { - pkg: '@superdoc/pm-adapter/sections/types.js', - distEntry: 'layout-engine/pm-adapter/src/sections/types.d.ts', + pkg: '@superdoc/style-engine', + distEntry: 'layout-engine/style-engine/src/index.d.ts', matchSubpaths: false, - viteIncludes: ['../layout-engine/pm-adapter/src/sections/types.ts'], - tsconfigIncludes: ['../layout-engine/pm-adapter/src/sections/types.ts'], + viteIncludes: ['../layout-engine/style-engine/src/**/*'], + tsconfigIncludes: ['../layout-engine/style-engine/src'], }, - // style-engine/ooxml: subpath-only. Includes the ooxml subtree plus the - // sibling cascade.ts dependency it imports. { - pkg: '@superdoc/style-engine/ooxml', - distEntry: 'layout-engine/style-engine/src/ooxml/index.d.ts', - matchSubpaths: false, - viteIncludes: [ - '../layout-engine/style-engine/src/ooxml/**/*', - '../layout-engine/style-engine/src/cascade.ts', - ], - tsconfigIncludes: [ - '../layout-engine/style-engine/src/ooxml', - '../layout-engine/style-engine/src/cascade.ts', - ], + pkg: '@superdoc/word-layout', + distEntry: 'word-layout/src/index.d.ts', + matchSubpaths: true, + viteIncludes: ['../word-layout/src/**/*'], + tsconfigIncludes: ['../word-layout/src'], }, // common/list-marker-utils and common (bare): emitted via tsc-postbuild // (see sharedCommonDtsTargets) because the source lives in shared/, which @@ -183,6 +188,15 @@ const relocations = [ viteIncludes: [], // emitted via sharedCommonDtsTargets tsc-postbuild tsconfigIncludes: [], }, + // SD-3222: the v1 layout-adapter's list-helpers re-exports list-numbering + // utilities. Emit the leaf module via tsc-postbuild like list-marker-utils. + { + pkg: '@superdoc/common/list-numbering', + distEntry: 'shared/common/list-numbering/index.d.ts', + matchSubpaths: false, + viteIncludes: [], // emitted via sharedCommonDtsTargets tsc-postbuild + tsconfigIncludes: [], + }, ]; /** @@ -194,6 +208,7 @@ const sharedCommonDtsTargets = [ 'list-marker-utils.ts', 'layout-constants.ts', // dependency of list-marker-utils 'comments-types.ts', + 'list-numbering/index.ts', // SD-3222: re-exported by the v1 layout-adapter's list-helpers ]; /** @@ -209,10 +224,11 @@ const relocationGuardPackages = [ '@superdoc/layout-bridge', '@superdoc/layout-engine', '@superdoc/painter-dom', - '@superdoc/pm-adapter', '@superdoc/style-engine', + '@superdoc/word-layout', '@superdoc/common', '@superdoc/common/list-marker-utils', + '@superdoc/common/list-numbering', ]; /** @@ -221,7 +237,11 @@ const relocationGuardPackages = [ * forward-compat documentation; the SD-2942 removal made shim * generation a no-op. */ -const unshimmedPrivateSpecifiers = ['@superdoc/pm-adapter', '@superdoc/style-engine']; +const unshimmedPrivateSpecifiers = [ + '@superdoc/style-engine', + '@superdoc/word-layout', + '@superdoc/common/list-numbering', +]; /** * Bare `@superdoc/*` specifiers permitted in published d.ts beyond the diff --git a/packages/superdoc/tsconfig.json b/packages/superdoc/tsconfig.json index ecd714aa6b..b2c9c32ac5 100644 --- a/packages/superdoc/tsconfig.json +++ b/packages/superdoc/tsconfig.json @@ -31,9 +31,9 @@ "../layout-engine/layout-bridge/src", "../layout-engine/layout-engine/src", "../layout-engine/painters/dom/src", - "../layout-engine/pm-adapter/src/converter-context.ts", - "../layout-engine/pm-adapter/src/sections/types.ts", "../layout-engine/style-engine/src/ooxml", - "../layout-engine/style-engine/src/cascade.ts" + "../layout-engine/style-engine/src/cascade.ts", + "../layout-engine/style-engine/src", + "../word-layout/src" ] } diff --git a/packages/superdoc/tsconfig.types.json b/packages/superdoc/tsconfig.types.json index 47d7bdf366..8815c1c5de 100644 --- a/packages/superdoc/tsconfig.types.json +++ b/packages/superdoc/tsconfig.types.json @@ -2,7 +2,8 @@ "extends": "./tsconfig.build.json", "compilerOptions": { "composite": true, - "outDir": "dist-types" + "outDir": "dist-types", + "rootDir": "../.." }, "references": [ { "path": "../super-editor/tsconfig.types.json" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8fd9c69405..0d10b58118 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -479,9 +479,6 @@ importers: '@superdoc/document-api': specifier: workspace:* version: link:../../packages/document-api - '@superdoc/pm-adapter': - specifier: workspace:* - version: link:../../packages/layout-engine/pm-adapter '@superdoc/super-editor': specifier: workspace:* version: link:../../packages/super-editor @@ -558,7 +555,7 @@ importers: version: 14.0.3 mintlify: specifier: 4.2.531 - version: 4.2.531(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@25.6.0)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 4.2.531(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) remark-mdx: specifier: ^3.1.1 version: 3.1.1 @@ -1056,7 +1053,7 @@ importers: dependencies: '@hocuspocus/provider': specifier: latest - version: 4.0.0(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30) + version: 4.1.0(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) '@hookform/resolvers': specifier: ^3.9.1 version: 3.10.0(react-hook-form@7.72.0(react@18.2.0)) @@ -1212,10 +1209,10 @@ importers: version: 0.9.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) y-prosemirror: specifier: latest - version: 1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30) + version: 1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) yjs: specifier: latest - version: 13.6.30 + version: 13.6.31 zod: specifier: ^3.24.1 version: 3.25.76 @@ -2609,9 +2606,6 @@ importers: '@superdoc/painter-dom': specifier: workspace:* version: link:../painters/dom - '@superdoc/pm-adapter': - specifier: workspace:* - version: link:../pm-adapter '@types/node': specifier: 'catalog:' version: 22.19.2 @@ -2702,49 +2696,6 @@ importers: specifier: 'catalog:' version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@25.6.0)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - packages/layout-engine/pm-adapter: - dependencies: - '@superdoc/common': - specifier: workspace:* - version: link:../../../shared/common - '@superdoc/contracts': - specifier: workspace:* - version: link:../contracts - '@superdoc/font-utils': - specifier: workspace:* - version: link:../../../shared/font-utils - '@superdoc/locale-utils': - specifier: workspace:* - version: link:../../../shared/locale-utils - '@superdoc/measuring-dom': - specifier: workspace:* - version: link:../measuring/dom - '@superdoc/style-engine': - specifier: workspace:* - version: link:../style-engine - '@superdoc/super-editor': - specifier: workspace:* - version: link:../../super-editor - '@superdoc/url-validation': - specifier: workspace:* - version: link:../../../shared/url-validation - '@superdoc/word-layout': - specifier: workspace:* - version: link:../../word-layout - devDependencies: - '@superdoc/layout-engine': - specifier: workspace:* - version: link:../layout-engine - '@superdoc/layout-resolved': - specifier: workspace:* - version: link:../layout-resolved - '@superdoc/painter-dom': - specifier: workspace:* - version: link:../painters/dom - vitest: - specifier: 'catalog:' - version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@25.6.0)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - packages/layout-engine/style-engine: dependencies: '@superdoc/contracts': @@ -2778,9 +2729,6 @@ importers: '@superdoc/measuring-dom': specifier: workspace:* version: link:../measuring/dom - '@superdoc/pm-adapter': - specifier: workspace:* - version: link:../pm-adapter devDependencies: '@vitejs/plugin-vue': specifier: 'catalog:' @@ -2905,6 +2853,9 @@ importers: '@superdoc/dom-contract': specifier: workspace:* version: link:../layout-engine/dom-contract + '@superdoc/font-utils': + specifier: workspace:* + version: link:../../shared/font-utils '@superdoc/layout-bridge': specifier: workspace:* version: link:../layout-engine/layout-bridge @@ -2917,9 +2868,6 @@ importers: '@superdoc/painter-dom': specifier: workspace:* version: link:../layout-engine/painters/dom - '@superdoc/pm-adapter': - specifier: workspace:* - version: link:../layout-engine/pm-adapter '@superdoc/preset-geometry': specifier: workspace:* version: link:../preset-geometry @@ -3029,6 +2977,9 @@ importers: '@floating-ui/dom': specifier: 'catalog:' version: 1.7.6 + '@superdoc/layout-engine': + specifier: workspace:* + version: link:../layout-engine/layout-engine '@testing-library/react': specifier: 'catalog:' version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -5676,8 +5627,8 @@ packages: '@hocuspocus/common@2.15.3': resolution: {integrity: sha512-Rzh1HF0a2o/tf90A3w2XNdXd9Ym3aQzMDfD3lAUONCX9B9QOdqdyiORrj6M25QEaJrEIbXFy8LtAFcL0wRdWzA==} - '@hocuspocus/common@4.0.0': - resolution: {integrity: sha512-7BE8TsKBkdiOZO6tfm3ny6bIHPbxkIZb3hsYdVn/X5xbXI8n8w9pnE6pXgEMKQhJm6zsWsa9IDRJIp/c9u+DmA==} + '@hocuspocus/common@4.1.0': + resolution: {integrity: sha512-SOBbu0GcBMbLo7IYRDZC6gvEcoATbEFIC5KqzvLanC6dZZLkv91pYEBli+Exs/G71ELL3iUjSwnaf+gksxcjFA==} '@hocuspocus/provider@2.15.3': resolution: {integrity: sha512-oadN05m+KL4ylNKVo5YspNG4MXkT2Y+FUFzrgigpQeTjQibkPUwCNmUnkUxMgrGRgxb+O0lJCfirFIJMxedctA==} @@ -5685,8 +5636,8 @@ packages: y-protocols: ^1.0.6 yjs: ^13.6.8 - '@hocuspocus/provider@4.0.0': - resolution: {integrity: sha512-08gpeNZ6x2pmRD6m4XwRD52yQKnTl32a0HS9VSXZ5A1dIBVqxMz/x8Z06XbkKM2X8sp6vWEUCZCtzAGFSsofgg==} + '@hocuspocus/provider@4.1.0': + resolution: {integrity: sha512-K80V2yX4AMdpqgjIkexJioyI2Oq8pu8hjdFqY0INXKSpH8TQv9NMaDzJmlrZMYmUeKyRO2t7VhGyFK9jZQPnNw==} peerDependencies: y-protocols: ^1.0.6 yjs: ^13.6.8 @@ -6562,7 +6513,6 @@ packages: '@microsoft/teamsapp-cli@3.0.2': resolution: {integrity: sha512-AowuJwrrUxeF9Bq/frxuy9YZjK/ECk3pi0UBXl3CQLZ4XNWfgWatiFi/UWpyHDLccFs+0Za3nNYATFvgsxEFwQ==} engines: {node: '>=12'} - deprecated: This package is deprecated and supported Node.js version is 18-22. Please use @microsoft/m365agentstoolkit-cli instead. hasBin: true '@microsoft/teamsfx-api@0.23.1': @@ -23060,6 +23010,10 @@ packages: resolution: {integrity: sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yjs@13.6.31: + resolution: {integrity: sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -26223,7 +26177,7 @@ snapshots: dependencies: lib0: 0.2.117 - '@hocuspocus/common@4.0.0': + '@hocuspocus/common@4.1.0': dependencies: lib0: 0.2.117 @@ -26251,17 +26205,13 @@ snapshots: - bufferutil - utf-8-validate - '@hocuspocus/provider@4.0.0(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30)': + '@hocuspocus/provider@4.1.0(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)': dependencies: - '@hocuspocus/common': 4.0.0 + '@hocuspocus/common': 4.1.0 '@lifeomic/attempt': 3.1.0 lib0: 0.2.117 - ws: 8.20.0 - y-protocols: 1.0.7(yjs@13.6.30) - yjs: 13.6.30 - transitivePeerDependencies: - - bufferutil - - utf-8-validate + y-protocols: 1.0.7(yjs@13.6.31) + yjs: 13.6.31 '@hocuspocus/server@2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19)': dependencies: @@ -26540,6 +26490,16 @@ snapshots: chalk: 4.1.2 figures: 3.2.0 + '@inquirer/checkbox@4.3.2(@types/node@22.19.2)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.19.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/checkbox@4.3.2(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -26565,6 +26525,13 @@ snapshots: '@inquirer/type': 1.5.5 chalk: 4.1.2 + '@inquirer/confirm@5.1.21(@types/node@22.19.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.2) + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/confirm@5.1.21(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26579,6 +26546,19 @@ snapshots: optionalDependencies: '@types/node': 18.19.130 + '@inquirer/core@10.3.2(@types/node@22.19.2)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.19.2) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/core@10.3.2(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -26645,6 +26625,14 @@ snapshots: chalk: 4.1.2 external-editor: 3.1.0 + '@inquirer/editor@4.2.23(@types/node@22.19.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/external-editor': 1.0.3(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.2) + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/editor@4.2.23(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26668,6 +26656,14 @@ snapshots: chalk: 4.1.2 figures: 3.2.0 + '@inquirer/expand@4.0.23(@types/node@22.19.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/expand@4.0.23(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26676,6 +26672,13 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 + '@inquirer/external-editor@1.0.3(@types/node@22.19.2)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/external-editor@1.0.3(@types/node@25.6.0)': dependencies: chardet: 2.1.1 @@ -26700,6 +26703,13 @@ snapshots: '@inquirer/type': 1.5.5 chalk: 4.1.2 + '@inquirer/input@4.3.1(@types/node@22.19.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.2) + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/input@4.3.1(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26714,6 +26724,13 @@ snapshots: optionalDependencies: '@types/node': 18.19.130 + '@inquirer/number@3.0.23(@types/node@22.19.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.2) + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/number@3.0.23(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26728,6 +26745,14 @@ snapshots: ansi-escapes: 4.3.2 chalk: 4.1.2 + '@inquirer/password@4.0.23(@types/node@22.19.2)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.2) + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/password@4.0.23(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -26748,6 +26773,21 @@ snapshots: '@inquirer/rawlist': 1.2.16 '@inquirer/select': 1.3.3 + '@inquirer/prompts@7.10.1(@types/node@22.19.2)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@22.19.2) + '@inquirer/confirm': 5.1.21(@types/node@22.19.2) + '@inquirer/editor': 4.2.23(@types/node@22.19.2) + '@inquirer/expand': 4.0.23(@types/node@22.19.2) + '@inquirer/input': 4.3.1(@types/node@22.19.2) + '@inquirer/number': 3.0.23(@types/node@22.19.2) + '@inquirer/password': 4.0.23(@types/node@22.19.2) + '@inquirer/rawlist': 4.1.11(@types/node@22.19.2) + '@inquirer/search': 3.2.2(@types/node@22.19.2) + '@inquirer/select': 4.4.2(@types/node@22.19.2) + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/prompts@7.10.1(@types/node@25.6.0)': dependencies: '@inquirer/checkbox': 4.3.2(@types/node@25.6.0) @@ -26763,20 +26803,20 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 - '@inquirer/prompts@7.9.0(@types/node@25.6.0)': + '@inquirer/prompts@7.9.0(@types/node@22.19.2)': dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@25.6.0) - '@inquirer/confirm': 5.1.21(@types/node@25.6.0) - '@inquirer/editor': 4.2.23(@types/node@25.6.0) - '@inquirer/expand': 4.0.23(@types/node@25.6.0) - '@inquirer/input': 4.3.1(@types/node@25.6.0) - '@inquirer/number': 3.0.23(@types/node@25.6.0) - '@inquirer/password': 4.0.23(@types/node@25.6.0) - '@inquirer/rawlist': 4.1.11(@types/node@25.6.0) - '@inquirer/search': 3.2.2(@types/node@25.6.0) - '@inquirer/select': 4.4.2(@types/node@25.6.0) + '@inquirer/checkbox': 4.3.2(@types/node@22.19.2) + '@inquirer/confirm': 5.1.21(@types/node@22.19.2) + '@inquirer/editor': 4.2.23(@types/node@22.19.2) + '@inquirer/expand': 4.0.23(@types/node@22.19.2) + '@inquirer/input': 4.3.1(@types/node@22.19.2) + '@inquirer/number': 3.0.23(@types/node@22.19.2) + '@inquirer/password': 4.0.23(@types/node@22.19.2) + '@inquirer/rawlist': 4.1.11(@types/node@22.19.2) + '@inquirer/search': 3.2.2(@types/node@22.19.2) + '@inquirer/select': 4.4.2(@types/node@22.19.2) optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 22.19.2 '@inquirer/rawlist@1.2.16': dependencies: @@ -26784,6 +26824,14 @@ snapshots: '@inquirer/type': 1.5.5 chalk: 4.1.2 + '@inquirer/rawlist@4.1.11(@types/node@22.19.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/rawlist@4.1.11(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26792,6 +26840,15 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 + '@inquirer/search@3.2.2(@types/node@22.19.2)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.19.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/search@3.2.2(@types/node@25.6.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@25.6.0) @@ -26809,6 +26866,16 @@ snapshots: chalk: 4.1.2 figures: 3.2.0 + '@inquirer/select@4.4.2(@types/node@22.19.2)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.19.2) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/select@4.4.2(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -26832,6 +26899,10 @@ snapshots: dependencies: mute-stream: 1.0.0 + '@inquirer/type@3.0.10(@types/node@22.19.2)': + optionalDependencies: + '@types/node': 22.19.2 + '@inquirer/type@3.0.10(@types/node@25.6.0)': optionalDependencies: '@types/node': 25.6.0 @@ -27396,11 +27467,11 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} - '@mintlify/cli@4.0.1134(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@25.6.0)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': + '@mintlify/cli@4.0.1134(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': dependencies: - '@inquirer/prompts': 7.9.0(@types/node@25.6.0) + '@inquirer/prompts': 7.9.0(@types/node@22.19.2) '@mintlify/common': 1.0.865(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) - '@mintlify/link-rot': 3.0.1043(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + '@mintlify/link-rot': 3.0.1043(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@mintlify/prebuild': 1.0.1008(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@mintlify/previewing': 4.0.1069(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@mintlify/validation': 0.1.676(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(typescript@5.9.3) @@ -27411,7 +27482,7 @@ snapshots: front-matter: 4.0.2 fs-extra: 11.2.0 ink: 6.3.0(@types/react@19.2.14)(react@19.2.3) - inquirer: 12.3.0(@types/node@25.6.0) + inquirer: 12.3.0(@types/node@22.19.2) js-yaml: 4.1.0 mdast-util-mdx-jsx: 3.2.0 open: 8.4.2 @@ -27443,7 +27514,7 @@ snapshots: - utf-8-validate - yaml - '@mintlify/common@1.0.661(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(typescript@5.9.3)': + '@mintlify/common@1.0.661(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(typescript@5.9.3)': dependencies: '@asyncapi/parser': 3.4.0 '@mintlify/mdx': 3.0.4(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(typescript@5.9.3) @@ -27483,7 +27554,7 @@ snapshots: remark-parse: 11.0.0 remark-rehype: 11.1.1 remark-stringify: 11.0.0 - tailwindcss: 3.4.4(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3)) + tailwindcss: 3.4.4(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3)) unified: 11.0.5 unist-builder: 4.0.0 unist-util-map: 4.0.0 @@ -27567,13 +27638,13 @@ snapshots: - typescript - yaml - '@mintlify/link-rot@3.0.1043(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': + '@mintlify/link-rot@3.0.1043(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': dependencies: '@mintlify/common': 1.0.865(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@mintlify/models': 0.0.296 '@mintlify/prebuild': 1.0.1008(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) '@mintlify/previewing': 4.0.1069(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) - '@mintlify/scraping': 4.0.522(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(typescript@5.9.3) + '@mintlify/scraping': 4.0.522(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(typescript@5.9.3) '@mintlify/validation': 0.1.676(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(typescript@5.9.3) fs-extra: 11.1.0 unist-util-visit: 4.1.2 @@ -27718,9 +27789,9 @@ snapshots: - utf-8-validate - yaml - '@mintlify/scraping@4.0.522(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(typescript@5.9.3)': + '@mintlify/scraping@4.0.522(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(typescript@5.9.3)': dependencies: - '@mintlify/common': 1.0.661(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(typescript@5.9.3) + '@mintlify/common': 1.0.661(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(typescript@5.9.3) '@mintlify/openapi-parser': 0.0.8 fs-extra: 11.1.1 hast-util-to-mdast: 10.1.0 @@ -39100,12 +39171,12 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - inquirer@12.3.0(@types/node@25.6.0): + inquirer@12.3.0(@types/node@22.19.2): dependencies: - '@inquirer/core': 10.3.2(@types/node@25.6.0) - '@inquirer/prompts': 7.10.1(@types/node@25.6.0) - '@inquirer/type': 3.0.10(@types/node@25.6.0) - '@types/node': 25.6.0 + '@inquirer/core': 10.3.2(@types/node@22.19.2) + '@inquirer/prompts': 7.10.1(@types/node@22.19.2) + '@inquirer/type': 3.0.10(@types/node@22.19.2) + '@types/node': 22.19.2 ansi-escapes: 4.3.2 mute-stream: 2.0.0 run-async: 3.0.0 @@ -41481,9 +41552,9 @@ snapshots: dependencies: minipass: 7.1.3 - mintlify@4.2.531(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@25.6.0)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): + mintlify@4.2.531(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: - '@mintlify/cli': 4.0.1134(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@25.6.0)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + '@mintlify/cli': 4.0.1134(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(react@19.2.3))(@types/node@22.19.2)(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.3))(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) transitivePeerDependencies: - '@radix-ui/react-popover' - '@types/node' @@ -43444,13 +43515,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.8 - postcss-load-config@4.0.2(postcss@8.5.10)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3)): + postcss-load-config@4.0.2(postcss@8.5.10)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3)): dependencies: lilconfig: 3.1.3 yaml: 2.8.3 optionalDependencies: postcss: 8.5.10 - ts-node: 10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3) + ts-node: 10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3) postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.3): dependencies: @@ -46621,7 +46692,7 @@ snapshots: - tsx - yaml - tailwindcss@3.4.4(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3)): + tailwindcss@3.4.4(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -46640,7 +46711,7 @@ snapshots: postcss: 8.5.10 postcss-import: 15.1.0(postcss@8.5.10) postcss-js: 4.1.0(postcss@8.5.10) - postcss-load-config: 4.0.2(postcss@8.5.10)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3)) + postcss-load-config: 4.0.2(postcss@8.5.10)(ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3)) postcss-nested: 6.2.0(postcss@8.5.10) postcss-selector-parser: 6.1.2 resolve: 1.22.11 @@ -46771,7 +46842,7 @@ snapshots: jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.46.1 - webpack: 5.105.4(esbuild@0.27.7)(webpack-cli@5.1.4) + webpack: 5.105.4(esbuild@0.27.7)(webpack-cli@6.0.1) optionalDependencies: esbuild: 0.27.7 @@ -47006,14 +47077,14 @@ snapshots: '@swc/core': 1.15.21 optional: true - ts-node@10.9.2(@swc/core@1.15.21)(@types/node@25.6.0)(typescript@5.9.3): + ts-node@10.9.2(@swc/core@1.15.21)(@types/node@22.19.2)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 25.6.0 + '@types/node': 22.19.2 acorn: 8.16.0 acorn-walk: 8.3.5 arg: 4.1.3 @@ -48267,7 +48338,7 @@ snapshots: std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 0.3.2 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 tinypool: 1.1.1 tinyrainbow: 2.0.0 vite: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.2)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) @@ -48313,7 +48384,7 @@ snapshots: std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 0.3.2 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 tinypool: 1.1.1 tinyrainbow: 2.0.0 vite: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.2)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) @@ -48359,7 +48430,7 @@ snapshots: std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 0.3.2 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 tinypool: 1.1.1 tinyrainbow: 2.0.0 vite: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.2)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) @@ -48405,7 +48476,7 @@ snapshots: std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 0.3.2 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 tinypool: 1.1.1 tinyrainbow: 2.0.0 vite: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) @@ -49040,6 +49111,15 @@ snapshots: y-protocols: 1.0.7(yjs@13.6.30) yjs: 13.6.30 + y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31): + dependencies: + lib0: 0.2.117 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.7 + y-protocols: 1.0.7(yjs@13.6.31) + yjs: 13.6.31 + y-protocols@1.0.7(yjs@13.6.19): dependencies: lib0: 0.2.117 @@ -49050,6 +49130,11 @@ snapshots: lib0: 0.2.117 yjs: 13.6.30 + y-protocols@1.0.7(yjs@13.6.31): + dependencies: + lib0: 0.2.117 + yjs: 13.6.31 + y-websocket@2.1.0(yjs@13.6.19): dependencies: lib0: 0.2.117 @@ -49149,6 +49234,10 @@ snapshots: dependencies: lib0: 0.2.117 + yjs@13.6.31: + dependencies: + lib0: 0.2.117 + yn@3.1.1: optional: true diff --git a/vitest.config.mjs b/vitest.config.mjs index 94c3fdcc22..b66ce41313 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -22,7 +22,6 @@ export default defineConfig({ './packages/layout-engine/layout-bridge', './packages/layout-engine/measuring/dom', './packages/layout-engine/painters/dom', - './packages/layout-engine/pm-adapter', './packages/layout-engine/tests', './apps/vscode-ext', ], From 7348c7482ecf4fb03fa861586c29dccca637b267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1?= Date: Sat, 30 May 2026 20:32:03 -0300 Subject: [PATCH 18/23] fix(presentation): keep virtualized pages in sync with host scroll (#3488) --- .../presentation-editor/PresentationEditor.ts | 23 +++-- tests/behavior/fixtures/superdoc.ts | 4 + tests/behavior/harness/main.ts | 19 ++++ .../blocked-scroll-event.spec.ts | 98 +++++++++++++++++++ 4 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 tests/behavior/tests/virtualization/blocked-scroll-event.spec.ts diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 83c8b945f9..aa8722d7e5 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -512,7 +512,8 @@ export class PresentationEditor extends EventEmitter { #semanticResizeDebounce: number | null = null; #lastSemanticContainerWidth: number | null = null; #editorListeners: Array<{ event: string; handler: (...args: unknown[]) => void }> = []; - #scrollHandler: (() => void) | null = null; + #scrollHandler: ((event?: Event) => void) | null = null; + #handledScrollEvents = new WeakSet(); #scrollContainer: Element | Window | null = null; #scrollContainerValidated = false; #sectionMetadata: SectionMetadata[] = []; @@ -4288,11 +4289,12 @@ export class PresentationEditor extends EventEmitter { if (this.#scrollHandler) { if (this.#scrollContainer) { - this.#scrollContainer.removeEventListener('scroll', this.#scrollHandler); + this.#scrollContainer.removeEventListener('scroll', this.#scrollHandler, { capture: true }); } const win = this.#visibleHost?.ownerDocument?.defaultView; - win?.removeEventListener('scroll', this.#scrollHandler); + win?.removeEventListener('scroll', this.#scrollHandler, { capture: true }); this.#scrollHandler = null; + this.#handledScrollEvents = new WeakSet(); this.#scrollContainer = null; } this.#inputBridge?.notifyTargetChanged(); @@ -5015,20 +5017,25 @@ export class PresentationEditor extends EventEmitter { // Scroll handler for virtualization - find the actual scroll container // by walking up the DOM tree to find the first scrollable ancestor - this.#scrollHandler = () => { + this.#handledScrollEvents = new WeakSet(); + this.#scrollHandler = (event?: Event) => { + if (event) { + if (this.#handledScrollEvents.has(event)) return; + this.#handledScrollEvents.add(event); + } this.#painterAdapter.onScroll(); }; // Find the scrollable ancestor and attach listener there this.#scrollContainer = this.#findScrollableAncestor(this.#visibleHost); if (this.#scrollContainer) { - this.#scrollContainer.addEventListener('scroll', this.#scrollHandler, { passive: true }); + this.#scrollContainer.addEventListener('scroll', this.#scrollHandler, { passive: true, capture: true }); } // Also listen on window as fallback const win = this.#visibleHost.ownerDocument?.defaultView; if (win && this.#scrollContainer !== win) { - win.addEventListener('scroll', this.#scrollHandler, { passive: true }); + win.addEventListener('scroll', this.#scrollHandler, { passive: true, capture: true }); } } @@ -5105,11 +5112,11 @@ export class PresentationEditor extends EventEmitter { if (!next || next === this.#scrollContainer) return; const prev = this.#scrollContainer; - prev.removeEventListener('scroll', this.#scrollHandler!); + prev.removeEventListener('scroll', this.#scrollHandler!, { capture: true }); this.#scrollContainer = next; if (next instanceof Element) { - next.addEventListener('scroll', this.#scrollHandler!, { passive: true }); + next.addEventListener('scroll', this.#scrollHandler!, { passive: true, capture: true }); } this.#painterAdapter.setScrollContainer(next instanceof HTMLElement ? next : null); } diff --git a/tests/behavior/fixtures/superdoc.ts b/tests/behavior/fixtures/superdoc.ts index da28b39b8b..f1a7489d5c 100644 --- a/tests/behavior/fixtures/superdoc.ts +++ b/tests/behavior/fixtures/superdoc.ts @@ -27,6 +27,8 @@ interface HarnessConfig { showSelection?: boolean; allowSelectionInViewMode?: boolean; documentMode?: 'editing' | 'viewing' | 'suggesting'; + previewScroll?: boolean; + blockPreviewScrollEvents?: boolean; } type DocumentMode = 'editing' | 'suggesting' | 'viewing'; @@ -63,6 +65,8 @@ function buildHarnessUrl(config: HarnessConfig = {}): string { if (config.showSelection !== undefined) params.set('showSelection', config.showSelection ? '1' : '0'); if (config.allowSelectionInViewMode) params.set('allowSelectionInViewMode', '1'); if (config.documentMode) params.set('documentMode', config.documentMode); + if (config.previewScroll) params.set('previewScroll', '1'); + if (config.blockPreviewScrollEvents) params.set('blockPreviewScrollEvents', '1'); const qs = params.toString(); return qs ? `${HARNESS_URL}?${qs}` : HARNESS_URL; } diff --git a/tests/behavior/harness/main.ts b/tests/behavior/harness/main.ts index 581b1a1994..010bdc4ac1 100644 --- a/tests/behavior/harness/main.ts +++ b/tests/behavior/harness/main.ts @@ -73,11 +73,30 @@ const allowSelectionInViewMode = params.get('allowSelectionInViewMode') === '1'; const documentMode = params.get('documentMode') as 'editing' | 'viewing' | 'suggesting' | null; const contentOverride = params.get('contentOverride') ?? undefined; const overrideType = (params.get('overrideType') as OverrideType | null) ?? undefined; +const previewScroll = params.get('previewScroll') === '1'; +const blockPreviewScrollEvents = params.get('blockPreviewScrollEvents') === '1'; if (!showCaret) { document.documentElement.style.setProperty('caret-color', 'transparent', 'important'); } +if (previewScroll) { + const harnessMain = document.querySelector('#harness-main'); + if (harnessMain) { + harnessMain.style.height = '720px'; + harnessMain.style.overflowY = 'auto'; + if (blockPreviewScrollEvents) { + harnessMain.addEventListener( + 'scroll', + (event) => { + event.stopImmediatePropagation(); + }, + { capture: true }, + ); + } + } +} + let instance: SuperDocInstance | null = null; const commentsPanel = document.querySelector('#comments-panel'); diff --git a/tests/behavior/tests/virtualization/blocked-scroll-event.spec.ts b/tests/behavior/tests/virtualization/blocked-scroll-event.spec.ts new file mode 100644 index 0000000000..0fcab4322f --- /dev/null +++ b/tests/behavior/tests/virtualization/blocked-scroll-event.spec.ts @@ -0,0 +1,98 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'none', previewScroll: true, blockPreviewScrollEvents: true } }); + +async function generateLongDocument(page: any, paragraphCount = 200): Promise { + await page.evaluate((count: number) => { + const editor = (window as any).editor; + const { state } = editor; + const { schema } = state; + + const paragraphs: any[] = []; + for (let i = 0; i < count; i++) { + const text = schema.text( + `SD-3230 paragraph ${i + 1}. ` + + 'This document is intentionally long enough to require a virtualized page window. ' + + 'The visible pages should update when the scroll owner moves.', + ); + const run = schema.nodes.run.create(null, text); + paragraphs.push(schema.nodes.paragraph.create(null, run)); + } + + const doc = schema.nodes.doc.create(null, paragraphs); + const tr = state.tr.replaceWith(0, state.doc.content.size, doc.content); + editor.view.dispatch(tr); + }, paragraphCount); +} + +async function jumpPastInitialVirtualWindow(page: any): Promise { + await page.evaluate(() => { + const editor = document.querySelector('.superdoc-viewport') ?? document.querySelector('#editor'); + let scrollOwner: HTMLElement | null = editor as HTMLElement; + + while (scrollOwner && scrollOwner !== document.documentElement) { + if (scrollOwner.scrollHeight > scrollOwner.clientHeight + 10) break; + scrollOwner = scrollOwner.parentElement; + } + + if (!scrollOwner || scrollOwner === document.documentElement) { + throw new Error('Expected a SuperDoc scroll owner for the virtualization regression test.'); + } + + scrollOwner.scrollTop = Math.floor(scrollOwner.clientHeight * 8); + }); +} + +async function getVirtualizedViewportState(page: any): Promise<{ + scrollTop: number; + mountedPages: number[]; + visibleText: string; +}> { + return page.evaluate(() => { + const editor = document.querySelector('.superdoc-viewport') ?? document.querySelector('#editor'); + let scrollOwner: HTMLElement | null = editor as HTMLElement; + + while (scrollOwner && scrollOwner !== document.documentElement) { + if (scrollOwner.scrollHeight > scrollOwner.clientHeight + 10) break; + scrollOwner = scrollOwner.parentElement; + } + + if (!scrollOwner || scrollOwner === document.documentElement) { + throw new Error('Expected a SuperDoc scroll owner for the virtualization regression test.'); + } + + const mountedPages = Array.from(document.querySelectorAll('.superdoc-page[data-page-index]')) + .map((pageEl) => Number((pageEl as HTMLElement).dataset.pageIndex)) + .sort((a, b) => a - b); + + const ownerRect = scrollOwner.getBoundingClientRect(); + const visibleText = Array.from(document.querySelectorAll('.superdoc-page[data-page-index]')) + .filter((pageEl) => { + const rect = pageEl.getBoundingClientRect(); + return rect.bottom > ownerRect.top && rect.top < ownerRect.bottom; + }) + .map((pageEl) => pageEl.textContent?.trim() ?? '') + .join('\n') + .trim(); + + return { + scrollTop: scrollOwner.scrollTop, + mountedPages, + visibleText, + }; + }); +} + +test('virtualized pages follow a host scroll owner that stops scroll propagation', async ({ superdoc }) => { + await generateLongDocument(superdoc.page); + await superdoc.waitForStable(2000); + + await jumpPastInitialVirtualWindow(superdoc.page); + await superdoc.waitForStable(500); + + const state = await getVirtualizedViewportState(superdoc.page); + + expect(state.scrollTop).toBeGreaterThan(0); + expect(Math.min(...state.mountedPages)).toBeGreaterThan(0); + expect(state.visibleText).toContain('SD-3230 paragraph'); +}); From fc1480ec137b19b8a71db2180a259f0a95371977 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1?= Date: Sun, 31 May 2026 20:37:33 -0300 Subject: [PATCH 19/23] fix(super-converter): normalize single-paragraph BIBLIOGRAPHY/INDEX/TOA field content (SD-3005) (#3538) --- .../core/layout-adapter/sdt/bibliography.ts | 77 +--------- .../core/layout-adapter/sdt/document-index.ts | 97 +------------ .../sdt/document-part-object.test.ts | 79 ++++++++++ .../sdt/document-part-object.ts | 20 ++- .../sdt/paragraph-container.test.ts | 74 ++++++++++ .../layout-adapter/sdt/paragraph-container.ts | 99 +++++++++++++ .../sdt/structured-content-block.ts | 13 +- .../sdt/table-of-authorities.ts | 77 +--------- .../bibliography-preprocessor.js | 30 +--- .../bibliography-preprocessor.test.js | 63 ++++++++ .../build-block-field-node.js | 32 ++++ .../build-block-field-node.test.js | 35 +++++ .../fld-preprocessors/index-preprocessor.js | 14 +- .../index-preprocessor.test.js | 23 ++- .../normalize-field-content.js | 49 +++++++ .../fld-preprocessors/toa-preprocessor.js | 14 +- .../toa-preprocessor.test.js | 51 +++++++ .../preProcessNodesForFldChar.js | 8 +- .../preProcessNodesForFldChar.test.js | 63 +++++++- .../v2/importer/docxImporter.js | 2 + .../v2/importer/paragraphNodeImporter.js | 58 +++++++- .../v2/importer/paragraphNodeImporter.test.js | 137 ++++++++++++++++++ .../v2/importer/tableOfAuthoritiesImporter.js | 10 ++ .../tableOfAuthoritiesImporter.test.js | 31 ++++ .../bibliography-export-routing.test.js | 23 +++ .../bibliography/bibliography-translator.js | 48 ++---- .../v3/handlers/sd/index/index-translator.js | 49 +------ .../indexEntry/indexEntry-translator.test.js | 27 ++++ .../sd/shared/block-field-xml-names.js | 18 +++ .../sd/shared/build-block-field-paragraphs.js | 67 +++++++++ .../build-block-field-paragraphs.test.js | 72 +++++++++ .../v3/handlers/sd/shared/index.js | 1 + .../tableOfAuthorities-translator.js | 45 +----- .../helpers/handle-structured-content-node.js | 7 +- .../handle-structured-content-node.test.js | 14 ++ .../extensions/bibliography/bibliography.js | 8 + .../document-index/document-index.js | 4 + .../table-of-authorities.js | 4 + .../v1/extensions/types/node-attributes.ts | 2 + 39 files changed, 1128 insertions(+), 417 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/layout-adapter/sdt/paragraph-container.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/layout-adapter/sdt/paragraph-container.ts create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/build-block-field-node.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/build-block-field-node.test.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/normalize-field-content.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toa-preprocessor.test.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v2/importer/paragraphNodeImporter.test.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v2/importer/tableOfAuthoritiesImporter.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v2/importer/tableOfAuthoritiesImporter.test.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/shared/block-field-xml-names.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/shared/build-block-field-paragraphs.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/shared/build-block-field-paragraphs.test.js diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/bibliography.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/bibliography.ts index 57a1cee22b..ce7bfb9451 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/bibliography.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/bibliography.ts @@ -1,79 +1,8 @@ /** * Bibliography Processing Module * - * Handles bibliography field containers by converting child paragraphs to flow blocks. - * Follows the same pattern as document-index.ts. + * Bibliography field containers convert their child paragraphs to flow blocks + * via the shared paragraph-container handler. */ -import type { PMNode, NodeHandlerContext } from '../types.js'; -import { createSectionBreakBlock, hasIntrinsicBoundarySignals, shouldRequirePageBoundary } from '../sections/index.js'; - -const getChildren = (node: PMNode): PMNode[] => { - if (Array.isArray(node.content)) return node.content; - const content = node.content as { forEach?: (cb: (child: PMNode) => void) => void } | undefined; - if (content && typeof content.forEach === 'function') { - const children: PMNode[] = []; - content.forEach((child) => children.push(child)); - return children; - } - return []; -}; - -export function handleBibliographyNode(node: PMNode, context: NodeHandlerContext): void { - const children = getChildren(node); - if (children.length === 0) return; - - const { - blocks, - recordBlockKind, - nextBlockId, - positions, - trackedChangesConfig, - bookmarks, - hyperlinkConfig, - sectionState, - converters, - themeColors, - enableComments, - } = context; - - const paragraphToFlowBlocks = converters.paragraphToFlowBlocks; - - children.forEach((child) => { - if (child.type !== 'paragraph') return; - - if ((sectionState?.ranges?.length ?? 0) > 0) { - const nextSection = sectionState!.ranges[sectionState!.currentSectionIndex + 1]; - if (nextSection && sectionState!.currentParagraphIndex === nextSection.startParagraphIndex) { - const currentSection = sectionState!.ranges[sectionState!.currentSectionIndex]; - const requiresPageBoundary = - shouldRequirePageBoundary(currentSection, nextSection) || hasIntrinsicBoundarySignals(nextSection); - const extraAttrs = requiresPageBoundary ? { requirePageBoundary: true } : undefined; - const sectionBreak = createSectionBreakBlock(nextSection, nextBlockId, extraAttrs); - blocks.push(sectionBreak); - recordBlockKind?.(sectionBreak.kind); - sectionState!.currentSectionIndex++; - } - } - - const paragraphBlocks = paragraphToFlowBlocks({ - para: child, - nextBlockId, - positions, - trackedChangesConfig, - bookmarks, - hyperlinkConfig, - themeColors, - converterContext: context.converterContext, - enableComments, - converters, - }); - - paragraphBlocks.forEach((block) => { - blocks.push(block); - recordBlockKind?.(block.kind); - }); - - sectionState!.currentParagraphIndex++; - }); -} +export { handleParagraphContainerNode as handleBibliographyNode } from './paragraph-container.js'; diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-index.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-index.ts index 32a6679d0b..f54ce7f034 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-index.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-index.ts @@ -1,98 +1,9 @@ /** * Document Index Processing Module * - * Handles index field containers and keeps section break accounting aligned - * with the paragraph flow inside the index. + * Index field containers convert their child paragraphs to flow blocks via the + * shared paragraph-container handler, which keeps section-break accounting + * aligned with the paragraph flow inside the index. */ -import type { PMNode, NodeHandlerContext } from '../types.js'; -import { createSectionBreakBlock, hasIntrinsicBoundarySignals, shouldRequirePageBoundary } from '../sections/index.js'; - -/** - * Extracts child nodes from an index node. - * - * Handles both array-based content (plain objects) and ProseMirror Fragment-like - * content (which uses forEach instead of array iteration). - * - * @param node - The index node to extract children from - * @returns Array of child nodes, or empty array if no children - */ -const getIndexChildren = (node: PMNode): PMNode[] => { - if (Array.isArray(node.content)) return node.content; - const content = node.content as { forEach?: (cb: (child: PMNode) => void) => void } | undefined; - if (content && typeof content.forEach === 'function') { - const children: PMNode[] = []; - content.forEach((child) => { - children.push(child); - }); - return children; - } - return []; -}; - -/** - * Handle index nodes by converting child paragraphs to flow blocks. - * - * @param node - Index node to process - * @param context - Shared handler context - */ -export function handleIndexNode(node: PMNode, context: NodeHandlerContext): void { - const children = getIndexChildren(node); - if (children.length === 0) return; - - const { - blocks, - recordBlockKind, - nextBlockId, - positions, - trackedChangesConfig, - bookmarks, - hyperlinkConfig, - sectionState, - converters, - themeColors, - enableComments, - } = context; - - const paragraphToFlowBlocks = converters.paragraphToFlowBlocks; - - children.forEach((child) => { - if (child.type !== 'paragraph') { - return; - } - - if ((sectionState?.ranges?.length ?? 0) > 0) { - const nextSection = sectionState!.ranges[sectionState!.currentSectionIndex + 1]; - if (nextSection && sectionState!.currentParagraphIndex === nextSection.startParagraphIndex) { - const currentSection = sectionState!.ranges[sectionState!.currentSectionIndex]; - const requiresPageBoundary = - shouldRequirePageBoundary(currentSection, nextSection) || hasIntrinsicBoundarySignals(nextSection); - const extraAttrs = requiresPageBoundary ? { requirePageBoundary: true } : undefined; - const sectionBreak = createSectionBreakBlock(nextSection, nextBlockId, extraAttrs); - blocks.push(sectionBreak); - recordBlockKind?.(sectionBreak.kind); - sectionState!.currentSectionIndex++; - } - } - - const paragraphBlocks = paragraphToFlowBlocks({ - para: child, - nextBlockId, - positions, - trackedChangesConfig, - bookmarks, - hyperlinkConfig, - themeColors, - converterContext: context.converterContext, - enableComments: enableComments, - converters, - }); - - paragraphBlocks.forEach((block) => { - blocks.push(block); - recordBlockKind?.(block.kind); - }); - - sectionState!.currentParagraphIndex++; - }); -} +export { handleParagraphContainerNode as handleIndexNode } from './paragraph-container.js'; diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-part-object.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-part-object.test.ts index 4f91220885..f67569bc7e 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-part-object.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-part-object.test.ts @@ -702,5 +702,84 @@ describe('document-part-object', () => { expect(callArgs[2]).toMatchObject({ sectionState: state }); }); }); + + // ==================== SD-3005: block field children ==================== + describe('block field children (SD-3005)', () => { + beforeEach(() => { + vi.mocked(metadataModule.getDocPartGallery).mockReturnValue('Bibliographies'); + vi.mocked(metadataModule.getDocPartObjectId).mockReturnValue('bib-1'); + vi.mocked(metadataModule.getNodeInstruction).mockReturnValue(undefined); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue({ type: 'docPartObject' }); + }); + + const bibliography = (): PMNode => + ({ + type: 'bibliography', + attrs: { instruction: 'BIBLIOGRAPHY' }, + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'Entry One' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'Entry Two' }] }, + ], + }) as unknown as PMNode; + + // Minimal section state with a far-away boundary so no break is emitted β€” + // these tests only assert rendering + the paragraph-index counter. + const withSectionState = () => { + mockContext.sectionState = { + ranges: [{ sectionIndex: 0, startParagraphIndex: 0, endParagraphIndex: 99 }], + currentSectionIndex: 0, + currentParagraphIndex: 0, + } as unknown as NonNullable; + }; + + it('renders a direct bibliography child (heading + both entries become blocks)', () => { + withSectionState(); + const node: PMNode = { + type: 'documentPartObject', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Bibliography' }] }, bibliography()], + }; + + handleDocumentPartObjectNode(node, mockContext); + + // heading paragraph + 2 bibliography entry paragraphs + expect(mockParagraphConverter).toHaveBeenCalledTimes(3); + expect(mockContext.blocks).toHaveLength(3); + }); + + it('advances currentParagraphIndex through a bibliography child to match findParagraphsWithSectPr', () => { + // findParagraphsWithSectPr recurses `bibliography`, so the handler must + // advance the counter per entry or section breaks downstream drift. + withSectionState(); + const node: PMNode = { + type: 'documentPartObject', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Bibliography' }] }, bibliography()], + }; + + handleDocumentPartObjectNode(node, mockContext); + + // heading (1) + Entry One (1) + Entry Two (1) = 3 paragraphs counted + expect(mockContext.sectionState!.currentParagraphIndex).toBe(3); + }); + + it('renders a structuredContentBlock-wrapped bibliography without advancing the counter', () => { + // findParagraphsWithSectPr does NOT recurse structuredContentBlock, so its + // inner paragraphs render but must not advance currentParagraphIndex. + withSectionState(); + const node: PMNode = { + type: 'documentPartObject', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'Bibliography' }] }, + { type: 'structuredContentBlock', attrs: {}, content: [bibliography()] }, + ], + }; + + handleDocumentPartObjectNode(node, mockContext); + + // both entries still render + expect(mockParagraphConverter).toHaveBeenCalledTimes(3); // heading + 2 entries + // but only the heading advanced the counter (scb is not recursed by analysis) + expect(mockContext.sectionState!.currentParagraphIndex).toBe(1); + }); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-part-object.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-part-object.ts index eb9106f630..2263cb4801 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-part-object.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/document-part-object.ts @@ -9,6 +9,13 @@ import type { PMNode, NodeHandlerContext } from '../types.js'; import { emitPendingSectionBreakForParagraph } from '../sections/index.js'; import { getDocPartGallery, getDocPartObjectId, getNodeInstruction, resolveNodeSdtMetadata } from './metadata.js'; import { processTocChildren } from './toc.js'; +import { handleParagraphContainerNode } from './paragraph-container.js'; +import { handleStructuredContentBlockNode } from './structured-content-block.js'; + +// Block field children whose paragraphs `findParagraphsWithSectPr` recurses into, +// so their handler must advance currentParagraphIndex in step (delegated to +// handleParagraphContainerNode). +const PARAGRAPH_CONTAINER_TYPES = new Set(['bibliography', 'index', 'tableOfAuthorities']); /** * Handle document part object nodes (e.g., TOC galleries, page numbers). @@ -115,9 +122,18 @@ export function handleDocumentPartObjectNode(node: PMNode, context: NodeHandlerC }; const output = { blocks, recordBlockKind }; processTocChildren(child.content, metadata, tocContext, output); + } else if (PARAGRAPH_CONTAINER_TYPES.has(child.type)) { + // SD-3005: a block field (bibliography / index / table of authorities) + // generated inside this SDT. Render its entry paragraphs and advance + // currentParagraphIndex per child to match findParagraphsWithSectPr, + // which recurses into these node types. + handleParagraphContainerNode(child, context); + } else if (child.type === 'structuredContentBlock') { + // SD-3005: a nested content control (often wrapping a block field). + // findParagraphsWithSectPr does NOT recurse structuredContentBlock, so + // its handler renders without advancing currentParagraphIndex. + handleStructuredContentBlockNode(child, context); } } } - // Note: Other documentPartObject types (e.g., Bibliography) are intentionally - // not processed - they are ignored to maintain backward compatibility. } diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/paragraph-container.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/paragraph-container.test.ts new file mode 100644 index 0000000000..fe7cd34d88 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/paragraph-container.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi } from 'vitest'; +import { getParagraphContainerChildren, handleParagraphContainerNode } from './paragraph-container.js'; +import { handleIndexNode } from './document-index.js'; +import { handleBibliographyNode } from './bibliography.js'; +import { handleTableOfAuthoritiesNode } from './table-of-authorities.js'; +import type { PMNode, NodeHandlerContext } from '../types.js'; + +describe('getParagraphContainerChildren', () => { + it('reads array-based content', () => { + const node = { type: 'index', content: [{ type: 'paragraph' }, { type: 'paragraph' }] } as unknown as PMNode; + expect(getParagraphContainerChildren(node)).toHaveLength(2); + }); + + it('reads ProseMirror Fragment-like content via forEach', () => { + const kids = [{ type: 'paragraph' }]; + const node = { + type: 'index', + content: { forEach: (cb: (c: unknown) => void) => kids.forEach(cb) }, + } as unknown as PMNode; + expect(getParagraphContainerChildren(node)).toEqual(kids); + }); + + it('returns an empty array when there is no content', () => { + expect(getParagraphContainerChildren({ type: 'index' } as unknown as PMNode)).toEqual([]); + }); +}); + +describe('handleParagraphContainerNode', () => { + const makeContext = () => { + const blocks: unknown[] = []; + const paragraphToFlowBlocks = vi.fn(({ para }: { para: PMNode }) => [ + { kind: 'paragraph', text: (para as { text?: string }).text }, + ]); + const context = { + blocks, + recordBlockKind: vi.fn(), + nextBlockId: vi.fn(() => 'b1'), + positions: {}, + trackedChangesConfig: undefined, + bookmarks: undefined, + hyperlinkConfig: undefined, + sectionState: { ranges: [], currentSectionIndex: 0, currentParagraphIndex: 0 }, + converters: { paragraphToFlowBlocks }, + themeColors: undefined, + enableComments: false, + converterContext: {}, + } as unknown as NodeHandlerContext; + return { context, blocks, paragraphToFlowBlocks }; + }; + + it('converts each child paragraph to flow blocks and advances the paragraph counter', () => { + const { context, blocks, paragraphToFlowBlocks } = makeContext(); + const node = { + type: 'index', + content: [ + { type: 'paragraph', text: 'a' }, + { type: 'paragraph', text: 'b' }, + { type: 'someAtom', text: 'skip' }, + ], + } as unknown as PMNode; + + handleParagraphContainerNode(node, context); + + expect(paragraphToFlowBlocks).toHaveBeenCalledTimes(2); + expect(blocks).toHaveLength(2); + expect((context.sectionState as { currentParagraphIndex: number }).currentParagraphIndex).toBe(2); + }); + + it('is the single implementation shared by the three block-field handlers', () => { + expect(handleIndexNode).toBe(handleParagraphContainerNode); + expect(handleBibliographyNode).toBe(handleParagraphContainerNode); + expect(handleTableOfAuthoritiesNode).toBe(handleParagraphContainerNode); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/paragraph-container.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/paragraph-container.ts new file mode 100644 index 0000000000..11f258be7d --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/paragraph-container.ts @@ -0,0 +1,99 @@ +/** + * Paragraph-Container Field Module + * + * Shared handler for block field containers whose children are paragraphs: + * bibliography, document index, and table of authorities. Each converts its + * child paragraphs to flow blocks while keeping section-break accounting + * aligned with the paragraph flow. The three previously hand-rolled identical + * copies of this loop; they now delegate here. + */ + +import type { PMNode, NodeHandlerContext } from '../types.js'; +import { createSectionBreakBlock, hasIntrinsicBoundarySignals, shouldRequirePageBoundary } from '../sections/index.js'; + +/** + * Extract child nodes from a paragraph-container node. + * + * Handles both array-based content (plain objects) and ProseMirror + * Fragment-like content (which uses forEach instead of array iteration). + * + * @param node - The container node to extract children from + * @returns Array of child nodes, or empty array if no children + */ +export const getParagraphContainerChildren = (node: PMNode): PMNode[] => { + if (Array.isArray(node.content)) return node.content; + const content = node.content as { forEach?: (cb: (child: PMNode) => void) => void } | undefined; + if (content && typeof content.forEach === 'function') { + const children: PMNode[] = []; + content.forEach((child) => children.push(child)); + return children; + } + return []; +}; + +/** + * Convert a paragraph-container field's child paragraphs to flow blocks, + * emitting pending section breaks and advancing the section-break paragraph + * counter as it goes. + * + * @param node - The container node (bibliography / index / tableOfAuthorities) + * @param context - Shared handler context + */ +export function handleParagraphContainerNode(node: PMNode, context: NodeHandlerContext): void { + const children = getParagraphContainerChildren(node); + if (children.length === 0) return; + + const { + blocks, + recordBlockKind, + nextBlockId, + positions, + trackedChangesConfig, + bookmarks, + hyperlinkConfig, + sectionState, + converters, + themeColors, + enableComments, + } = context; + + const paragraphToFlowBlocks = converters.paragraphToFlowBlocks; + + children.forEach((child) => { + if (child.type !== 'paragraph') return; + + if ((sectionState?.ranges?.length ?? 0) > 0) { + const nextSection = sectionState!.ranges[sectionState!.currentSectionIndex + 1]; + if (nextSection && sectionState!.currentParagraphIndex === nextSection.startParagraphIndex) { + const currentSection = sectionState!.ranges[sectionState!.currentSectionIndex]; + const requiresPageBoundary = + shouldRequirePageBoundary(currentSection, nextSection) || hasIntrinsicBoundarySignals(nextSection); + const extraAttrs = requiresPageBoundary ? { requirePageBoundary: true } : undefined; + const sectionBreak = createSectionBreakBlock(nextSection, nextBlockId, extraAttrs); + blocks.push(sectionBreak); + recordBlockKind?.(sectionBreak.kind); + sectionState!.currentSectionIndex++; + } + } + + const paragraphBlocks = paragraphToFlowBlocks({ + para: child, + nextBlockId, + positions, + trackedChangesConfig, + bookmarks, + hyperlinkConfig, + themeColors, + converterContext: context.converterContext, + enableComments, + converters, + }); + + paragraphBlocks.forEach((block) => { + blocks.push(block); + recordBlockKind?.(block.kind); + }); + + sectionState!.currentParagraphIndex++; + }); +} diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/structured-content-block.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/structured-content-block.ts index 86f4efac22..4fb2c5f7f7 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/structured-content-block.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/structured-content-block.ts @@ -227,7 +227,18 @@ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHand } return; } - if (child.type === 'documentPartObject' && Array.isArray(child.content)) { + // SD-1333: documentPartObject is a transparent wrapper - recurse its content. + // SD-3005: a block field (bibliography / index / table of authorities) generated + // inside this content control is likewise transparent here; render its entry + // paragraphs without advancing currentParagraphIndex, since + // findParagraphsWithSectPr does not recurse structuredContentBlock. + if ( + Array.isArray(child.content) && + (child.type === 'documentPartObject' || + child.type === 'bibliography' || + child.type === 'index' || + child.type === 'tableOfAuthorities') + ) { child.content.forEach(visitChild); } }; diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/table-of-authorities.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/table-of-authorities.ts index dd4e162a4f..86e6215fdd 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/table-of-authorities.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sdt/table-of-authorities.ts @@ -1,79 +1,8 @@ /** * Table of Authorities Processing Module * - * Handles TOA field containers by converting child paragraphs to flow blocks. - * Follows the same pattern as document-index.ts. + * Table-of-authorities field containers convert their child paragraphs to flow + * blocks via the shared paragraph-container handler. */ -import type { PMNode, NodeHandlerContext } from '../types.js'; -import { createSectionBreakBlock, hasIntrinsicBoundarySignals, shouldRequirePageBoundary } from '../sections/index.js'; - -const getChildren = (node: PMNode): PMNode[] => { - if (Array.isArray(node.content)) return node.content; - const content = node.content as { forEach?: (cb: (child: PMNode) => void) => void } | undefined; - if (content && typeof content.forEach === 'function') { - const children: PMNode[] = []; - content.forEach((child) => children.push(child)); - return children; - } - return []; -}; - -export function handleTableOfAuthoritiesNode(node: PMNode, context: NodeHandlerContext): void { - const children = getChildren(node); - if (children.length === 0) return; - - const { - blocks, - recordBlockKind, - nextBlockId, - positions, - trackedChangesConfig, - bookmarks, - hyperlinkConfig, - sectionState, - converters, - themeColors, - enableComments, - } = context; - - const paragraphToFlowBlocks = converters.paragraphToFlowBlocks; - - children.forEach((child) => { - if (child.type !== 'paragraph') return; - - if ((sectionState?.ranges?.length ?? 0) > 0) { - const nextSection = sectionState!.ranges[sectionState!.currentSectionIndex + 1]; - if (nextSection && sectionState!.currentParagraphIndex === nextSection.startParagraphIndex) { - const currentSection = sectionState!.ranges[sectionState!.currentSectionIndex]; - const requiresPageBoundary = - shouldRequirePageBoundary(currentSection, nextSection) || hasIntrinsicBoundarySignals(nextSection); - const extraAttrs = requiresPageBoundary ? { requirePageBoundary: true } : undefined; - const sectionBreak = createSectionBreakBlock(nextSection, nextBlockId, extraAttrs); - blocks.push(sectionBreak); - recordBlockKind?.(sectionBreak.kind); - sectionState!.currentSectionIndex++; - } - } - - const paragraphBlocks = paragraphToFlowBlocks({ - para: child, - nextBlockId, - positions, - trackedChangesConfig, - bookmarks, - hyperlinkConfig, - themeColors, - converterContext: context.converterContext, - enableComments, - converters, - }); - - paragraphBlocks.forEach((block) => { - blocks.push(block); - recordBlockKind?.(block.kind); - }); - - sectionState!.currentParagraphIndex++; - }); -} +export { handleParagraphContainerNode as handleTableOfAuthoritiesNode } from './paragraph-container.js'; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/bibliography-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/bibliography-preprocessor.js index 2228653d51..37b336f579 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/bibliography-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/bibliography-preprocessor.js @@ -1,32 +1,16 @@ +import { buildBlockFieldNode } from './build-block-field-node.js'; + /** * Processes a BIBLIOGRAPHY instruction and creates an `sd:bibliography` node. * - * BIBLIOGRAPHY syntax: BIBLIOGRAPHY (no switches) + * BIBLIOGRAPHY syntax: BIBLIOGRAPHY (with optional switches like `\l 1033`) * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. + * @param {import('../../v2/docxHelper').ParsedDocx} [_docx] The docx object (unused). + * @param {Array<{type: string, text?: string}>} [instructionTokens] Raw instruction tokens. * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessBibliographyInstruction(nodesToCombine, instrText) { - const contentNodes = - Array.isArray(nodesToCombine) && nodesToCombine.length > 0 - ? nodesToCombine - : [ - { - name: 'w:p', - type: 'element', - elements: [], - }, - ]; - - return [ - { - name: 'sd:bibliography', - type: 'element', - attributes: { - instruction: instrText, - }, - elements: contentNodes, - }, - ]; +export function preProcessBibliographyInstruction(nodesToCombine, instrText, _docx, instructionTokens = null) { + return buildBlockFieldNode('sd:bibliography', nodesToCombine, instrText, instructionTokens); } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/bibliography-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/bibliography-preprocessor.test.js index e269245ab8..3bd0b264c7 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/bibliography-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/bibliography-preprocessor.test.js @@ -22,4 +22,67 @@ describe('preProcessBibliographyInstruction', () => { }, ]); }); + + it('wraps loose runs in a synthesized paragraph (single-paragraph field)', () => { + // SD-3005: When the entire BIBLIOGRAPHY envelope lives inside one , the + // collected after-separate nodes are runs, not paragraphs. The + // bibliography PM node declares `content: 'paragraph+'`, so emitting loose + // runs as direct children crashes the schema. The preprocessor must group + // adjacent inline nodes into a synthesized . + const r1 = { + name: 'w:r', + type: 'element', + elements: [{ name: 'w:t', elements: [{ text: 'Smith, J. (2024). ' }] }], + }; + const r2 = { name: 'w:r', type: 'element', elements: [{ name: 'w:t', elements: [{ text: 'Document Formats.' }] }] }; + + const result = preProcessBibliographyInstruction([r1, r2], 'BIBLIOGRAPHY \\l 1033 '); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('sd:bibliography'); + expect(result[0].elements).toEqual([{ name: 'w:p', type: 'element', elements: [r1, r2] }]); + }); + + it('preserves existing w:p children as-is (multi-paragraph field)', () => { + const p1 = { name: 'w:p', type: 'element', elements: [{ name: 'w:r', elements: [] }] }; + const p2 = { name: 'w:p', type: 'element', elements: [{ name: 'w:r', elements: [] }] }; + + const result = preProcessBibliographyInstruction([p1, p2], 'BIBLIOGRAPHY'); + + expect(result[0].elements).toEqual([p1, p2]); + }); + + it('groups runs around paragraphs without merging them (mixed content)', () => { + const leadingRun = { name: 'w:r', type: 'element', elements: [] }; + const para = { name: 'w:p', type: 'element', elements: [] }; + const trailingRun = { name: 'w:r', type: 'element', elements: [] }; + + const result = preProcessBibliographyInstruction([leadingRun, para, trailingRun], 'BIBLIOGRAPHY'); + + expect(result[0].elements).toEqual([ + { name: 'w:p', type: 'element', elements: [leadingRun] }, + para, + { name: 'w:p', type: 'element', elements: [trailingRun] }, + ]); + }); + + it('preserves instructionTokens so split instructions round-trip (SD-3066)', () => { + // Parity with index/toa: a BIBLIOGRAPHY instruction split across runs + // (e.g. 'BIBLIOGRAPHY ' + '\\l 1033 ') must keep its raw fragments so the + // exporter can rebuild the original runs instead of collapsing to one. + const instructionTokens = [ + { type: 'text', text: 'BIBLIOGRAPHY ' }, + { type: 'text', text: '\\l 1033 ' }, + ]; + + const result = preProcessBibliographyInstruction([], 'BIBLIOGRAPHY \\l 1033', null, instructionTokens); + + expect(result[0].attributes.instructionTokens).toEqual(instructionTokens); + }); + + it('omits instructionTokens when none are provided', () => { + const result = preProcessBibliographyInstruction([], 'BIBLIOGRAPHY'); + + expect(result[0].attributes).not.toHaveProperty('instructionTokens'); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/build-block-field-node.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/build-block-field-node.js new file mode 100644 index 0000000000..c80fd77355 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/build-block-field-node.js @@ -0,0 +1,32 @@ +import { normalizeFieldContentToParagraphs } from './normalize-field-content.js'; + +/** + * Build a block-level field node (`sd:bibliography`, `sd:index`, + * `sd:tableOfAuthorities`) from the runs a complex field collected between its + * `separate` and `end` fldChars. + * + * These three fields share one shape: an `sd:*` element carrying the raw + * instruction (plus its token fragments, when the instruction was split across + * runs) whose children are the field's generated paragraphs. The result is + * normalized so loose inline runs are wrapped into paragraphs, satisfying the + * `paragraph+` PM schema (see normalize-field-content / SD-3005). + * + * @param {string} xmlName The `sd:*` element name to emit. + * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The collected result nodes. + * @param {string} instrText The field instruction text. + * @param {Array<{type: string, text?: string}> | null} [instructionTokens] Raw instruction-run fragments. + * @returns {import('../../v2/types/index.js').OpenXmlNode[]} + */ +export function buildBlockFieldNode(xmlName, nodesToCombine, instrText, instructionTokens = null) { + return [ + { + name: xmlName, + type: 'element', + attributes: { + instruction: instrText, + ...(instructionTokens ? { instructionTokens } : {}), + }, + elements: normalizeFieldContentToParagraphs(nodesToCombine), + }, + ]; +} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/build-block-field-node.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/build-block-field-node.test.js new file mode 100644 index 0000000000..d6a944584a --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/build-block-field-node.test.js @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { buildBlockFieldNode } from './build-block-field-node.js'; + +describe('buildBlockFieldNode', () => { + it('emits the given sd:* element with the instruction and normalized paragraphs', () => { + const run = { name: 'w:r', type: 'element', elements: [] }; + + const result = buildBlockFieldNode('sd:index', [run], 'INDEX \\c 2'); + + expect(result).toEqual([ + { + name: 'sd:index', + type: 'element', + attributes: { instruction: 'INDEX \\c 2' }, + elements: [{ name: 'w:p', type: 'element', elements: [run] }], + }, + ]); + }); + + it('includes instructionTokens only when provided', () => { + const tokens = [{ type: 'text', text: 'INDEX ' }, { type: 'tab' }]; + + const withTokens = buildBlockFieldNode('sd:tableOfAuthorities', [], 'INDEX', tokens); + expect(withTokens[0].attributes.instructionTokens).toEqual(tokens); + + const withoutTokens = buildBlockFieldNode('sd:tableOfAuthorities', [], 'INDEX'); + expect(withoutTokens[0].attributes).not.toHaveProperty('instructionTokens'); + }); + + it('synthesizes an empty paragraph when there is no content', () => { + const result = buildBlockFieldNode('sd:bibliography', [], 'BIBLIOGRAPHY'); + + expect(result[0].elements).toEqual([{ name: 'w:p', type: 'element', elements: [] }]); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.js index cad5064bf8..5e0bcbaf1d 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.js @@ -1,3 +1,5 @@ +import { buildBlockFieldNode } from './build-block-field-node.js'; + /** * Processes an INDEX instruction and creates an `sd:index` node. * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. @@ -7,15 +9,5 @@ * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ export function preProcessIndexInstruction(nodesToCombine, instrText, _docx, instructionTokens = null) { - return [ - { - name: 'sd:index', - type: 'element', - attributes: { - instruction: instrText, - ...(instructionTokens ? { instructionTokens } : {}), - }, - elements: nodesToCombine, - }, - ]; + return buildBlockFieldNode('sd:index', nodesToCombine, instrText, instructionTokens); } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.test.js index 8d24db968e..3d52a8c6ae 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.test.js @@ -3,7 +3,7 @@ import { preProcessIndexInstruction } from './index-preprocessor.js'; describe('preProcessIndexInstruction', () => { it('creates sd:index node with instruction attribute', () => { - const nodesToCombine = [{ name: 'w:p', elements: [] }]; + const nodesToCombine = [{ name: 'w:p', type: 'element', elements: [] }]; const instrText = 'INDEX \\e "\\t" \\h "A"'; const result = preProcessIndexInstruction(nodesToCombine, instrText); @@ -12,7 +12,7 @@ describe('preProcessIndexInstruction', () => { expect(result[0].name).toBe('sd:index'); expect(result[0].type).toBe('element'); expect(result[0].attributes.instruction).toBe(instrText); - expect(result[0].elements).toBe(nodesToCombine); + expect(result[0].elements).toEqual(nodesToCombine); }); it('includes instructionTokens when provided', () => { @@ -37,10 +37,25 @@ describe('preProcessIndexInstruction', () => { expect(result[0].attributes).not.toHaveProperty('instructionTokens'); }); - it('handles empty nodesToCombine', () => { + it('synthesizes an empty paragraph when the field has no rendered content', () => { + // SD-3005: PM `index` schema requires `paragraph+`. An empty result must + // still produce at least one paragraph child. const result = preProcessIndexInstruction([], 'INDEX \\h'); - expect(result[0].elements).toEqual([]); + expect(result[0].elements).toEqual([{ name: 'w:p', type: 'element', elements: [] }]); + }); + + it('wraps loose runs in a synthesized paragraph (single-paragraph field)', () => { + // SD-3005 / SD-3017: same crash class as bibliography when the INDEX + // envelope sits inside one . + const r1 = { name: 'w:r', type: 'element', elements: [{ name: 'w:t', elements: [{ text: 'apple, 3' }] }] }; + const r2 = { name: 'w:r', type: 'element', elements: [{ name: 'w:t', elements: [{ text: 'banana, 5' }] }] }; + + const result = preProcessIndexInstruction([r1, r2], 'INDEX \\c "2"'); + + expect(result[0].elements).toEqual([ + { name: 'w:p', type: 'element', elements: [r1, r2] }, + ]); }); it('preserves complex instruction text with switches', () => { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/normalize-field-content.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/normalize-field-content.js new file mode 100644 index 0000000000..fb98977263 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/normalize-field-content.js @@ -0,0 +1,49 @@ +/** + * Normalize the result-content nodes of a field code so they can be placed + * inside a PM node whose schema requires `paragraph+`. + * + * Field-code preprocessors (BIBLIOGRAPHY, INDEX, TOA) wrap whatever + * `nodesToCombine` they receive from `preProcessNodesForFldChar`. When the + * field envelope spans multiple paragraphs the collected nodes are s + * and everything is fine. When the envelope lives inside a single + * paragraph the collected nodes are loose runs (or other inline + * elements). Wrapping those directly violates the PM schema and crashes + * the editor on import β€” see SD-3005. + * + * This helper groups adjacent non- nodes into synthesized paragraphs + * and preserves any existing nodes as-is. An empty input yields a + * single empty paragraph so the downstream PM schema (`paragraph+`) is + * always satisfied. + * + * @param {import('../../v2/types/index.js').OpenXmlNode[] | null | undefined} nodes + * @returns {import('../../v2/types/index.js').OpenXmlNode[]} + */ +export function normalizeFieldContentToParagraphs(nodes) { + const input = Array.isArray(nodes) ? nodes : []; + if (input.length === 0) { + return [{ name: 'w:p', type: 'element', elements: [] }]; + } + + const out = []; + let buffer = null; + + const flushBuffer = () => { + if (buffer) { + out.push({ name: 'w:p', type: 'element', elements: buffer }); + buffer = null; + } + }; + + for (const node of input) { + if (node?.name === 'w:p') { + flushBuffer(); + out.push(node); + } else { + if (!buffer) buffer = []; + buffer.push(node); + } + } + flushBuffer(); + + return out; +} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toa-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toa-preprocessor.js index 81349bd025..d6548547dd 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toa-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toa-preprocessor.js @@ -1,3 +1,5 @@ +import { buildBlockFieldNode } from './build-block-field-node.js'; + /** * Processes a TOA (Table of Authorities) instruction and creates an `sd:tableOfAuthorities` node. * @@ -10,15 +12,5 @@ * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ export function preProcessToaInstruction(nodesToCombine, instrText, _docx, instructionTokens = null) { - return [ - { - name: 'sd:tableOfAuthorities', - type: 'element', - attributes: { - instruction: instrText, - ...(instructionTokens ? { instructionTokens } : {}), - }, - elements: nodesToCombine, - }, - ]; + return buildBlockFieldNode('sd:tableOfAuthorities', nodesToCombine, instrText, instructionTokens); } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toa-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toa-preprocessor.test.js new file mode 100644 index 0000000000..ad1657c030 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toa-preprocessor.test.js @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { preProcessToaInstruction } from './toa-preprocessor.js'; + +describe('preProcessToaInstruction', () => { + it('creates sd:tableOfAuthorities node with instruction attribute', () => { + const nodesToCombine = [{ name: 'w:p', type: 'element', elements: [] }]; + const instrText = 'TOA \\h \\c "1"'; + + const result = preProcessToaInstruction(nodesToCombine, instrText); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('sd:tableOfAuthorities'); + expect(result[0].type).toBe('element'); + expect(result[0].attributes.instruction).toBe(instrText); + expect(result[0].elements).toEqual(nodesToCombine); + }); + + it('includes instructionTokens when provided', () => { + const tokens = [{ type: 'text', text: 'TOA \\h \\c "' }, { type: 'text', text: '1"' }]; + + const result = preProcessToaInstruction([], 'TOA \\h \\c "1"', null, tokens); + + expect(result[0].attributes.instructionTokens).toEqual(tokens); + }); + + it('omits instructionTokens when undefined', () => { + const result = preProcessToaInstruction([], 'TOA'); + + expect(result[0].attributes).not.toHaveProperty('instructionTokens'); + }); + + it('synthesizes an empty paragraph when the field has no rendered content', () => { + // SD-3005: PM `tableOfAuthorities` schema requires `paragraph+`. + const result = preProcessToaInstruction([], 'TOA \\h'); + + expect(result[0].elements).toEqual([{ name: 'w:p', type: 'element', elements: [] }]); + }); + + it('wraps loose runs in a synthesized paragraph (single-paragraph field)', () => { + // SD-3005: same crash class β€” TOA envelopes can also sit inside one . + const r1 = { + name: 'w:r', + type: 'element', + elements: [{ name: 'w:t', elements: [{ text: 'Smith v. Jones, 1 U.S. 1 (1900)' }] }], + }; + + const result = preProcessToaInstruction([r1], 'TOA \\h \\c "1"'); + + expect(result[0].elements).toEqual([{ name: 'w:p', type: 'element', elements: [r1] }]); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js index 82d19e5e2d..613083e125 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js @@ -178,7 +178,13 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { currentField.instructionTokens.push(...instructionTokens); const instrTextValue = instrTextEl?.elements?.[0]?.text; if (instrTextValue != null) { - currentField.instrText += `${instrTextValue} `; + // SD-3066: join instrText fragments verbatim. Word preserves the + // literal spaces inside each run, so an instruction split across + // runs (e.g. ' XE "' + 'Building Standard' + '" ') already carries + // its own separators. Injecting a space per fragment corrupted the + // entry text to 'XE " Building Standard "'. The leading/trailing + // whitespace is trimmed by finalizeField via `instrText.trim()`. + currentField.instrText += `${instrTextValue}`; } if (instructionTokens.some((token) => token.type === 'tab')) { currentField.instrText += '\t'; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js index 568ff5efd2..80c551a89f 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js @@ -683,7 +683,11 @@ describe('preProcessNodesForFldChar', () => { { nodes: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'link text' }] }] }], fieldInfo: { - instrText: 'HYPERLINK "http://example.com" ', + // SD-3066: verbatim concatenation of the two instrText runs + // ('HYPERLINK "http://example.com"' + ' ') is a single trailing + // space. The previous expectation of three spaces reflected the + // old per-fragment injected separator, not the literal source text. + instrText: 'HYPERLINK "http://example.com" ', instructionTokens: [ { type: 'text', text: 'HYPERLINK "http://example.com"' }, { type: 'text', text: ' ' }, @@ -746,6 +750,63 @@ describe('preProcessNodesForFldChar', () => { expect(processedNodes[0].attributes.instruction).toBe('XE "Term"'); }); + it('processes fldSimple INDEX fields, wrapping loose result runs in a paragraph (SD-3066)', () => { + // The ticket flags w:fldSimple as a primary INDEX signal. A fldSimple INDEX + // carries its generated entries as loose runs; the index PM node requires + // `paragraph+`, so the preprocessor must wrap them (normalizeFieldContentToParagraphs, + // the SD-3005 fix). This guards both the fldSimple dispatch and that wrapping. + const nodes = [ + { + name: 'w:fldSimple', + attributes: { 'w:instr': 'INDEX \\c 2' }, + elements: [ + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'apple, 3' }] }] }, + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'banana, 5' }] }] }, + ], + }, + ]; + + const { processedNodes } = preProcessNodesForFldChar(nodes, mockDocx); + expect(processedNodes).toHaveLength(1); + expect(processedNodes[0].name).toBe('sd:index'); + expect(processedNodes[0].attributes.instruction).toBe('INDEX \\c 2'); + // Loose runs wrapped into a single paragraph so the PM `paragraph+` schema holds. + expect(processedNodes[0].elements).toHaveLength(1); + expect(processedNodes[0].elements[0].name).toBe('w:p'); + expect(processedNodes[0].elements[0].elements).toHaveLength(2); + }); + + it('joins instruction text split across multiple instrText runs verbatim (SD-3066)', () => { + // Word commonly splits an XE instruction across runs, with the literal + // spaces preserved inside each run: ' XE "' + 'Building Standard' + '" '. + // The aggregated instruction must reconstruct the literal string, not + // inject a separator space per fragment (which produced + // 'XE " Building Standard "' with spurious internal spaces). + const nodes = [ + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }] }, + { + name: 'w:r', + elements: [ + { name: 'w:instrText', attributes: { 'xml:space': 'preserve' }, elements: [{ type: 'text', text: ' XE "' }] }, + ], + }, + { name: 'w:r', elements: [{ name: 'w:instrText', elements: [{ type: 'text', text: 'Building Standard' }] }] }, + { + name: 'w:r', + elements: [ + { name: 'w:instrText', attributes: { 'xml:space': 'preserve' }, elements: [{ type: 'text', text: '" ' }] }, + ], + }, + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }] }, + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }] }, + ]; + + const { processedNodes } = preProcessNodesForFldChar(nodes, mockDocx); + expect(processedNodes).toHaveLength(1); + expect(processedNodes[0].name).toBe('sd:indexEntry'); + expect(processedNodes[0].attributes.instruction).toBe('XE "Building Standard"'); + }); + it('passes field-sequence rPr into body NUMWORDS fields when cached-result runs have no styling', () => { const nodes = [ { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js index 9637bd7e97..239825d532 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js @@ -33,6 +33,7 @@ import { tableNodeHandlerEntity } from './tableImporter.js'; import { tableOfContentsHandlerEntity } from './tableOfContentsImporter.js'; import { indexHandlerEntity, indexEntryHandlerEntity } from './indexImporter.js'; import { bibliographyHandlerEntity } from './bibliographyImporter.js'; +import { tableOfAuthoritiesHandlerEntity } from './tableOfAuthoritiesImporter.js'; import { preProcessNodesForFldChar } from '../../field-references'; import { preProcessPageFieldsOnly } from '../../field-references/preProcessPageFieldsOnly.js'; import { ensureNumberingCache } from './numberingCache.js'; @@ -344,6 +345,7 @@ export const defaultNodeListHandler = () => { tableOfContentsHandlerEntity, indexHandlerEntity, bibliographyHandlerEntity, + tableOfAuthoritiesHandlerEntity, indexEntryHandlerEntity, autoPageHandlerEntity, autoTotalPageCountEntity, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/paragraphNodeImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/paragraphNodeImporter.js index 514ea0faa3..f0544c92a9 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/paragraphNodeImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/paragraphNodeImporter.js @@ -1,12 +1,61 @@ // @ts-check import { translator as wPNodeTranslator } from '../../v3/handlers/w/p/index.js'; +import { BLOCK_FIELD_XML_NAMES } from '../../v3/handlers/sd/shared/block-field-xml-names.js'; +import { carbonCopy } from '@core/utilities/carbonCopy.js'; const PARAGRAPH_PROPERTIES_XML_NAME = 'w:pPr'; -const BLOCK_FIELD_XML_NAMES = new Set(['sd:tableOfContents', 'sd:index', 'sd:bibliography', 'sd:tableOfAuthorities']); const hasMeaningfulParagraphContent = (elements = []) => elements.some((element) => element?.name && element.name !== PARAGRAPH_PROPERTIES_XML_NAME); +const findParagraphProperties = (elements = []) => + elements.find((element) => element?.name === PARAGRAPH_PROPERTIES_XML_NAME) ?? null; + +const hasParagraphProperties = (elements = []) => + elements.some((element) => element?.name === PARAGRAPH_PROPERTIES_XML_NAME); + +const cloneParagraphPropertiesForRenderedResult = (paragraphProperties) => { + const elements = (paragraphProperties.elements || []) + .filter((element) => element?.name !== 'w:sectPr') + .map((element) => carbonCopy(element)); + if (elements.length === 0) return null; + return { + ...carbonCopy(paragraphProperties), + elements, + }; +}; + +const inheritWrapperParagraphProperties = (blockFieldElement, paragraphProperties) => { + if (!paragraphProperties) return blockFieldElement; + + const fieldElements = Array.isArray(blockFieldElement?.elements) ? blockFieldElement.elements : []; + const firstParagraphIndex = fieldElements.findIndex((element) => element?.name === 'w:p'); + if (firstParagraphIndex < 0) return blockFieldElement; + + const firstParagraph = fieldElements[firstParagraphIndex]; + const firstParagraphElements = Array.isArray(firstParagraph.elements) ? firstParagraph.elements : []; + if (hasParagraphProperties(firstParagraphElements)) return blockFieldElement; + + const renderedParagraphProperties = cloneParagraphPropertiesForRenderedResult(paragraphProperties); + const inheritedFirstParagraph = { + ...firstParagraph, + elements: renderedParagraphProperties + ? [renderedParagraphProperties, ...firstParagraphElements] + : firstParagraphElements, + }; + + return { + ...blockFieldElement, + attributes: { + ...(blockFieldElement.attributes || {}), + wrapperParagraphProperties: carbonCopy(paragraphProperties), + }, + elements: fieldElements.map((element, index) => + index === firstParagraphIndex ? inheritedFirstParagraph : element, + ), + }; +}; + const hoistBlockFieldNodes = (params, paragraphNode) => { const paragraphElements = Array.isArray(paragraphNode?.elements) ? paragraphNode.elements : []; const blockFieldElements = paragraphElements.filter((element) => BLOCK_FIELD_XML_NAMES.has(element?.name)); @@ -14,6 +63,8 @@ const hoistBlockFieldNodes = (params, paragraphNode) => { const nodes = []; const remainingElements = paragraphElements.filter((element) => !BLOCK_FIELD_XML_NAMES.has(element?.name)); + const wrapperParagraphProperties = findParagraphProperties(remainingElements); + const shouldTransferWrapperProperties = !hasMeaningfulParagraphContent(remainingElements); if (hasMeaningfulParagraphContent(remainingElements)) { const paragraph = wPNodeTranslator.encode({ @@ -31,10 +82,13 @@ const hoistBlockFieldNodes = (params, paragraphNode) => { } blockFieldElements.forEach((blockFieldElement) => { + const fieldElement = shouldTransferWrapperProperties + ? inheritWrapperParagraphProperties(blockFieldElement, wrapperParagraphProperties) + : blockFieldElement; nodes.push( ...params.nodeListHandler.handler({ ...params, - nodes: [blockFieldElement], + nodes: [fieldElement], path: [...(params.path || []), paragraphNode], }), ); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/paragraphNodeImporter.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/paragraphNodeImporter.test.js new file mode 100644 index 0000000000..8109d0a432 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/paragraphNodeImporter.test.js @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; +import { defaultNodeListHandler } from './docxImporter.js'; +import { exportSchemaToJson } from '../../exporter.js'; + +const textRun = (text) => ({ + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text }] }], +}); + +const paragraphProperties = (styleId) => ({ + name: 'w:pPr', + elements: [ + { name: 'w:pStyle', attributes: { 'w:val': styleId } }, + { + name: 'w:tabs', + elements: [{ name: 'w:tab', attributes: { 'w:val': 'right', 'w:pos': '8640', 'w:leader': 'dot' } }], + }, + { name: 'w:spacing', attributes: { 'w:before': '120', 'w:after': '240' } }, + { name: 'w:ind', attributes: { 'w:left': '360', 'w:hanging': '180' } }, + { + name: 'w:sectPr', + elements: [{ name: 'w:pgSz', attributes: { 'w:w': '15840', 'w:h': '12240', 'w:orient': 'landscape' } }], + }, + ], +}); + +const bibliographyField = (elements) => ({ + name: 'sd:bibliography', + attributes: { instruction: 'BIBLIOGRAPHY' }, + elements, +}); + +const importNodes = (nodes) => + defaultNodeListHandler().handler({ + nodes, + docx: {}, + }); + +describe('paragraphNodeImporter block field hoisting', () => { + it('transfers wrapper paragraph properties to a single-paragraph generated reference field result', () => { + const result = importNodes([ + { + name: 'w:p', + elements: [ + paragraphProperties('Bibliography'), + bibliographyField([ + { + name: 'w:p', + elements: [textRun('Generated bibliography result')], + }, + ]), + ], + }, + ]); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('bibliography'); + + const paragraph = result[0].content[0]; + const pPr = paragraph.attrs.paragraphProperties; + const wrapperPPr = result[0].attrs.wrapperParagraphProperties; + expect(pPr.styleId).toBe('Bibliography'); + expect(pPr.tabStops).toEqual([{ tab: { tabType: 'right', pos: 8640, leader: 'dot' } }]); + expect(pPr.spacing).toEqual({ before: 120, after: 240 }); + expect(pPr.indent).toEqual({ left: 360, hanging: 180 }); + expect(pPr.sectPr).toBeUndefined(); + expect(paragraph.attrs.pageBreakSource).toBeUndefined(); + expect(wrapperPPr.elements.find((element) => element.name === 'w:sectPr')).toMatchObject({ + name: 'w:sectPr', + elements: [{ name: 'w:pgSz', attributes: { 'w:w': '15840', 'w:h': '12240', 'w:orient': 'landscape' } }], + }); + + const exported = exportSchemaToJson({ node: result[0] }); + const exportedPPr = exported[0].elements[0]; + expect(exportedPPr).toMatchObject({ + name: 'w:pPr', + elements: [ + { name: 'w:pStyle', attributes: { 'w:val': 'Bibliography' } }, + { + name: 'w:tabs', + elements: [{ name: 'w:tab', attributes: { 'w:val': 'right', 'w:pos': '8640', 'w:leader': 'dot' } }], + }, + { name: 'w:spacing', attributes: { 'w:before': '120', 'w:after': '240' } }, + { name: 'w:ind', attributes: { 'w:left': '360', 'w:hanging': '180' } }, + { + name: 'w:sectPr', + elements: [{ name: 'w:pgSz', attributes: { 'w:w': '15840', 'w:h': '12240', 'w:orient': 'landscape' } }], + }, + ], + }); + }); + + it('does not overwrite existing generated result paragraph properties', () => { + const result = importNodes([ + { + name: 'w:p', + elements: [ + paragraphProperties('WrapperStyle'), + bibliographyField([ + { + name: 'w:p', + elements: [paragraphProperties('InnerResultStyle'), textRun('Generated bibliography result')], + }, + ]), + ], + }, + ]); + + const paragraph = result[0].content[0]; + expect(paragraph.attrs.paragraphProperties.styleId).toBe('InnerResultStyle'); + expect(result[0].attrs.wrapperParagraphProperties).toBeNull(); + }); + + it('keeps wrapper paragraph properties on ordinary paragraph content instead of duplicating them onto the field', () => { + const result = importNodes([ + { + name: 'w:p', + elements: [ + paragraphProperties('WrapperStyle'), + textRun('Leading text'), + bibliographyField([ + { + name: 'w:p', + elements: [textRun('Generated bibliography result')], + }, + ]), + ], + }, + ]); + + expect(result).toHaveLength(2); + expect(result[0].type).toBe('paragraph'); + expect(result[0].attrs.paragraphProperties.styleId).toBe('WrapperStyle'); + expect(result[1].type).toBe('bibliography'); + expect(result[1].content[0].attrs.paragraphProperties.styleId).toBeUndefined(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/tableOfAuthoritiesImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/tableOfAuthoritiesImporter.js new file mode 100644 index 0000000000..2b807c6486 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/tableOfAuthoritiesImporter.js @@ -0,0 +1,10 @@ +import { generateV2HandlerEntity } from '@core/super-converter/v3/handlers/utils'; +import { translator as tableOfAuthoritiesTranslator } from '../../v3/handlers/sd/tableOfAuthorities/tableOfAuthorities-translator.js'; + +/** + * @type {import("./docxImporter").NodeHandlerEntry} + */ +export const tableOfAuthoritiesHandlerEntity = generateV2HandlerEntity( + 'tableOfAuthoritiesHandler', + tableOfAuthoritiesTranslator, +); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/tableOfAuthoritiesImporter.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/tableOfAuthoritiesImporter.test.js new file mode 100644 index 0000000000..034b87782a --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/tableOfAuthoritiesImporter.test.js @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { defaultNodeListHandler } from './docxImporter.js'; + +describe('table of authorities importer', () => { + it('imports sd:tableOfAuthorities blocks produced by fld preprocessing', () => { + const handler = defaultNodeListHandler(); + + const result = handler.handler({ + nodes: [ + { + name: 'sd:tableOfAuthorities', + attributes: { + instruction: 'TOA \\h \\c "4" \\p', + }, + elements: [ + { + name: 'w:p', + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Cases' }] }] }], + }, + ], + }, + ], + docx: {}, + }); + + expect(result).toHaveLength(1); + expect(result[0]?.type).toBe('tableOfAuthorities'); + expect(result[0]?.attrs?.instruction).toBe('TOA \\h \\c "4" \\p'); + expect(result[0]?.content?.[0]?.type).toBe('paragraph'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/bibliography/bibliography-export-routing.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/bibliography/bibliography-export-routing.test.js index 5e71ee2fbc..2de002516a 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/bibliography/bibliography-export-routing.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/bibliography/bibliography-export-routing.test.js @@ -57,4 +57,27 @@ describe('bibliography export routing', () => { expect(serialized).toContain('w:instrText'); expect(serialized).toContain(BIBLIOGRAPHY_INSTRUCTION); }); + + it('reproduces a multi-run split instruction verbatim from tokens (SD-3066)', () => { + // Parity with index/toa: a BIBLIOGRAPHY instruction Word split across runs + // must export as those same runs, rebuilt from instructionTokens, rather + // than collapsing to a single instrText run. + const instructionTokens = [ + { type: 'text', text: 'BIBLIOGRAPHY ' }, + { type: 'text', text: '\\l 1033 ' }, + ]; + + const exported = exportSchemaToJson({ + node: buildBibliographyNode({ attrs: { instructionTokens } }), + }); + + const instrTexts = exported + .flatMap((para) => para.elements || []) + .filter((el) => el.name === 'w:r') + .flatMap((run) => run.elements || []) + .filter((el) => el.name === 'w:instrText') + .map((el) => el.elements[0].text); + + expect(instrTexts).toEqual(['BIBLIOGRAPHY ', '\\l 1033 ']); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/bibliography/bibliography-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/bibliography/bibliography-translator.js index 79c1de8c0d..3e15556ce3 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/bibliography/bibliography-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/bibliography/bibliography-translator.js @@ -1,7 +1,7 @@ // @ts-check import { NodeTranslator } from '@translator'; import { exportSchemaToJson } from '../../../../exporter.js'; -import { buildInstructionElements } from '../shared/index.js'; +import { buildInstructionElements, wrapParagraphsAsComplexField } from '../shared/index.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'sd:bibliography'; @@ -27,6 +27,8 @@ const encode = (params) => { type: SD_NODE_NAME, attrs: { instruction: node.attributes?.instruction || '', + instructionTokens: node.attributes?.instructionTokens || null, + wrapperParagraphProperties: node.attributes?.wrapperParagraphProperties || null, }, content: processedContent, }; @@ -43,45 +45,13 @@ const decode = (params) => { /** @type {any[]} */ const contentNodes = (node.content ?? []).map((n) => exportSchemaToJson({ ...params, node: n })); - const instructionElements = buildInstructionElements(node.attrs?.instruction, null); + const instructionElements = buildInstructionElements(node.attrs?.instruction, node.attrs?.instructionTokens ?? null); - const beginElements = [ - { - name: 'w:r', - elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' }, elements: [] }], - }, - { - name: 'w:r', - elements: instructionElements, - }, - { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' }, elements: [] }] }, - ]; - - if (contentNodes.length > 0) { - const firstParagraph = contentNodes[0]; - let insertIndex = 0; - if (firstParagraph.elements) { - const pPrIndex = firstParagraph.elements.findIndex((el) => el.name === 'w:pPr'); - insertIndex = pPrIndex >= 0 ? pPrIndex + 1 : 0; - } else { - firstParagraph.elements = []; - } - firstParagraph.elements.splice(insertIndex, 0, ...beginElements); - } else { - contentNodes.push({ name: 'w:p', elements: beginElements }); - } - - const endElements = [ - { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' }, elements: [] }] }, - ]; - const lastParagraph = contentNodes[contentNodes.length - 1]; - if (lastParagraph.elements) { - lastParagraph.elements.push(...endElements); - } else { - lastParagraph.elements = [...endElements]; - } - - return contentNodes; + return wrapParagraphsAsComplexField( + contentNodes, + instructionElements, + node.attrs?.wrapperParagraphProperties ?? null, + ); }; /** @type {import('@translator').NodeTranslatorConfig} */ diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/index/index-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/index/index-translator.js index a12bf87cff..b5f7f9f46c 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/index/index-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/index/index-translator.js @@ -1,7 +1,7 @@ // @ts-check import { NodeTranslator } from '@translator'; import { exportSchemaToJson } from '../../../../exporter.js'; -import { buildInstructionElements } from '../shared/index.js'; +import { buildInstructionElements, wrapParagraphsAsComplexField } from '../shared/index.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'sd:index'; @@ -28,6 +28,7 @@ const encode = (params) => { attrs: { instruction: node.attributes?.instruction || '', instructionTokens: node.attributes?.instructionTokens || null, + wrapperParagraphProperties: node.attributes?.wrapperParagraphProperties || null, }, content: processedContent, }; @@ -45,47 +46,11 @@ const decode = (params) => { const contentNodes = (node.content ?? []).map((n) => exportSchemaToJson({ ...params, node: n })); const instructionElements = buildInstructionElements(node.attrs?.instruction, node.attrs?.instructionTokens); - const indexBeginElements = [ - { - name: 'w:r', - elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' }, elements: [] }], - }, - { - name: 'w:r', - elements: instructionElements, - }, - { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' }, elements: [] }] }, - ]; - - if (contentNodes.length > 0) { - const firstParagraph = contentNodes[0]; - let insertIndex = 0; - if (firstParagraph.elements) { - const pPrIndex = firstParagraph.elements.findIndex((el) => el.name === 'w:pPr'); - insertIndex = pPrIndex >= 0 ? pPrIndex + 1 : 0; - } else { - firstParagraph.elements = []; - } - - firstParagraph.elements.splice(insertIndex, 0, ...indexBeginElements); - } else { - contentNodes.push({ - name: 'w:p', - elements: indexBeginElements, - }); - } - - const indexEndElements = [ - { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' }, elements: [] }] }, - ]; - const lastParagraph = contentNodes[contentNodes.length - 1]; - if (lastParagraph.elements) { - lastParagraph.elements.push(...indexEndElements); - } else { - lastParagraph.elements = [...indexEndElements]; - } - - return contentNodes; + return wrapParagraphsAsComplexField( + contentNodes, + instructionElements, + node.attrs?.wrapperParagraphProperties ?? null, + ); }; /** @type {import('@translator').NodeTranslatorConfig} */ diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/indexEntry/indexEntry-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/indexEntry/indexEntry-translator.test.js index 473c77eadb..51818da6cd 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/indexEntry/indexEntry-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/indexEntry/indexEntry-translator.test.js @@ -194,6 +194,33 @@ describe('sd:indexEntry translator', () => { expect(hasTab).toBe(true); }); + it('reproduces a multi-run split instruction verbatim from tokens (SD-3066)', () => { + // Word splits XE instructions across runs with literal spaces preserved: + // ' XE "' + 'Building Standard' + '" '. The clean `instruction` string is + // for display; export must rebuild the original runs from tokens so the + // round-trip stays byte-faithful (not collapse to one run from the string). + const instructionTokens = [ + { type: 'text', text: ' XE "' }, + { type: 'text', text: 'Building Standard' }, + { type: 'text', text: '" ' }, + ]; + + const result = config.decode({ + node: { + type: 'indexEntry', + attrs: { instruction: 'XE "Building Standard"', instructionTokens }, + content: [], + }, + }); + + const instrTexts = result + .flatMap((run) => (run.name === 'w:r' ? run.elements || [] : [])) + .filter((el) => el.name === 'w:instrText') + .map((el) => el.elements[0].text); + + expect(instrTexts).toEqual([' XE "', 'Building Standard', '" ']); + }); + it('includes instruction text in instrText element', () => { const mockParams = { node: { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/shared/block-field-xml-names.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/shared/block-field-xml-names.js new file mode 100644 index 0000000000..dc8d29ab50 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/shared/block-field-xml-names.js @@ -0,0 +1,18 @@ +// @ts-check + +/** + * XML element names emitted by the field-code preprocessors for block-level + * fields (table of contents, index, bibliography, table of authorities). + * + * Shared so the paragraph importer (which hoists these out of their wrapper + * paragraph) and the SDT classifier (which must treat a content control + * wrapping one of these as block, not inline) agree on the same set. + * + * @type {Set} + */ +export const BLOCK_FIELD_XML_NAMES = new Set([ + 'sd:tableOfContents', + 'sd:index', + 'sd:bibliography', + 'sd:tableOfAuthorities', +]); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/shared/build-block-field-paragraphs.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/shared/build-block-field-paragraphs.js new file mode 100644 index 0000000000..20c8fa687b --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/shared/build-block-field-paragraphs.js @@ -0,0 +1,67 @@ +// @ts-check +import { carbonCopy } from '@core/utilities/carbonCopy.js'; + +/** + * Wrap already-exported content paragraphs in a block-level complex field. + * + * Block fields (BIBLIOGRAPHY, INDEX, TOA) emit their generated result as one or + * more `` paragraphs bracketed by fldChar runs: the begin / instruction / + * separate runs are spliced into the first paragraph (after its `w:pPr`, if + * present) and the end run is appended to the last paragraph. This mirrors the + * inline-field helper `buildComplexFieldRuns` for block content, eliminating the + * duplicate hand-rolled wrappers that previously lived in the bibliography, + * index, and tableOfAuthorities decoders. + * + * Mutates and returns `contentNodes`. + * + * @param {any[]} contentNodes - Exported OOXML paragraph nodes (may be empty). + * @param {any[]} instructionElements - `w:instrText` / `w:tab` elements for the instruction run. + * @param {any | null} wrapperParagraphProperties - Optional original wrapper `w:pPr` to restore on the first result paragraph. + * @returns {any[]} The same array, with the field's fldChar runs inserted. + */ +export function wrapParagraphsAsComplexField(contentNodes, instructionElements, wrapperParagraphProperties = null) { + const beginElements = [ + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' }, elements: [] }] }, + { name: 'w:r', elements: instructionElements }, + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' }, elements: [] }] }, + ]; + + if (contentNodes.length > 0) { + const firstParagraph = contentNodes[0]; + if (wrapperParagraphProperties) { + const restoredPPr = carbonCopy(wrapperParagraphProperties); + if (firstParagraph.elements) { + const pPrIndex = firstParagraph.elements.findIndex((/** @type {any} */ el) => el.name === 'w:pPr'); + if (pPrIndex >= 0) { + firstParagraph.elements.splice(pPrIndex, 1, restoredPPr); + } else { + firstParagraph.elements.unshift(restoredPPr); + } + } else { + firstParagraph.elements = [restoredPPr]; + } + } + let insertIndex = 0; + if (firstParagraph.elements) { + const pPrIndex = firstParagraph.elements.findIndex((/** @type {any} */ el) => el.name === 'w:pPr'); + insertIndex = pPrIndex >= 0 ? pPrIndex + 1 : 0; + } else { + firstParagraph.elements = []; + } + firstParagraph.elements.splice(insertIndex, 0, ...beginElements); + } else { + contentNodes.push({ name: 'w:p', elements: beginElements }); + } + + const endElements = [ + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' }, elements: [] }] }, + ]; + const lastParagraph = contentNodes[contentNodes.length - 1]; + if (lastParagraph.elements) { + lastParagraph.elements.push(...endElements); + } else { + lastParagraph.elements = [...endElements]; + } + + return contentNodes; +} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/shared/build-block-field-paragraphs.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/shared/build-block-field-paragraphs.test.js new file mode 100644 index 0000000000..4dd2b763e7 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/shared/build-block-field-paragraphs.test.js @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { wrapParagraphsAsComplexField } from './build-block-field-paragraphs.js'; + +const instr = [ + { name: 'w:instrText', attributes: { 'xml:space': 'preserve' }, elements: [{ type: 'text', text: 'INDEX' }] }, +]; + +const fldCharTypesOf = (run) => + (run?.elements || []).filter((e) => e.name === 'w:fldChar').map((e) => e.attributes['w:fldCharType']); + +describe('wrapParagraphsAsComplexField', () => { + it('synthesizes a single paragraph carrying begin/separate/end when content is empty', () => { + const result = wrapParagraphsAsComplexField([], instr); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('w:p'); + const types = result[0].elements.flatMap(fldCharTypesOf); + expect(types).toEqual(['begin', 'separate', 'end']); + }); + + it('splices begin/separate into the first paragraph after its w:pPr and end into the last', () => { + const first = { + name: 'w:p', + elements: [ + { name: 'w:pPr', elements: [] }, + { name: 'w:r', elements: [] }, + ], + }; + const last = { name: 'w:p', elements: [{ name: 'w:r', elements: [] }] }; + + const result = wrapParagraphsAsComplexField([first, last], instr); + + // begin/separate land after the pPr in the first paragraph + expect(result[0].elements[0].name).toBe('w:pPr'); + expect(fldCharTypesOf(result[0].elements[1])).toEqual(['begin']); + expect(result[0].elements[2]).toEqual({ name: 'w:r', elements: instr }); + expect(fldCharTypesOf(result[0].elements[3])).toEqual(['separate']); + // end is appended to the last paragraph + const lastTypes = result[result.length - 1].elements.flatMap(fldCharTypesOf); + expect(lastTypes).toEqual(['end']); + }); + + it('inserts begin at index 0 when the first paragraph has no w:pPr', () => { + const only = { name: 'w:p', elements: [{ name: 'w:r', elements: [] }] }; + + const result = wrapParagraphsAsComplexField([only], instr); + + expect(fldCharTypesOf(result[0].elements[0])).toEqual(['begin']); + const types = result[0].elements.flatMap(fldCharTypesOf); + expect(types).toEqual(['begin', 'separate', 'end']); + }); + + it('restores wrapper paragraph properties before inserting field runs', () => { + const wrapperPPr = { + name: 'w:pPr', + elements: [ + { name: 'w:pStyle', attributes: { 'w:val': 'Index1' } }, + { name: 'w:sectPr', elements: [] }, + ], + }; + const only = { + name: 'w:p', + elements: [{ name: 'w:pPr', elements: [{ name: 'w:pStyle', attributes: { 'w:val': 'IndexVisual' } }] }], + }; + + const result = wrapParagraphsAsComplexField([only], instr, wrapperPPr); + + expect(result[0].elements[0]).toEqual(wrapperPPr); + expect(fldCharTypesOf(result[0].elements[1])).toEqual(['begin']); + expect(result[0].elements[2]).toEqual({ name: 'w:r', elements: instr }); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/shared/index.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/shared/index.js index f478aeb4a3..409bcbbf55 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/shared/index.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/shared/index.js @@ -1,2 +1,3 @@ export * from './instruction-elements.js'; export * from './build-field-result-runs.js'; +export * from './build-block-field-paragraphs.js'; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/tableOfAuthorities/tableOfAuthorities-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/tableOfAuthorities/tableOfAuthorities-translator.js index 2d4b2fe036..5e9e1f4b24 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/tableOfAuthorities/tableOfAuthorities-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/tableOfAuthorities/tableOfAuthorities-translator.js @@ -1,7 +1,7 @@ // @ts-check import { NodeTranslator } from '@translator'; import { exportSchemaToJson } from '../../../../exporter.js'; -import { buildInstructionElements } from '../shared/index.js'; +import { buildInstructionElements, wrapParagraphsAsComplexField } from '../shared/index.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'sd:tableOfAuthorities'; @@ -28,6 +28,7 @@ const encode = (params) => { attrs: { instruction: node.attributes?.instruction || '', instructionTokens: node.attributes?.instructionTokens || null, + wrapperParagraphProperties: node.attributes?.wrapperParagraphProperties || null, }, content: processedContent, }; @@ -46,43 +47,11 @@ const decode = (params) => { const contentNodes = (node.content ?? []).map((n) => exportSchemaToJson({ ...params, node: n })); const instructionElements = buildInstructionElements(node.attrs?.instruction, node.attrs?.instructionTokens); - const beginElements = [ - { - name: 'w:r', - elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' }, elements: [] }], - }, - { - name: 'w:r', - elements: instructionElements, - }, - { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' }, elements: [] }] }, - ]; - - if (contentNodes.length > 0) { - const firstParagraph = contentNodes[0]; - let insertIndex = 0; - if (firstParagraph.elements) { - const pPrIndex = firstParagraph.elements.findIndex((el) => el.name === 'w:pPr'); - insertIndex = pPrIndex >= 0 ? pPrIndex + 1 : 0; - } else { - firstParagraph.elements = []; - } - firstParagraph.elements.splice(insertIndex, 0, ...beginElements); - } else { - contentNodes.push({ name: 'w:p', elements: beginElements }); - } - - const endElements = [ - { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' }, elements: [] }] }, - ]; - const lastParagraph = contentNodes[contentNodes.length - 1]; - if (lastParagraph.elements) { - lastParagraph.elements.push(...endElements); - } else { - lastParagraph.elements = [...endElements]; - } - - return contentNodes; + return wrapParagraphsAsComplexField( + contentNodes, + instructionElements, + node.attrs?.wrapperParagraphProperties ?? null, + ); }; /** @type {import('@translator').NodeTranslatorConfig} */ diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.js index c3d0232104..ee57751e11 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.js @@ -1,5 +1,6 @@ import { parseAnnotationMarks } from './handle-annotation-node'; import { parseStrictStOnOff } from '../../../utils.js'; +import { BLOCK_FIELD_XML_NAMES } from '../../../sd/shared/block-field-xml-names.js'; /** * Detect the semantic control type from sdtPr child elements. @@ -114,6 +115,10 @@ export function handleStructuredContentNode(params) { const paragraph = sdtContent.elements?.find((el) => el.name === 'w:p'); const table = sdtContent.elements?.find((el) => el.name === 'w:tbl'); + // SD-3005: a content control wrapping a block field (e.g. BIBLIOGRAPHY) has + // no direct w:p after preprocessing β€” its child is an sd:* block node. It is + // block content and must not be emitted as an inline structuredContent. + const blockField = sdtContent.elements?.find((el) => BLOCK_FIELD_XML_NAMES.has(el?.name)); const { marks } = parseAnnotationMarks(sdtContent); const translatedContent = nodeListHandler.handler({ ...params, @@ -121,7 +126,7 @@ export function handleStructuredContentNode(params) { path: [...(params.path || []), sdtContent], }); - const isBlockNode = paragraph || table; + const isBlockNode = paragraph || table || blockField; const sdtContentType = isBlockNode ? 'structuredContentBlock' : 'structuredContent'; let result = { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.test.js index 5f32162789..92e14f098d 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.test.js @@ -110,6 +110,20 @@ describe('handleStructuredContentNode', () => { expect(result.type).toBe('structuredContentBlock'); }); + it('returns structuredContentBlock when content is a block field node (SD-3005)', () => { + // A content control wrapping a block field (e.g. BIBLIOGRAPHY) has no + // direct w:p β€” after field preprocessing its only child is an sd:bibliography + // block node. Classifying it inline (structuredContent) puts a block node + // inside an inline node and crashes the editor; it must be block. + const sdtContentElements = [{ name: 'sd:bibliography', attributes: { instruction: 'BIBLIOGRAPHY' }, elements: [] }]; + const node = createNode([], sdtContentElements); + + parseAnnotationMarks.mockReturnValue({ marks: [] }); + const result = handleStructuredContentNode({ nodes: [node], nodeListHandler: mockNodeListHandler }); + + expect(result.type).toBe('structuredContentBlock'); + }); + it('includes sdtPr in result attrs', () => { const sdtPrElements = [{ name: 'w:tag', attributes: { 'w:val': 'test' } }]; const node = createNode(sdtPrElements, [{ name: 'w:r', text: 'content' }]); diff --git a/packages/super-editor/src/editors/v1/extensions/bibliography/bibliography.js b/packages/super-editor/src/editors/v1/extensions/bibliography/bibliography.js index 9ee2251735..5a40d0a286 100644 --- a/packages/super-editor/src/editors/v1/extensions/bibliography/bibliography.js +++ b/packages/super-editor/src/editors/v1/extensions/bibliography/bibliography.js @@ -22,10 +22,18 @@ export const Bibliography = Node.create({ default: '', rendered: false, }, + instructionTokens: { + default: null, + rendered: false, + }, sdBlockId: { default: null, rendered: false, }, + wrapperParagraphProperties: { + default: null, + rendered: false, + }, style: { default: null, rendered: false, diff --git a/packages/super-editor/src/editors/v1/extensions/document-index/document-index.js b/packages/super-editor/src/editors/v1/extensions/document-index/document-index.js index ccd4996a95..48e7bb743f 100644 --- a/packages/super-editor/src/editors/v1/extensions/document-index/document-index.js +++ b/packages/super-editor/src/editors/v1/extensions/document-index/document-index.js @@ -54,6 +54,10 @@ export const DocumentIndex = Node.create({ return attrs.sdBlockId ? { 'data-sd-block-id': attrs.sdBlockId } : {}; }, }, + wrapperParagraphProperties: { + default: null, + rendered: false, + }, }; }, }); diff --git a/packages/super-editor/src/editors/v1/extensions/table-of-authorities/table-of-authorities.js b/packages/super-editor/src/editors/v1/extensions/table-of-authorities/table-of-authorities.js index bd8fbe8667..7bcdd02671 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-of-authorities/table-of-authorities.js +++ b/packages/super-editor/src/editors/v1/extensions/table-of-authorities/table-of-authorities.js @@ -30,6 +30,10 @@ export const TableOfAuthorities = Node.create({ default: null, rendered: false, }, + wrapperParagraphProperties: { + default: null, + rendered: false, + }, }; }, diff --git a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts index 7bd04f9c8e..3b7a109a4c 100644 --- a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts @@ -1084,6 +1084,8 @@ export interface DocumentIndexAttrs extends BlockNodeAttributes { instructionTokens?: unknown; /** SuperDoc block tracking ID */ sdBlockId?: string | null; + /** @internal Original generated-reference wrapper paragraph properties for export preservation */ + wrapperParagraphProperties?: unknown; } /** Index entry node attributes */ From 83013e42cbd08270833be37b18b478ae5c7d8fe5 Mon Sep 17 00:00:00 2001 From: Nick Bernal <117235294+harbournick@users.noreply.github.com> Date: Sun, 31 May 2026 18:15:48 -0700 Subject: [PATCH 20/23] feat: add per-author tracked change colors (#3559) --- .../docs/editor/built-in-ui/track-changes.mdx | 40 ++++ apps/docs/editor/custom-ui/track-changes.mdx | 48 +++- apps/docs/editor/superdoc/configuration.mdx | 38 +++ .../contracts/src/author-colors.test.ts | 97 ++++++++ .../contracts/src/author-colors.ts | 217 ++++++++++++++++++ packages/layout-engine/contracts/src/index.ts | 31 +++ .../painters/dom/src/index.test.ts | 4 + .../painters/dom/src/runs/tracked-changes.ts | 73 ++++++ .../core/Editor.contentControlEvents.test.ts | 22 +- .../src/editors/v1/core/Editor.ts | 1 - .../editors/v1/core/layout-adapter/index.ts | 11 + .../v1/core/layout-adapter/internal.test.ts | 2 +- .../v1/core/layout-adapter/internal.ts | 7 + .../editors/v1/core/layout-adapter/types.ts | 9 + .../presentation-editor/PresentationEditor.ts | 4 + .../layout/EndnotesBuilder.ts | 4 +- .../layout/FootnotesBuilder.ts | 4 +- .../v1/core/presentation-editor/types.ts | 17 +- .../helpers/tracked-change-resolver.test.ts | 16 ++ .../helpers/tracked-change-resolver.ts | 28 ++- .../src/ui/create-super-doc-ui.ts | 44 +++- .../super-editor/src/ui/react/hooks.test.tsx | 2 +- packages/super-editor/src/ui/react/hooks.ts | 2 +- .../super-editor/src/ui/track-changes.test.ts | 39 ++++ packages/super-editor/src/ui/types.ts | 53 ++++- packages/super-editor/src/ui/viewport.test.ts | 14 +- packages/superdoc/package.json | 1 + packages/superdoc/src/SuperDoc.test.js | 22 ++ packages/superdoc/src/SuperDoc.vue | 4 + .../helpers/normalize-track-changes-config.js | 13 +- .../normalize-track-changes-config.test.js | 14 ++ packages/superdoc/src/core/types/index.ts | 47 ++++ packages/superdoc/src/index.js | 2 + packages/superdoc/src/public/index.ts | 2 + pnpm-lock.yaml | 133 ++++++++++- .../content-control-scroll-into-view.spec.ts | 9 +- .../superdoc-root-classification.json | 28 ++- .../snapshots/superdoc-root-classification.md | 13 +- .../snapshots/superdoc-root-exports.json | 14 +- .../snapshots/superdoc-root-exports.md | 24 +- .../src/all-public-types.ts | 4 + .../src/config-callback-payloads.ts | 5 +- 42 files changed, 1101 insertions(+), 61 deletions(-) create mode 100644 packages/layout-engine/contracts/src/author-colors.test.ts create mode 100644 packages/layout-engine/contracts/src/author-colors.ts diff --git a/apps/docs/editor/built-in-ui/track-changes.mdx b/apps/docs/editor/built-in-ui/track-changes.mdx index b9fe67687d..9b50954b28 100644 --- a/apps/docs/editor/built-in-ui/track-changes.mdx +++ b/apps/docs/editor/built-in-ui/track-changes.mdx @@ -75,6 +75,46 @@ const superdoc = new SuperDoc({ + + Resolve one highlight color per tracked-change author. This replaces app-side CSS overrides like `[data-track-change-author]` selectors. + + + + Set to `false` to keep the default insert, delete, and format colors. + + + Exact color overrides keyed by author email or author name. Email matches first, then name. + + + Callback for authors not covered by `overrides`. Receives `{ name, email, image }`. Return any CSS color string, or `undefined` to use SuperDoc's deterministic fallback color. + + + + +```javascript +new SuperDoc({ + selector: "#editor", + document: "contract.docx", + modules: { + trackChanges: { + visible: true, + authorColors: { + overrides: { + "alice@example.com": "#1f6feb", + "Bob Reviewer": "#d1242f", + }, + resolve: (author) => { + if (author.email?.endsWith("@outside-counsel.com")) return "#8250df"; + return undefined; // SuperDoc assigns a stable fallback color + }, + }, + }, + }, +}); +``` + +Per-author colors apply to insertion, deletion, and format-change highlights. SuperDoc derives lighter background variants from the same author color and exposes the resolved colors through the custom UI snapshot. + ## Viewing mode visibility Tracked-change markup is hidden by default when `documentMode` is `'viewing'`. Flip `modules.trackChanges.visible` to show it in read-only mode. diff --git a/apps/docs/editor/custom-ui/track-changes.mdx b/apps/docs/editor/custom-ui/track-changes.mdx index e614f4f44e..04695b4543 100644 --- a/apps/docs/editor/custom-ui/track-changes.mdx +++ b/apps/docs/editor/custom-ui/track-changes.mdx @@ -12,16 +12,25 @@ description: 'Build your own track-changes review panel. Accept, reject, navigat import { useSuperDocTrackChanges, useSuperDocUI } from 'superdoc/ui/react'; export function ReviewPanel() { - const { items, total } = useSuperDocTrackChanges(); + const { items, total, authors } = useSuperDocTrackChanges(); const ui = useSuperDocUI(); return (