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
11 changes: 11 additions & 0 deletions app/editor-webview/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,19 @@ export default function EditorWebViewPage() {
const [initialContent, setInitialContent] = useState('')
const editorRef = React.useRef<RichTextEditorWebViewHandle>(null)
const lastKnownHtmlRef = React.useRef<string | null>(null)
const lastHistoryStateRef = React.useRef<{ canUndo: boolean; canRedo: boolean } | null>(null)
const pendingBaselineRef = React.useRef(false)
const chunkBuffers = React.useRef<ChunkBufferStore>({})

const handleHistoryStateChange = React.useCallback((state: { canUndo: boolean; canRedo: boolean }) => {
const prev = lastHistoryStateRef.current
if (prev && prev.canUndo === state.canUndo && prev.canRedo === state.canRedo) {
return
}
lastHistoryStateRef.current = state
window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'HISTORY_STATE', payload: state }))
}, [])

useEffect(() => {
const getMobileConfig = () => {
const cfg = (window as unknown as { __EVERFREENOTE_MOBILE__?: { devHost?: string | null; supabaseUrl?: string | null; theme?: string | null } }).__EVERFREENOTE_MOBILE__
Expand Down Expand Up @@ -270,6 +280,7 @@ export default function EditorWebViewPage() {
onFocus={handleFocus}
onBlur={handleBlur}
onSelectionChange={handleSelectionChange}
onHistoryStateChange={handleHistoryStateChange}
/>
</div>
)
Expand Down
29 changes: 29 additions & 0 deletions cypress/component/RichTextEditorWebView.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,33 @@ describe('RichTextEditorWebView', () => {
expect(text.trim().endsWith('Y')).to.eq(true)
})
})

it('does not clear baseline content on first undo after setContent', () => {
const Harness = () => {
const ref = React.useRef<{
setContent: (html: string) => void
runCommand: (command: string) => void
} | null>(null)

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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's verify the handle contract in the component
rg -n "RichTextEditorWebViewHandle|getHTML|setContent|runCommand|useImperativeHandle" -A 5 -B 2

Repository: koreyba/EverFreeNote

Length of output: 47838


🏁 Script executed:

# Check the test file context around lines 145-170
fd "RichTextEditorWebView.cy.tsx" | head -1 | xargs cat -n | sed -n '140,175p'

Repository: koreyba/EverFreeNote

Length of output: 1173


🏁 Script executed:

# Find the RichTextEditorWebView component file
fd "RichTextEditorWebView.tsx" | grep -v "\.cy\."

Repository: koreyba/EverFreeNote

Length of output: 107


Ref type is incomplete and breaks type safety.

Lines 145–148 define a ref type with only setContent and runCommand, but RichTextEditorWebView (line 158) requires a handle with getHTML as well. This causes a type mismatch that will fail TypeScript checks.

Use React.ElementRef<typeof RichTextEditorWebView> to automatically derive the ref type from the component's forwardRef signature, preventing handle drift:

Suggested fix
-      const ref = React.useRef<{
-        setContent: (html: string) => void
-        runCommand: (command: string) => void
-      } | null>(null)
+      const ref = React.useRef<React.ElementRef<typeof RichTextEditorWebView> | null>(null)
📝 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
const ref = React.useRef<{
setContent: (html: string) => void
runCommand: (command: string) => void
} | null>(null)
const ref = React.useRef<React.ElementRef<typeof RichTextEditorWebView> | null>(null)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cypress/component/RichTextEditorWebView.cy.tsx` around lines 145 - 148, The
ref type currently only declares setContent and runCommand but
RichTextEditorWebView exposes getHTML too, causing a TypeScript mismatch; update
the ref declaration (the ref variable named ref) to derive its type from the
component's forwardRef by using React.ElementRef<typeof RichTextEditorWebView>
so the handle (including getHTML, setContent, runCommand, etc.) stays in sync
with RichTextEditorWebView's forwarded handle and preserve the initial null
value.


return (
<div>
<button data-cy="set-content" onClick={() => ref.current?.setContent('<p>Baseline</p>')}>
Set content
</button>
<button data-cy="undo" onClick={() => ref.current?.runCommand('undo')}>
Undo
</button>
<RichTextEditorWebView ref={ref} initialContent="" />
</div>
)
}

cy.mount(<Harness />)

cy.get('[data-cy="set-content"]').click()
cy.get('.ProseMirror').should('contain', 'Baseline')

cy.get('[data-cy="undo"]').click()
cy.get('.ProseMirror').should('contain', 'Baseline')
})
})
62 changes: 62 additions & 0 deletions cypress/component/editor/EditorWebViewPageBridge.cy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React from 'react'
import EditorWebViewPage from '../../../app/editor-webview/page'

describe('EditorWebViewPage bridge', () => {
it('deduplicates HISTORY_STATE messages sent to React Native', () => {
cy.window().then((win) => {
;(win as unknown as { ReactNativeWebView?: { postMessage: (msg: string) => void } }).ReactNativeWebView = {
postMessage: cy.stub().as('nativePostMessage'),
}
})

cy.mount(<EditorWebViewPage />)
cy.get('.ProseMirror').should('exist')

const extractHistoryStates = () => {
return cy.get('@nativePostMessage').then((stub) => {
const calls = (stub as sinon.SinonStub).getCalls()
return calls
.map((call) => {
try {
return JSON.parse(String(call.args[0]))
} catch {
return null
}
})
.filter((message): message is { type: string; payload: { canUndo: boolean; canRedo: boolean } } =>
Boolean(message && message.type === 'HISTORY_STATE')
)
.map((message) => [Boolean(message.payload.canUndo), Boolean(message.payload.canRedo)])
})
}

// Initial state should be emitted exactly once.
extractHistoryStates().then((states) => {
expect(states).to.deep.equal([[false, false]])
})

// Focus-only transaction keeps same history state and must not emit duplicate.
cy.get('.ProseMirror').click()
extractHistoryStates().then((states) => {
expect(states).to.deep.equal([[false, false]])
})

// First text input flips history to undo=true/redo=false and should emit once.
cy.get('.ProseMirror').type('A')
extractHistoryStates().then((states) => {
expect(states).to.deep.equal([
[false, false],
[true, false],
])
})

// More typing keeps same canUndo/canRedo and should not emit duplicate state.
cy.get('.ProseMirror').type('B')
extractHistoryStates().then((states) => {
expect(states).to.deep.equal([
[false, false],
[true, false],
])
})
})
})
201 changes: 201 additions & 0 deletions cypress/component/editor/RichTextEditor.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -984,4 +984,205 @@ describe('RichTextEditor Component', () => {
cy.get('[data-cy="editor-content"]').find('s').should('not.exist')
cy.get('[data-cy="editor-content"]').should('contain', 'Highlighted text')
})

describe('Undo/Redo buttons', () => {
it('renders undo and redo buttons, positioned before bold in toolbar', () => {
cy.mount(
<RichTextEditor initialContent="" onContentChange={cy.stub()} />
)

cy.get('[data-cy="undo-button"]').should('be.visible')
cy.get('[data-cy="redo-button"]').should('be.visible')

// Undo/Redo appear before Bold in the DOM
cy.get('[data-cy="undo-button"]').then(($undo) => {
cy.get('[data-cy="bold-button"]').then(($bold) => {
const position = $undo[0].compareDocumentPosition($bold[0])
expect(position & Node.DOCUMENT_POSITION_FOLLOWING).to.equal(Node.DOCUMENT_POSITION_FOLLOWING)
})
})
})

it('undo and redo buttons are disabled on empty editor (no history)', () => {
cy.mount(
<RichTextEditor initialContent="" onContentChange={cy.stub()} />
)

cy.get('[data-cy="undo-button"]').should('be.disabled')
cy.get('[data-cy="redo-button"]').should('be.disabled')
})

it('undo button becomes enabled after formatting; redo stays disabled', () => {
cy.mount(
<RichTextEditor initialContent="" onContentChange={cy.stub()} />
)

// cy.type() synthetic events don't populate ProseMirror history reliably.
// Use a toolbar button click (direct TipTap command) to create a history entry.
cy.get('[data-cy="editor-content"]').click()
cy.get('[data-cy="editor-content"]').type('Hello')
cy.get('[data-cy="editor-content"]').type('{selectall}')
cy.get('[data-cy="bold-button"]').click()

cy.get('[data-cy="undo-button"]').should('not.be.disabled')
cy.get('[data-cy="redo-button"]').should('be.disabled')
})

it('clicking undo reverts bold formatting', () => {
// TipTap 3.x: can().redo() is not reactive via useEditor — redo button may stay
// visually disabled even when ProseMirror redo stack is populated. Test behavior instead.
cy.mount(
<RichTextEditor initialContent="" onContentChange={cy.stub()} />
)

cy.get('[data-cy="editor-content"]').click()
cy.get('[data-cy="editor-content"]').type('Hello')
cy.get('[data-cy="editor-content"]').type('{selectall}')
cy.get('[data-cy="bold-button"]').click()
cy.get('[data-cy="editor-content"]').find('strong').should('exist')

cy.get('[data-cy="undo-button"]').click()
cy.get('[data-cy="editor-content"]').find('strong').should('not.exist')
})

it('keyboard undo reverts formatting; keyboard redo restores it', () => {
// The undo BUTTON calls editor.chain().focus().undo(), where focus() dispatches
// a transaction that clears the redo stack. Keyboard shortcuts bypass focus() and
// go directly through ProseMirror's keymap, keeping the redo stack intact.
cy.mount(
<RichTextEditor initialContent="" onContentChange={cy.stub()} />
)

cy.get('[data-cy="editor-content"]').click()
cy.get('[data-cy="editor-content"]').type('Hello')
cy.get('[data-cy="editor-content"]').type('{selectall}')
cy.get('[data-cy="bold-button"]').click()
cy.get('[data-cy="editor-content"]').find('strong').should('exist')

cy.get('[data-cy="editor-content"]').type('{ctrl}z')
cy.get('[data-cy="editor-content"]').find('strong').should('not.exist')

cy.get('[data-cy="editor-content"]').type('{ctrl}y')
cy.get('[data-cy="editor-content"]').find('strong').should('exist')
})

it('redo button restores formatting after keyboard undo', () => {
cy.mount(
<RichTextEditor initialContent="" onContentChange={cy.stub()} />
)

cy.get('[data-cy="editor-content"]').click()
cy.get('[data-cy="editor-content"]').type('Hello')
cy.get('[data-cy="editor-content"]').type('{selectall}')
cy.get('[data-cy="bold-button"]').click()
cy.get('[data-cy="editor-content"]').find('strong').should('exist')

// Use keyboard undo to preserve redo stack in TipTap 3.x
cy.get('[data-cy="editor-content"]').type('{ctrl}z')
cy.get('[data-cy="editor-content"]').find('strong').should('not.exist')

// Force a React re-render without mutating history entries:
// toggle selection state only so disabled={!editor.can().redo()} is recalculated.
cy.get('[data-cy="editor-content"]').type('{selectall}')
cy.get('[data-cy="editor-content"]').type('{rightarrow}')

cy.get('[data-cy="redo-button"]').should('not.be.disabled')
cy.get('[data-cy="redo-button"]').click()
cy.get('[data-cy="editor-content"]').find('strong').should('exist')
})

it('redo button becomes disabled after redoing the latest undone step', () => {
cy.mount(
<RichTextEditor initialContent="" onContentChange={cy.stub()} />
)

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

// Use keyboard undo to preserve redo stack in TipTap 3.x
cy.get('[data-cy="editor-content"]').type('{ctrl}z')

// Re-render toolbar state via selection-only changes (no history mutation).
cy.get('[data-cy="editor-content"]').type('{selectall}')
cy.get('[data-cy="editor-content"]').type('{rightarrow}')

cy.get('[data-cy="redo-button"]').should('not.be.disabled')

cy.get('[data-cy="redo-button"]').click()
cy.get('[data-cy="editor-content"]').find('strong').should('exist')
cy.get('[data-cy="redo-button"]').should('be.disabled')
})

it('redo button reapplies multiple formatting steps in order', () => {
cy.mount(
<RichTextEditor initialContent="" onContentChange={cy.stub()} />
)

cy.get('[data-cy="editor-content"]').click()
cy.get('[data-cy="editor-content"]').type('Hello')
cy.get('[data-cy="editor-content"]').type('{selectall}')
cy.get('[data-cy="bold-button"]').click()
cy.get('[data-cy="italic-button"]').click()
cy.get('[data-cy="editor-content"]').find('strong').should('exist')
cy.get('[data-cy="editor-content"]').find('em').should('exist')

// Use keyboard undo to preserve redo stack in TipTap 3.x
cy.get('[data-cy="editor-content"]').type('{ctrl}z')
cy.get('[data-cy="editor-content"]').type('{ctrl}z')
cy.get('[data-cy="editor-content"]').find('strong').should('not.exist')
cy.get('[data-cy="editor-content"]').find('em').should('not.exist')

// Re-render toolbar state via selection-only changes (no history mutation).
cy.get('[data-cy="editor-content"]').type('{selectall}')
cy.get('[data-cy="editor-content"]').type('{rightarrow}')

cy.get('[data-cy="redo-button"]').should('not.be.disabled')
cy.get('[data-cy="redo-button"]').click()
cy.get('[data-cy="editor-content"]').find('strong').should('exist')
cy.get('[data-cy="editor-content"]').find('em').should('not.exist')

cy.get('[data-cy="redo-button"]').should('not.be.disabled')
cy.get('[data-cy="redo-button"]').click()
cy.get('[data-cy="editor-content"]').find('strong').should('exist')
cy.get('[data-cy="editor-content"]').find('em').should('exist')
})

it('undo button has correct tooltip text', () => {
cy.mount(
<RichTextEditor initialContent="" onContentChange={cy.stub()} />
)

// Enable undo button
cy.get('[data-cy="editor-content"]').click()
cy.get('[data-cy="editor-content"]').type('Hello')
cy.get('[data-cy="editor-content"]').type('{selectall}')
cy.get('[data-cy="bold-button"]').click()
cy.get('[data-cy="undo-button"]').should('not.be.disabled')

// Radix Tooltip opens immediately on focus (no delayDuration)
cy.get('[data-cy="undo-button"]').focus()
cy.get('[role="tooltip"]').should('contain', 'Undo (Ctrl+Z)')
})

it('redo button has correct tooltip text', () => {
cy.mount(
<RichTextEditor initialContent="" onContentChange={cy.stub()} />
)

// Enable redo by using keyboard undo (bypasses button's focus() chain that
// clears the redo stack). After keyboard undo, TipTap correctly enables redo.
cy.get('[data-cy="editor-content"]').click()
cy.get('[data-cy="editor-content"]').type('Hello')
cy.get('[data-cy="editor-content"]').type('{selectall}')
cy.get('[data-cy="bold-button"]').click()
cy.get('[data-cy="editor-content"]').type('{ctrl}z')
cy.get('[data-cy="redo-button"]').should('not.be.disabled')

// Radix Tooltip opens immediately on focus (no delayDuration)
cy.get('[data-cy="redo-button"]').focus()
cy.get('[role="tooltip"]').should('contain', 'Redo (Ctrl+Shift+Z)')
})
})
})
4 changes: 0 additions & 4 deletions cypress/component/features/notes/NoteEditor.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,12 +147,8 @@ describe('NoteEditor Component', () => {

// INPUT_DEBOUNCE_MS (250) + autosaveDelayMs (200) = 450ms

// Wait a bit less
// cy.wait(300)
cy.get('@onAutoSave').should('not.have.been.called', { timeout: 500 })

// Wait enough
//cy.wait(1000)
cy.get('@onAutoSave').should('have.been.calledOnce', { timeout: 1500 })
cy.get('@onAutoSave').should('have.been.calledWith', Cypress.sinon.match({
noteId: 'note-1',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ describe('NoteList Virtualization', () => {
cy.get('div[style*="overflow"]').should('exist').scrollTo('bottom', { ensureScrollable: false, duration: 100 })

// Wait for virtualization to catch up
//cy.wait(200)

// Now the last note should be visible
cy.contains('Note 99').should('be.visible')
Expand Down
Loading
Loading