From e87934726e93ca8a941f43fb1a9b76028ab24b1c Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 19 Feb 2026 13:41:46 -0800 Subject: [PATCH] fix(ai-actions): preserve html/markdown insertion and prevent repeated formatted replacement --- .../__tests__/editor/editor-adapter.test.ts | 92 ++++++++ .../services/ai-actions-service.test.ts | 210 ++++++++++++++++++ .../src/ai-actions/editor/editor-adapter.ts | 45 ++++ packages/ai/src/ai-actions/index.ts | 7 +- .../ai-actions/services/ai-actions-service.ts | 76 +++++-- .../ai-actions/tools/builtin/content-tools.ts | 6 +- .../ai-actions/tools/builtin/replace-tools.ts | 5 + packages/ai/src/ai-actions/tools/types.ts | 7 +- packages/ai/src/shared/types.ts | 7 +- .../src/tests/insertContent-links.test.js | 138 ++++++++++++ 10 files changed, 572 insertions(+), 21 deletions(-) create mode 100644 packages/super-editor/src/tests/insertContent-links.test.js diff --git a/packages/ai/src/ai-actions/__tests__/editor/editor-adapter.test.ts b/packages/ai/src/ai-actions/__tests__/editor/editor-adapter.test.ts index 1255cf4bdc..7d6c4bf109 100644 --- a/packages/ai/src/ai-actions/__tests__/editor/editor-adapter.test.ts +++ b/packages/ai/src/ai-actions/__tests__/editor/editor-adapter.test.ts @@ -656,6 +656,98 @@ describe('EditorAdapter', () => { }); }); + describe('insertFormattedContent', () => { + it('calls editor.commands.insertContent with contentType for html', () => { + updateEditorState(defaultSegments, { from: 1, to: 5 }); + + mockAdapter.insertFormattedContent('

Hello link

', { + contentType: 'html', + position: 'replace', + }); + + expect(mockEditor.commands.setTextSelection).toHaveBeenCalledWith({ from: 1, to: 5 }); + expect(mockEditor.commands.insertContent).toHaveBeenCalledWith( + '

Hello link

', + { contentType: 'html' }, + ); + }); + + it('calls editor.commands.insertContent with contentType for markdown', () => { + updateEditorState(defaultSegments, { from: 1, to: 5 }); + + mockAdapter.insertFormattedContent('# Heading\n\n[link](https://example.com)', { + contentType: 'markdown', + position: 'replace', + }); + + expect(mockEditor.commands.insertContent).toHaveBeenCalledWith('# Heading\n\n[link](https://example.com)', { + contentType: 'markdown', + }); + }); + + it('falls back to insertText for contentType: text', () => { + updateEditorState(defaultSegments, { from: 1, to: 5 }); + + const insertSpy = vi.spyOn(mockAdapter, 'insertText'); + mockAdapter.insertFormattedContent('plain text', { contentType: 'text', position: 'replace' }); + + expect(insertSpy).toHaveBeenCalledWith('plain text', { contentType: 'text', position: 'replace' }); + expect(mockEditor.commands.insertContent).not.toHaveBeenCalledWith(expect.anything(), { + contentType: 'text', + }); + + insertSpy.mockRestore(); + }); + + it('falls back to insertText when no contentType is provided', () => { + updateEditorState(defaultSegments, { from: 1, to: 5 }); + + const insertSpy = vi.spyOn(mockAdapter, 'insertText'); + mockAdapter.insertFormattedContent('just text', { position: 'replace' }); + + expect(insertSpy).toHaveBeenCalledWith('just text', { position: 'replace' }); + + insertSpy.mockRestore(); + }); + + it('sets selection to collapsed range at from for position: before', () => { + updateEditorState(defaultSegments, { from: 3, to: 8 }); + + mockAdapter.insertFormattedContent('

Before

', { + contentType: 'html', + position: 'before', + }); + + // 'before' collapses to from: to = from + expect(mockEditor.commands.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 3 }); + expect(mockEditor.commands.insertContent).toHaveBeenCalledWith('

Before

', { contentType: 'html' }); + }); + + it('sets selection to collapsed range at to for position: after', () => { + updateEditorState(defaultSegments, { from: 3, to: 8 }); + + mockAdapter.insertFormattedContent('

After

', { + contentType: 'html', + position: 'after', + }); + + // 'after' collapses to to: from = to + expect(mockEditor.commands.setTextSelection).toHaveBeenCalledWith({ from: 8, to: 8 }); + expect(mockEditor.commands.insertContent).toHaveBeenCalledWith('

After

', { contentType: 'html' }); + }); + + it('uses full selection range for position: replace (default)', () => { + updateEditorState(defaultSegments, { from: 3, to: 8 }); + + mockAdapter.insertFormattedContent('

Replace

', { + contentType: 'html', + }); + + expect(mockEditor.commands.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 8 }); + expect(mockEditor.commands.insertContent).toHaveBeenCalledWith('

Replace

', { contentType: 'html' }); + }); + }); + describe('replaceText with appended content', () => { it('handles appended text correctly (prefix equals original, suffix is 0)', () => { const boldMark = schema.marks.bold.create(); diff --git a/packages/ai/src/ai-actions/__tests__/services/ai-actions-service.test.ts b/packages/ai/src/ai-actions/__tests__/services/ai-actions-service.test.ts index 07d30df74f..27f0fe22ad 100644 --- a/packages/ai/src/ai-actions/__tests__/services/ai-actions-service.test.ts +++ b/packages/ai/src/ai-actions/__tests__/services/ai-actions-service.test.ts @@ -457,6 +457,22 @@ describe('AIActionsService', () => { expect(result.success).toBe(true); expect(result.results).toHaveLength(1); }); + + it('should throw when trackChanges and contentType: html are both set', async () => { + const actions = new AIActionsService(mockProvider, mockEditor, () => mockEditor.state.doc.textContent, false); + + await expect( + actions.literalReplace('A', 'B', { trackChanges: true, contentType: 'html' }), + ).rejects.toThrow('trackChanges and contentType'); + }); + + it('should throw when trackChanges and contentType: markdown are both set', async () => { + const actions = new AIActionsService(mockProvider, mockEditor, () => mockEditor.state.doc.textContent, false); + + await expect( + actions.literalReplace('A', '**B**', { trackChanges: true, contentType: 'markdown' }), + ).rejects.toThrow('trackChanges and contentType'); + }); }); describe('insertTrackedChange', () => { @@ -805,6 +821,200 @@ describe('AIActionsService', () => { }); }); + describe('insertContent with contentType', () => { + it('should disable streaming when contentType is html', async () => { + const response = JSON.stringify({ + success: true, + results: [{ suggestedText: '

Hello link

' }], + }); + + const streamSpy = vi.fn().mockImplementation(async function* () { + yield response; + }); + const completionSpy = vi.fn().mockResolvedValue(response); + + mockProvider.streamCompletion = streamSpy as typeof mockProvider.streamCompletion; + mockProvider.getCompletion = completionSpy; + + const insertFormattedSpy = vi + .spyOn(EditorAdapter.prototype, 'insertFormattedContent') + .mockImplementation(() => {}); + + const actions = new AIActionsService( + mockProvider, + mockEditor, + () => mockEditor.state.doc.textContent, + false, + undefined, + true, // streaming preference enabled + ); + + const result = await actions.insertContent('generate html', { contentType: 'html' }); + + expect(result.success).toBe(true); + // Streaming must be disabled for HTML content + expect(streamSpy).not.toHaveBeenCalled(); + expect(completionSpy).toHaveBeenCalled(); + // Should use insertFormattedContent, not insertText + expect(insertFormattedSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ contentType: 'html' }), + ); + + insertFormattedSpy.mockRestore(); + }); + + it('should disable streaming when contentType is markdown', async () => { + const response = JSON.stringify({ + success: true, + results: [{ suggestedText: '# Title\n\n[link](https://example.com)' }], + }); + + const streamSpy = vi.fn().mockImplementation(async function* () { + yield response; + }); + const completionSpy = vi.fn().mockResolvedValue(response); + + mockProvider.streamCompletion = streamSpy as typeof mockProvider.streamCompletion; + mockProvider.getCompletion = completionSpy; + + const insertFormattedSpy = vi + .spyOn(EditorAdapter.prototype, 'insertFormattedContent') + .mockImplementation(() => {}); + + const actions = new AIActionsService( + mockProvider, + mockEditor, + () => mockEditor.state.doc.textContent, + false, + undefined, + true, + ); + + const result = await actions.insertContent('generate markdown', { contentType: 'markdown' }); + + expect(result.success).toBe(true); + expect(streamSpy).not.toHaveBeenCalled(); + expect(completionSpy).toHaveBeenCalled(); + expect(insertFormattedSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ contentType: 'markdown' }), + ); + + insertFormattedSpy.mockRestore(); + }); + + it('should route through insertFormattedContent with position: before for html', async () => { + const response = JSON.stringify({ + success: true, + results: [{ suggestedText: '

Before content

' }], + }); + + mockProvider.getCompletion = vi.fn().mockResolvedValue(response); + + const insertFormattedSpy = vi + .spyOn(EditorAdapter.prototype, 'insertFormattedContent') + .mockImplementation(() => {}); + + const actions = new AIActionsService(mockProvider, mockEditor, () => mockEditor.state.doc.textContent, false); + const result = await actions.insertContent('add content', { + position: 'before', + contentType: 'html', + }); + + expect(result.success).toBe(true); + expect(insertFormattedSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ position: 'before', contentType: 'html' }), + ); + + insertFormattedSpy.mockRestore(); + }); + + it('should route through insertFormattedContent with position: after for html', async () => { + const response = JSON.stringify({ + success: true, + results: [{ suggestedText: '

After content

' }], + }); + + mockProvider.getCompletion = vi.fn().mockResolvedValue(response); + + const insertFormattedSpy = vi + .spyOn(EditorAdapter.prototype, 'insertFormattedContent') + .mockImplementation(() => {}); + + const actions = new AIActionsService(mockProvider, mockEditor, () => mockEditor.state.doc.textContent, false); + const result = await actions.insertContent('add content', { + position: 'after', + contentType: 'html', + }); + + expect(result.success).toBe(true); + expect(insertFormattedSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ position: 'after', contentType: 'html' }), + ); + + insertFormattedSpy.mockRestore(); + }); + + it('should use plain insertText path when contentType is text', async () => { + const response = JSON.stringify({ + success: true, + results: [{ suggestedText: 'Plain text content' }], + }); + + mockProvider.getCompletion = vi.fn().mockResolvedValue(response); + + const insertTextSpy = vi.spyOn(EditorAdapter.prototype, 'insertText').mockImplementation(() => {}); + const insertFormattedSpy = vi + .spyOn(EditorAdapter.prototype, 'insertFormattedContent') + .mockImplementation(() => {}); + + const actions = new AIActionsService(mockProvider, mockEditor, () => mockEditor.state.doc.textContent, false); + const result = await actions.insertContent('add content', { contentType: 'text' }); + + expect(result.success).toBe(true); + expect(insertFormattedSpy).not.toHaveBeenCalled(); + expect(insertTextSpy).toHaveBeenCalled(); + + insertTextSpy.mockRestore(); + insertFormattedSpy.mockRestore(); + }); + }); + + describe('literalReplace with contentType', () => { + let literalSpy: ReturnType>; + let insertFormattedSpy: ReturnType>; + + beforeEach(() => { + literalSpy = vi.spyOn(EditorAdapter.prototype, 'findLiteralMatches'); + insertFormattedSpy = vi.spyOn(EditorAdapter.prototype, 'insertFormattedContent').mockImplementation(() => {}); + }); + + afterEach(() => { + literalSpy.mockRestore(); + insertFormattedSpy.mockRestore(); + }); + + it('should route replacement through insertFormattedContent when contentType is html', async () => { + const match = { from: 0, to: 5, text: 'Hello' }; + literalSpy.mockReturnValueOnce([match]).mockReturnValue([]); + + const actions = new AIActionsService(mockProvider, mockEditor, () => mockEditor.state.doc.textContent, false); + const result = await actions.literalReplace('Hello', '

Hi

', { + contentType: 'html', + }); + + expect(result.success).toBe(true); + expect(mockEditor.commands.setTextSelection).toHaveBeenCalled(); + expect(insertFormattedSpy).toHaveBeenCalledWith( + '

Hi

', + expect.objectContaining({ contentType: 'html', position: 'replace' }), + ); + }); + }); + describe('error handling', () => { it('should respect enableLogging flag', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { diff --git a/packages/ai/src/ai-actions/editor/editor-adapter.ts b/packages/ai/src/ai-actions/editor/editor-adapter.ts index 25c7fcbfb7..55a53c4929 100644 --- a/packages/ai/src/ai-actions/editor/editor-adapter.ts +++ b/packages/ai/src/ai-actions/editor/editor-adapter.ts @@ -744,4 +744,49 @@ export class EditorAdapter { this.applyPatch(from, to, suggestedText); } + + /** + * Inserts content with optional format parsing (HTML, markdown). + * When contentType is 'html' or 'markdown', delegates to the editor's + * insertContent command which parses the content through ProseMirror's DOMParser, + * creating proper marks (e.g., link marks for tags). + * When contentType is 'text' or omitted, falls back to plain-text insertText. + * + * @param content - The content to insert + * @param options + */ + insertFormattedContent( + content: string, + options?: { position?: 'before' | 'after' | 'replace'; contentType?: 'html' | 'markdown' | 'text' }, + ): void { + const contentType = options?.contentType; + + if (contentType && contentType !== 'text') { + const position = this.getSelectionRange(); + if (!position) return; + + const mode = options?.position ?? 'replace'; + let from = position.from; + let to = position.to; + + if (mode === 'before') { + to = from; + } else if (mode === 'after') { + from = to; + } + + // Set selection to the target range before inserting + this.editor.commands?.setTextSelection?.({ from, to }); + const commands = this.editor.commands as + | { + insertContent?: (value: string, config?: { contentType?: 'html' | 'markdown' | 'text' }) => unknown; + } + | undefined; + commands?.insertContent?.(content, { contentType }); + return; + } + + // Fall back to plain-text path + this.insertText(content, options); + } } diff --git a/packages/ai/src/ai-actions/index.ts b/packages/ai/src/ai-actions/index.ts index f096863a71..32008582e6 100644 --- a/packages/ai/src/ai-actions/index.ts +++ b/packages/ai/src/ai-actions/index.ts @@ -74,7 +74,7 @@ export class AIActions { literalReplace: async ( findText: string, replaceText: string, - options?: { caseSensitive?: boolean; trackChanges?: boolean }, + options?: { caseSensitive?: boolean; trackChanges?: boolean; contentType?: 'html' | 'markdown' | 'text' }, ) => { return this.executeActionWithCallbacks(() => this.commands.literalReplace(findText, replaceText, options)); }, @@ -97,7 +97,10 @@ export class AIActions { summarize: async (instruction: string) => { return this.executeActionWithCallbacks(() => this.commands.summarize(instruction)); }, - insertContent: async (instruction: string, options?: { position?: 'before' | 'after' | 'replace' }) => { + insertContent: async ( + instruction: string, + options?: { position?: 'before' | 'after' | 'replace'; contentType?: 'html' | 'markdown' | 'text' }, + ) => { return this.executeActionWithCallbacks(() => this.commands.insertContent(instruction, options)); }, }; diff --git a/packages/ai/src/ai-actions/services/ai-actions-service.ts b/packages/ai/src/ai-actions/services/ai-actions-service.ts index 5f0be706a7..78574a16ff 100644 --- a/packages/ai/src/ai-actions/services/ai-actions-service.ts +++ b/packages/ai/src/ai-actions/services/ai-actions-service.ts @@ -460,6 +460,7 @@ export class AIActionsService { options?: { caseSensitive?: boolean; trackChanges?: boolean; + contentType?: 'html' | 'markdown' | 'text'; }, ): Promise { if (!validateInput(findText)) { @@ -469,6 +470,14 @@ export class AIActionsService { throw new Error('Replacement text must be a string (use an empty string "" to delete text).'); } + const isFormattedContentType = options?.contentType === 'html' || options?.contentType === 'markdown'; + if (options?.trackChanges && isFormattedContentType) { + throw new Error( + `trackChanges and contentType: '${options.contentType}' cannot be used together. ` + + 'Tracked changes require plain-text insertion; HTML/Markdown content would be inserted as literal text.', + ); + } + const applied: FoundMatch[] = []; // Automatically detect if there's an active selection that matches findText @@ -486,9 +495,16 @@ export class AIActionsService { if (textMatches && selection.from >= 0 && selection.to <= doc.content.size) { // Direct replacement optimization - replace selection without search + const isFormattedContent = options?.contentType === 'html' || options?.contentType === 'markdown'; let changeId: string | undefined; if (options?.trackChanges) { changeId = this.adapter.createTrackedChange(selection.from, selection.to, replacementText); + } else if (isFormattedContent) { + this.editor?.commands?.setTextSelection?.({ from: selection.from, to: selection.to }); + this.adapter.insertFormattedContent(replacementText, { + position: 'replace', + contentType: options.contentType, + }); } else { this.adapter.replaceText(selection.from, selection.to, replacementText); } @@ -517,11 +533,15 @@ export class AIActionsService { return { success: false, results: [] }; } + const isFormattedReplace = options?.contentType === 'html' || options?.contentType === 'markdown'; const replacementContainsSearch = options?.caseSensitive ? replacementText.includes(findText) : replacementText.toLowerCase().includes(findText.toLowerCase()); - const maxPasses = replacementContainsSearch ? 10 : 1; + // Formatted content: always single-pass. After HTML/markdown insertion the visible + // text may still match findText (e.g., replacing "Hello" with "Hello"), + // but the replacement is correct — re-matching would cause duplicate rewrites. + const maxPasses = isFormattedReplace ? 1 : replacementContainsSearch ? 10 : 1; let pass = 0; const collectMatches = () => this.adapter.findLiteralMatches(findText, Boolean(options?.caseSensitive)); @@ -535,6 +555,8 @@ export class AIActionsService { const descending = [...normalizedMatches].sort((a, b) => b.from - a.from); const replacementsThisPass: FoundMatch[] = []; + const isFormattedContent = options?.contentType === 'html' || options?.contentType === 'markdown'; + for (const match of descending) { if (options?.trackChanges) { const changeId = this.adapter.createTrackedChange(match.from, match.to, replacementText); @@ -544,6 +566,17 @@ export class AIActionsService { positions: [{ from: match.from, to: match.to }], changeId, } as FoundMatch); + } else if (isFormattedContent) { + this.editor?.commands?.setTextSelection?.({ from: match.from, to: match.to }); + this.adapter.insertFormattedContent(replacementText, { + position: 'replace', + contentType: options!.contentType, + }); + replacementsThisPass.push({ + originalText: match.text, + suggestedText: replacementText, + positions: [{ from: match.from, to: match.to }], + }); } else { this.adapter.replaceText(match.from, match.to, replacementText); replacementsThisPass.push({ @@ -777,7 +810,10 @@ export class AIActionsService { * @param options - in reference to the current document position, where to insert the content. * @returns Result with inserted content location */ - async insertContent(query: string, options?: { position?: 'before' | 'after' | 'replace' }): Promise { + async insertContent( + query: string, + options?: { position?: 'before' | 'after' | 'replace'; contentType?: 'html' | 'markdown' | 'text' }, + ): Promise { if (!validateInput(query)) { throw new Error('Query cannot be empty'); } @@ -786,10 +822,15 @@ export class AIActionsService { return { success: false, results: [] }; } + const contentType = options?.contentType; + const isFormattedContent = contentType === 'html' || contentType === 'markdown'; + const documentContext = this.getDocumentContext(); const prompt = buildInsertContentPrompt(query, documentContext); - const useStreaming = this.streamPreference !== false; + // Disable streaming for non-text content types — partial HTML/markdown + // fragments will produce broken DOM parsing results. + const useStreaming = !isFormattedContent && this.streamPreference !== false; let streamingInsertedLength = 0; const insertionMode = options?.position === 'before' || options?.position === 'after' ? options.position : 'replace'; @@ -834,18 +875,25 @@ export class AIActionsService { return { success: false, results: [] }; } - // Strip list prefixes from suggested text while preserving leading whitespace - const strippedText = stripListPrefix(suggestedResult.suggestedText); + let finalText: string; - if (useStreaming && insertionMode === 'replace') { - const decoded = strippedText; - if (streamingInsertedLength < decoded.length) { - this.adapter.insertText(decoded.slice(streamingInsertedLength), { position: insertionMode }); - } - this.onStreamChunk?.(decoded); + if (isFormattedContent) { + // For HTML/markdown, use the raw text — stripListPrefix would break markdown + // list syntax (e.g., `- item`) and could mangle HTML structure. + finalText = suggestedResult.suggestedText; + this.adapter.insertFormattedContent(finalText, { position: insertionMode, contentType }); + this.onStreamChunk?.(finalText); } else { - this.adapter.insertText(strippedText, { position: insertionMode }); - this.onStreamChunk?.(strippedText); + // Strip list prefixes only on the plain-text path + finalText = stripListPrefix(suggestedResult.suggestedText); + if (useStreaming && insertionMode === 'replace') { + if (streamingInsertedLength < finalText.length) { + this.adapter.insertText(finalText.slice(streamingInsertedLength), { position: insertionMode }); + } + } else { + this.adapter.insertText(finalText, { position: insertionMode }); + } + this.onStreamChunk?.(finalText); } return { @@ -853,7 +901,7 @@ export class AIActionsService { results: [ { ...suggestedResult, - suggestedText: strippedText, + suggestedText: finalText, }, ], }; diff --git a/packages/ai/src/ai-actions/tools/builtin/content-tools.ts b/packages/ai/src/ai-actions/tools/builtin/content-tools.ts index 2116cb1d9b..d920892440 100644 --- a/packages/ai/src/ai-actions/tools/builtin/content-tools.ts +++ b/packages/ai/src/ai-actions/tools/builtin/content-tools.ts @@ -22,13 +22,17 @@ export function createInsertContentTool(actions: AIToolActions): AIToolDefinitio const args = step.args ?? {}; const position: 'before' | 'after' | 'replace' = args.position === 'before' || args.position === 'after' ? args.position : 'replace'; + const contentType = + args.contentType === 'html' || args.contentType === 'markdown' || args.contentType === 'text' + ? args.contentType + : undefined; const action = actions.insertContent; if (typeof action !== 'function') { throw new Error(ERROR_MESSAGES.ACTION_NOT_AVAILABLE('insertContent')); } - const result: Result = await action(instruction, { position }); + const result: Result = await action(instruction, { position, contentType }); return { success: Boolean(result?.success), data: result, diff --git a/packages/ai/src/ai-actions/tools/builtin/replace-tools.ts b/packages/ai/src/ai-actions/tools/builtin/replace-tools.ts index e8513be3ee..53da594ee6 100644 --- a/packages/ai/src/ai-actions/tools/builtin/replace-tools.ts +++ b/packages/ai/src/ai-actions/tools/builtin/replace-tools.ts @@ -53,6 +53,10 @@ export function createLiteralReplaceTool(actions: AIToolActions): AIToolDefiniti const replaceText = replaceTextProvided ? (args.replace as string) : ''; const caseSensitive = Boolean(args.caseSensitive); const trackChanges = Boolean(args.trackChanges); + const contentType = + args.contentType === 'html' || args.contentType === 'markdown' || args.contentType === 'text' + ? args.contentType + : undefined; if (!findText.trim()) { return { @@ -79,6 +83,7 @@ export function createLiteralReplaceTool(actions: AIToolActions): AIToolDefiniti const result: Result = await action(findText, replaceText, { caseSensitive, trackChanges, + contentType, }); return { diff --git a/packages/ai/src/ai-actions/tools/types.ts b/packages/ai/src/ai-actions/tools/types.ts index be08b7ece3..73aea8ac33 100644 --- a/packages/ai/src/ai-actions/tools/types.ts +++ b/packages/ai/src/ai-actions/tools/types.ts @@ -50,7 +50,7 @@ export interface AIToolActions { literalReplace: ( findText: string, replaceText: string, - options?: { caseSensitive?: boolean; trackChanges?: boolean }, + options?: { caseSensitive?: boolean; trackChanges?: boolean; contentType?: 'html' | 'markdown' | 'text' }, ) => Promise; insertTrackedChanges: (instruction: string) => Promise; insertComments: (instruction: string) => Promise; @@ -60,7 +60,10 @@ export interface AIToolActions { options?: { caseSensitive?: boolean }, ) => Promise; summarize: (instruction: string) => Promise; - insertContent: (instruction: string, options?: { position?: 'before' | 'after' | 'replace' }) => Promise; + insertContent: ( + instruction: string, + options?: { position?: 'before' | 'after' | 'replace'; contentType?: 'html' | 'markdown' | 'text' }, + ) => Promise; } /** diff --git a/packages/ai/src/shared/types.ts b/packages/ai/src/shared/types.ts index ae7f0c5890..4c7362c34c 100644 --- a/packages/ai/src/shared/types.ts +++ b/packages/ai/src/shared/types.ts @@ -145,12 +145,15 @@ export interface AIToolActions { literalReplace: ( findText: string, replaceText: string, - options?: { caseSensitive?: boolean; trackChanges?: boolean }, + options?: { caseSensitive?: boolean; trackChanges?: boolean; contentType?: 'html' | 'markdown' | 'text' }, ) => Promise; insertTrackedChanges: (instruction: string) => Promise; insertComments: (instruction: string) => Promise; summarize: (instruction: string) => Promise; - insertContent: (instruction: string, options?: { position?: 'before' | 'after' | 'replace' }) => Promise; + insertContent: ( + instruction: string, + options?: { position?: 'before' | 'after' | 'replace'; contentType?: 'html' | 'markdown' | 'text' }, + ) => Promise; } /** diff --git a/packages/super-editor/src/tests/insertContent-links.test.js b/packages/super-editor/src/tests/insertContent-links.test.js new file mode 100644 index 0000000000..2ec00ff0b0 --- /dev/null +++ b/packages/super-editor/src/tests/insertContent-links.test.js @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest'; +import { loadTestDataForEditorTests, initTestEditor } from './helpers/helpers.js'; +import { getExportedResultWithDocContent } from './export/export-helpers/index.js'; + +/** + * Regression tests for insertContent({ contentType: 'html' }) hyperlink handling. + * + * Validates that HTML with tags inserted via the contentType: 'html' path + * produces proper ProseMirror link marks and exports as in OOXML. + */ + +/** Collect link marks and literal HTML from editor state */ +const inspectEditorState = (editor) => { + const linkMarks = []; + const literalHtml = []; + editor.state.doc.descendants((node, pos) => { + if (!node.isText) return; + const link = node.marks.find((m) => m.type.name === 'link'); + if (link) linkMarks.push({ pos, text: node.text, href: link.attrs.href }); + if (/ elements in exported OOXML */ +const findHyperlinks = (node) => { + const results = []; + if (!node || !node.elements) return results; + for (const el of node.elements) { + if (el.name === 'w:hyperlink') { + results.push(el); + } + if (el.elements) { + results.push(...findHyperlinks(el)); + } + } + return results; +}; + +/** Extract text from a element */ +const getHyperlinkText = (hyperlink) => { + const texts = []; + const walk = (el) => { + if (!el) return; + if (el.type === 'text' && typeof el.text === 'string') { + texts.push(el.text); + } + if (el.elements) el.elements.forEach(walk); + }; + walk(hyperlink); + return texts.join(''); +}; + +let cachedDocxData = null; + +const setupEditor = async () => { + if (!cachedDocxData) { + cachedDocxData = await loadTestDataForEditorTests('blank-doc.docx'); + } + const { docx, media, mediaFiles, fonts } = cachedDocxData; + const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts, mode: 'docx' }); + return editor; +}; + +const exportFromEditor = async (editor) => { + const content = editor.getJSON().content || []; + return await getExportedResultWithDocContent(content); +}; + +describe('insertContent links (contentType: html)', () => { + describe('single link', () => { + it('produces a link mark in ProseMirror state', async () => { + const editor = await setupEditor(); + const input = '

See [27] today

'; + + editor.commands.insertContent(input, { contentType: 'html' }); + await Promise.resolve(); + + const { linkMarks, literalHtml } = inspectEditorState(editor); + expect(linkMarks.length).toBeGreaterThanOrEqual(1); + expect(linkMarks[0].href).toBe('https://example.com'); + expect(linkMarks[0].text).toBe('[27]'); + expect(literalHtml).toHaveLength(0); + }); + + it('exports with r:id', async () => { + const editor = await setupEditor(); + editor.commands.insertContent('

See [27] today

', { + contentType: 'html', + }); + await Promise.resolve(); + + const result = await exportFromEditor(editor); + const hyperlinks = findHyperlinks(result); + expect(hyperlinks.length).toBeGreaterThanOrEqual(1); + expect(hyperlinks[0].attributes?.['r:id']).toBeTruthy(); + expect(getHyperlinkText(hyperlinks[0])).toBe('[27]'); + }); + }); + + describe('multi-paragraph with multiple links', () => { + it('all links produce link marks', async () => { + const editor = await setupEditor(); + const input = ` +

First link one text

+

Second link two text

+

Third link three text

+ `; + + editor.commands.insertContent(input, { contentType: 'html' }); + await Promise.resolve(); + + const { linkMarks, literalHtml } = inspectEditorState(editor); + expect(linkMarks).toHaveLength(3); + expect(linkMarks.map((m) => m.href)).toEqual(['https://one.com', 'https://two.com', 'https://three.com']); + expect(literalHtml).toHaveLength(0); + }); + + it('all links export as with r:id', async () => { + const editor = await setupEditor(); + const input = ` +

First link one text

+

Second link two text

+

Third link three text

+ `; + + editor.commands.insertContent(input, { contentType: 'html' }); + await Promise.resolve(); + + const result = await exportFromEditor(editor); + const hyperlinks = findHyperlinks(result); + expect(hyperlinks).toHaveLength(3); + hyperlinks.forEach((hl) => { + expect(hl.attributes?.['r:id']).toBeTruthy(); + }); + }); + }); +});