Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 }))
}
}
Comment on lines +258 to +262

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

🧩 Analysis chain

🏁 Script executed:

rg -n "onSelectionChange" --type ts --type tsx -A 5

Repository: koreyba/EverFreeNote

Length of output: 91


🏁 Script executed:

rg -n "onSelectionChange" -A 5

Repository: koreyba/EverFreeNote

Length of output: 6949


🏁 Script executed:

sed -n '140,160p' ui/web/components/RichTextEditorWebView.tsx

Repository: koreyba/EverFreeNote

Length of output: 565


🏁 Script executed:

sed -n '250,280p' app/editor-webview/page.tsx

Repository: koreyba/EverFreeNote

Length of output: 773


Cache the previous selection state to avoid high-frequency postMessage calls

handleSelectionChange posts a SELECTION_CHANGE message on every invocation. However, TipTap's onSelectionUpdate event fires on every cursor movement or selection change, and onSelectionChange?.(from !== to) is called for each event. This results in postMessage calls even when the boolean value hasn't changed (e.g., moving the cursor while maintaining a selection will trigger many messages with identical payloads).

Store the previous hasSelection value in a ref and only post when the boolean state actually transitions to avoid unnecessary native layer communication:

Suggested pattern
const prevSelectionRef = useRef<boolean | null>(null)
const handleSelectionChange = (hasSelection: boolean) => {
  if (prevSelectionRef.current !== hasSelection) {
    prevSelectionRef.current = hasSelection
    if (window.ReactNativeWebView) {
      window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'SELECTION_CHANGE', payload: hasSelection }))
    }
  }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/editor-webview/page.tsx` around lines 258 - 262, handleSelectionChange
currently posts a SELECTION_CHANGE on every invocation causing noisy native
messages; add a ref (e.g., prevSelectionRef using React.useRef<boolean | null>)
to cache the previous hasSelection value and only call
window.ReactNativeWebView.postMessage when prevSelectionRef.current !==
hasSelection, updating prevSelectionRef.current after posting; this change
should be applied around the existing handleSelectionChange function and used
with the onSelectionUpdate/onSelectionChange flow so duplicate boolean payloads
are suppressed.


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

Align test title with the actual key used.

The test name says “pressing Escape,” but Line 38 sends {end}. This makes failures harder to interpret.

✏️ Proposed fix
-    it('becomes disabled again after deselecting (pressing Escape)', () => {
+    it('becomes disabled again after deselecting (pressing 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 deselecting (pressing 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, Test title and keystroke are inconsistent: update the spec inside the it
block to make them match by either changing the test name in the it(...) call to
reflect pressing End, or (preferably) send the Escape key with
cy.get('.ProseMirror').type('{esc}') so the test title "becomes disabled again
after deselecting (pressing Escape)" aligns with the action; adjust the .type
call that currently sends '{end}' to '{esc}' and keep existing helpers like
mountEditor, '.ProseMirror', and '[data-cy="apply-markdown-button"]' unchanged.

})
})

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