Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/editor-webview/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,12 @@ export default function EditorWebViewPage() {
}
}

const handleSelectionChange = (hasSelection: boolean) => {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'SELECTION_CHANGE', payload: hasSelection }))
}
}

return (
<div className="h-screen w-screen overflow-auto bg-background">
<RichTextEditorWebView
Expand All @@ -263,6 +269,7 @@ export default function EditorWebViewPage() {
onContentChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
onSelectionChange={handleSelectionChange}
/>
</div>
)
Expand Down
82 changes: 48 additions & 34 deletions core/services/smartPaste.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,46 +95,60 @@ export const SmartPasteService = {
return { type: 'plain', confidence: 0.6, reasons, warnings }
},

resolvePaste(payload: PastePayload, options: SmartPasteOptions = {}): PasteResult {
resolvePaste(
payload: PastePayload,
options: SmartPasteOptions = {},
forcedType?: PasteType,
): PasteResult {
const config = { ...DEFAULT_OPTIONS, ...options }
const detection = SmartPasteService.detectPasteType(payload, config)
const warnings = [...detection.warnings]

try {
if (detection.type === 'html' && payload.html) {
const sanitized = sanitizePasteHtml(payload.html)
const html = unwrapSingleParagraph(sanitized)
return { html, type: 'html', warnings, detection }
const detection: PasteDetection = forcedType
? { type: forcedType, confidence: 1.0, reasons: ['forced-by-user'], warnings: [] }
: SmartPasteService.detectPasteType(payload, config)
return resolvePasteInternal(payload, detection, config)
},
}

function resolvePasteInternal(
payload: PastePayload,
detection: PasteDetection,
config: Required<SmartPasteOptions>,
): PasteResult {
const warnings = [...detection.warnings]

try {
if (detection.type === 'html' && payload.html) {
const sanitized = sanitizePasteHtml(payload.html)
const html = unwrapSingleParagraph(sanitized)
return { html, type: 'html', warnings, detection }
}

if (detection.type === 'markdown' && payload.text) {
if (payload.text.length > config.maxLength) {
warnings.push('plain:oversized-text')
const html = plainTextToHtml(payload.text)
return { html: sanitizePasteHtml(html), type: 'plain', warnings, detection }
}

if (detection.type === 'markdown' && payload.text) {
if (payload.text.length > config.maxLength) {
warnings.push('plain:oversized-text')
const html = plainTextToHtml(payload.text)
return { html: sanitizePasteHtml(html), type: 'plain', warnings, detection }
}

if (containsUnsupportedMarkdown(payload.text)) {
warnings.push('plain:unsupported-markdown')
// Fallback to plain text wrapper without stripping characters (Strategy 2)
const html = plainTextToHtml(payload.text)
return { html: sanitizePasteHtml(html), type: 'plain', warnings, detection }
}

const rendered = markdown.render(payload.text)
return { html: sanitizePasteHtml(rendered), type: 'markdown', warnings, detection }
if (containsUnsupportedMarkdown(payload.text)) {
warnings.push('plain:unsupported-markdown')
// Fallback to plain text wrapper without stripping characters (Strategy 2)
const html = plainTextToHtml(payload.text)
return { html: sanitizePasteHtml(html), type: 'plain', warnings, detection }
}

const text = payload.text ?? SanitizationService.stripHtml(payload.html ?? '')
const plainHtml = plainTextToHtml(text)
return { html: sanitizePasteHtml(plainHtml), type: 'plain', warnings, detection }
} catch {
warnings.push('plain:parse-failed')
const fallbackText = payload.text ?? safeStripHtml(payload.html ?? '')
const fallbackHtml = plainTextToHtml(fallbackText)
return { html: fallbackHtml, type: 'plain', warnings, detection }
const rendered = markdown.render(payload.text)
return { html: sanitizePasteHtml(rendered), type: 'markdown', warnings, detection }
}
},

const text = payload.text ?? SanitizationService.stripHtml(payload.html ?? '')
const plainHtml = plainTextToHtml(text)
return { html: sanitizePasteHtml(plainHtml), type: 'plain', warnings, detection }
} catch {
warnings.push('plain:parse-failed')
const fallbackText = payload.text ?? safeStripHtml(payload.html ?? '')
const fallbackHtml = plainTextToHtml(fallbackText)
return { html: fallbackHtml, type: 'plain', warnings, detection }
}
}

function safeStripHtml(html: string): string {
Expand Down
5 changes: 5 additions & 0 deletions core/tests/fixtures/clipboard/force-markdown.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Project notes

- Buy milk
- Call dentist
- Review PR
110 changes: 110 additions & 0 deletions cypress/component/editor/RichTextEditorApplyMarkdown.cy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import React from 'react'
import RichTextEditor from '../../../ui/web/components/RichTextEditor'

function mountEditor(content = '') {
const spy = cy.spy().as('onChange')
cy.mount(
<RichTextEditor
initialContent={content}
onContentChange={spy}
/>
)
}

describe('RichTextEditor — Apply as Markdown button', () => {
describe('button state', () => {
it('renders the MD button in the toolbar', () => {
mountEditor('')
cy.get('[data-cy="apply-markdown-button"]').should('be.visible')
})

it('is disabled when no text is selected', () => {
mountEditor('<p>Some text</p>')
cy.get('[data-cy="apply-markdown-button"]').should('be.disabled')
})

it('becomes enabled after selecting text', () => {
mountEditor('<p>Some text</p>')
cy.get('[data-cy="editor-content"]').click()
cy.get('.ProseMirror').type('{selectall}')
cy.get('[data-cy="apply-markdown-button"]').should('not.be.disabled')
})

it('becomes disabled again after deselecting (pressing Escape)', () => {
mountEditor('<p>Some text</p>')
cy.get('[data-cy="editor-content"]').click()
cy.get('.ProseMirror').type('{selectall}')
cy.get('[data-cy="apply-markdown-button"]').should('not.be.disabled')
cy.get('.ProseMirror').type('{end}')
cy.get('[data-cy="apply-markdown-button"]').should('be.disabled')
Comment on lines +33 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Test description does not match the simulated key action.

The test says “pressing Escape” but sends {end}. Please align the title with the actual action (or switch the key event).

Suggested fix
-    it('becomes disabled again after deselecting (pressing Escape)', () => {
+    it('becomes disabled again after collapsing selection (moving caret to end)', () => {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('becomes disabled again after deselecting (pressing Escape)', () => {
mountEditor('<p>Some text</p>')
cy.get('[data-cy="editor-content"]').click()
cy.get('.ProseMirror').type('{selectall}')
cy.get('[data-cy="apply-markdown-button"]').should('not.be.disabled')
cy.get('.ProseMirror').type('{end}')
cy.get('[data-cy="apply-markdown-button"]').should('be.disabled')
it('becomes disabled again after collapsing selection (moving caret to end)', () => {
mountEditor('<p>Some text</p>')
cy.get('[data-cy="editor-content"]').click()
cy.get('.ProseMirror').type('{selectall}')
cy.get('[data-cy="apply-markdown-button"]').should('not.be.disabled')
cy.get('.ProseMirror').type('{end}')
cy.get('[data-cy="apply-markdown-button"]').should('be.disabled')
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cypress/component/editor/RichTextEditorApplyMarkdown.cy.tsx` around lines 33
- 39, The test title and simulated key action mismatch: the it(...) description
says "pressing Escape" but the test types '{end}' into '.ProseMirror'; update
either the test description or the simulated key to match: change the it(...)
title to "becomes disabled again after deselecting (pressing End)" if you intend
to keep cy.get('.ProseMirror').type('{end}'), or replace
cy.get('.ProseMirror').type('{end}') with cy.get('.ProseMirror').type('{esc}')
to simulate Escape; ensure the assertion around
'[data-cy="apply-markdown-button"]' remains correct after the change.

})
})

describe('markdown rendering', () => {
it('converts selected low-score markdown text to rendered list', () => {
// This text scores below the auto-detection threshold (bullet list only = 2pts < 3)
// so without forcedType it would be detected as plain text
mountEditor('<p>Project notes\n\n- Buy milk\n- Call dentist\n- Review PR</p>')

cy.get('[data-cy="editor-content"]').click()
cy.get('.ProseMirror').type('{selectall}')
cy.get('[data-cy="apply-markdown-button"]').click()

cy.get('[data-cy="editor-content"]').find('ul').should('exist')
cy.get('[data-cy="editor-content"]').find('li').should('have.length.gte', 3)
cy.get('[data-cy="editor-content"]').should('contain.text', 'Buy milk')
})

it('converts selected heading markdown to h1 element', () => {
mountEditor('<p># Main Title\n\nContent below</p>')

cy.get('[data-cy="editor-content"]').click()
cy.get('.ProseMirror').type('{selectall}')
cy.get('[data-cy="apply-markdown-button"]').click()

cy.get('[data-cy="editor-content"]').find('h1').should('exist').and('contain.text', 'Main Title')
})

it('strips XSS tags from forced markdown input', () => {
mountEditor('<p># Title\n\n<script>alert(1)</script>\n\nParagraph</p>')

cy.get('[data-cy="editor-content"]').click()
cy.get('.ProseMirror').type('{selectall}')
cy.get('[data-cy="apply-markdown-button"]').click()

cy.get('[data-cy="editor-content"]').should('not.contain.html', '<script>')
cy.get('[data-cy="editor-content"]').find('h1').should('exist')
cy.get('[data-cy="editor-content"]').should('contain.text', 'Paragraph')
})

it('calls onContentChange after applying markdown', () => {
mountEditor('<p>- Item one\n- Item two</p>')

cy.get('[data-cy="editor-content"]').click()
cy.get('.ProseMirror').type('{selectall}')
cy.get('[data-cy="apply-markdown-button"]').click()

cy.get('@onChange').should('have.been.called')
})

it('text without markdown syntax remains as paragraph without crashing', () => {
mountEditor('<p>Just plain text with no markdown</p>')

cy.get('[data-cy="editor-content"]').click()
cy.get('.ProseMirror').type('{selectall}')
cy.get('[data-cy="apply-markdown-button"]').click()

// No crash, text still present
cy.get('[data-cy="editor-content"]').should('contain.text', 'Just plain text with no markdown')
})
})

describe('early returns — no crash', () => {
it('clicking disabled button (no selection) does not modify content', () => {
mountEditor('<p>Unchanged</p>')
cy.get('[data-cy="apply-markdown-button"]').click({ force: true })
cy.get('[data-cy="editor-content"]').should('contain.text', 'Unchanged')
cy.get('@onChange').should('not.have.been.called')
})
})
})
Loading
Loading