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
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
28 changes: 27 additions & 1 deletion cypress/component/RichTextEditorWebView.cy.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react'
import RichTextEditorWebView from '../../ui/web/components/RichTextEditorWebView'
import RichTextEditorWebView, { type RichTextEditorWebViewHandle } from '../../ui/web/components/RichTextEditorWebView'

describe('RichTextEditorWebView', () => {
it('renders with full screen height and captures clicks below content', () => {
Expand Down Expand Up @@ -139,4 +139,30 @@ 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<RichTextEditorWebViewHandle | null>(null)

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')
})
})
133 changes: 133 additions & 0 deletions cypress/component/editor/EditorWebViewPageBridge.cy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import React from 'react'
import EditorWebViewPage from '../../../app/editor-webview/page'

describe('EditorWebViewPage bridge', () => {
it('deduplicates HISTORY_STATE messages sent to React Native', () => {
const nativePostMessage = cy.stub().as('nativePostMessage')

cy.window().then((win) => {
;(win as unknown as { ReactNativeWebView?: { postMessage: (msg: string) => void } }).ReactNativeWebView = {
postMessage: nativePostMessage,
}
})

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

const readHistoryStates = () => {
const calls = nativePostMessage.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)] as [boolean, boolean])
}

// Initial state should be emitted exactly once.
cy.wrap(null, { log: false }).should(() => {
expect(readHistoryStates()).to.deep.equal([[false, false]])
})

// Focus-only transaction keeps same history state and must not emit duplicate.
cy.get('.ProseMirror').click()
cy.wrap(null, { log: false }).should(() => {
expect(readHistoryStates()).to.deep.equal([[false, false]])
})

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

// More typing keeps same canUndo/canRedo and should not emit duplicate state.
cy.get('.ProseMirror').type('B')
cy.wrap(null, { log: false }).should(() => {
expect(readHistoryStates()).to.deep.equal([
[false, false],
[true, false],
])
})
})

it('emits HISTORY_STATE on each state transition and deduplicates only consecutive duplicates', () => {
const nativePostMessage = cy.stub().as('nativePostMessage')

cy.window().then((win) => {
;(win as unknown as { ReactNativeWebView?: { postMessage: (msg: string) => void } }).ReactNativeWebView = {
postMessage: nativePostMessage,
}
})

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

const readHistoryStates = () => {
const calls = nativePostMessage.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)] as [boolean, boolean])
}

cy.wrap(null, { log: false }).should(() => {
expect(readHistoryStates()).to.deep.equal([[false, false]])
})

// Change 1: typing enables undo.
cy.get('.ProseMirror').type('A')
cy.wrap(null, { log: false }).should(() => {
expect(readHistoryStates()).to.deep.equal([
[false, false],
[true, false],
])
})

// Duplicate state: more typing keeps [true, false], should not append.
cy.get('.ProseMirror').type('B')
cy.wrap(null, { log: false }).should(() => {
expect(readHistoryStates()).to.deep.equal([
[false, false],
[true, false],
])
})

// Change 2: undo must emit a new state (not a duplicate of [true, false]).
cy.get('.ProseMirror').type('{ctrl}z')
cy.wrap(null, { log: false }).should(() => {
const states = readHistoryStates()
expect(states).to.have.length(3)
expect(states[0]).to.deep.equal([false, false])
expect(states[1]).to.deep.equal([true, false])
expect(states[2][1]).to.equal(true) // redo must become available after undo
})

// Change 3: redo returns to [true, false] and must be emitted again (non-consecutive repeat).
cy.get('.ProseMirror').type('{ctrl}y')
cy.wrap(null, { log: false }).should(() => {
const states = readHistoryStates()
expect(states).to.have.length(4)
expect(states[0]).to.deep.equal([false, false])
expect(states[1]).to.deep.equal([true, false])
expect(states[3]).to.deep.equal([true, false])
})
})
})
184 changes: 184 additions & 0 deletions cypress/component/editor/RichTextEditor.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -984,4 +984,188 @@ 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', () => {
// Keyboard path should remain fully functional alongside toolbar buttons.
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')

// Create one redo entry.
cy.get('[data-cy="editor-content"]').type('{ctrl}z')
cy.get('[data-cy="editor-content"]').find('strong').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')
})

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()

// Create one redo entry.
cy.get('[data-cy="editor-content"]').type('{ctrl}z')
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')

// Create two redo entries.
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')

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 creating an undone step first.
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)')
})
})
})
Loading