diff --git a/app/editor-webview/page.tsx b/app/editor-webview/page.tsx index d8a77183cc5..d17b341b044 100644 --- a/app/editor-webview/page.tsx +++ b/app/editor-webview/page.tsx @@ -17,9 +17,19 @@ export default function EditorWebViewPage() { const [initialContent, setInitialContent] = useState('') const editorRef = React.useRef(null) const lastKnownHtmlRef = React.useRef(null) + const lastHistoryStateRef = React.useRef<{ canUndo: boolean; canRedo: boolean } | null>(null) const pendingBaselineRef = React.useRef(false) const chunkBuffers = React.useRef({}) + 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__ @@ -270,6 +280,7 @@ export default function EditorWebViewPage() { onFocus={handleFocus} onBlur={handleBlur} onSelectionChange={handleSelectionChange} + onHistoryStateChange={handleHistoryStateChange} /> ) diff --git a/cypress/component/RichTextEditorWebView.cy.tsx b/cypress/component/RichTextEditorWebView.cy.tsx index 47d62d34f84..d7e10dd5317 100644 --- a/cypress/component/RichTextEditorWebView.cy.tsx +++ b/cypress/component/RichTextEditorWebView.cy.tsx @@ -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', () => { @@ -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(null) + + return ( +
+ + + +
+ ) + } + + cy.mount() + + 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') + }) }) diff --git a/cypress/component/editor/EditorWebViewPageBridge.cy.tsx b/cypress/component/editor/EditorWebViewPageBridge.cy.tsx new file mode 100644 index 00000000000..47138cd6456 --- /dev/null +++ b/cypress/component/editor/EditorWebViewPageBridge.cy.tsx @@ -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() + 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() + 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]) + }) + }) +}) diff --git a/cypress/component/editor/RichTextEditor.cy.tsx b/cypress/component/editor/RichTextEditor.cy.tsx index e818067dc7d..0b5725e2043 100644 --- a/cypress/component/editor/RichTextEditor.cy.tsx +++ b/cypress/component/editor/RichTextEditor.cy.tsx @@ -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( + + ) + + 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( + + ) + + 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( + + ) + + // 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( + + ) + + 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( + + ) + + 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( + + ) + + 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( + + ) + + 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( + + ) + + 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( + + ) + + // 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( + + ) + + // 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)') + }) + }) }) diff --git a/cypress/component/features/notes/NoteEditor.cy.tsx b/cypress/component/features/notes/NoteEditor.cy.tsx index 6b4d9f16537..f25eb6eb5c2 100644 --- a/cypress/component/features/notes/NoteEditor.cy.tsx +++ b/cypress/component/features/notes/NoteEditor.cy.tsx @@ -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', @@ -319,6 +315,61 @@ describe('NoteEditor Component', () => { cy.wrap(onAutoSave).should('have.been.called') }) + it('resets editor undo/redo history when switching between different notes', () => { + function Wrapper() { + const [active, setActive] = React.useState<'a' | 'b'>('a') + const note = active === 'a' + ? { + id: 'note-a', + title: 'Note A', + description: '

Alpha body

', + tags: 'alpha', + } + : { + id: 'note-b', + title: 'Note B', + description: '

Beta body

', + tags: 'beta', + } + + return ( +
+ + undefined} + onRead={() => undefined} + /> +
+ ) + } + + cy.mount() + + cy.get('[data-cy="editor-content"]').should('contain.text', 'Alpha body') + cy.get('[data-cy="undo-button"]').should('be.disabled') + cy.get('[data-cy="redo-button"]').should('be.disabled') + + // Create history in note A. + cy.get('[data-cy="editor-content"]').click().type('{end} edited') + cy.get('[data-cy="undo-button"]').should('not.be.disabled') + + // Switch to note B and verify new editor session starts with clean history. + cy.get('[data-cy="switch-note"]').click() + cy.get('input[placeholder="Note title"]').should('have.value', 'Note B') + cy.get('[data-cy="editor-content"]').should('contain.text', 'Beta body') + cy.get('[data-cy="editor-content"]').should('not.contain.text', 'edited') + cy.get('[data-cy="undo-button"]').should('be.disabled') + cy.get('[data-cy="redo-button"]').should('be.disabled') + }) + it('shows export button when WordPress is configured and note has id', () => { const props = { ...getDefaultProps(), diff --git a/cypress/component/features/notes/NoteListVirtualization.cy.tsx b/cypress/component/features/notes/NoteListVirtualization.cy.tsx index 2cb9d869646..f53a80dd4fc 100644 --- a/cypress/component/features/notes/NoteListVirtualization.cy.tsx +++ b/cypress/component/features/notes/NoteListVirtualization.cy.tsx @@ -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') diff --git a/docs/ai/design/feature-editor-undo-redo.md b/docs/ai/design/feature-editor-undo-redo.md new file mode 100644 index 00000000000..77275f0cf60 --- /dev/null +++ b/docs/ai/design/feature-editor-undo-redo.md @@ -0,0 +1,158 @@ +--- +phase: design +title: System Design & Architecture +description: Define the technical architecture, components, and data models +--- + +# System Design & Architecture — Editor Undo/Redo Controls + +## Architecture Overview +**What is the high-level system structure?** + +```mermaid +graph TD + subgraph "Web (Next.js)" + NE[NoteEditor.tsx] --> RTE[RichTextEditor.tsx] + RTE --> MB[MenuBar component] + MB --> UB[Undo Button] + MB --> RB[Redo Button] + UB -->|editor.chain.undo| TT[TipTap History] + RB -->|editor.chain.redo| TT + TT -->|onUpdate| OCC[onContentChange] + OCC -->|schedule| DAS[debouncedAutoSave] + end + + subgraph "Mobile Native (React Native)" + NS[note/[id].tsx] --> SH[Stack.Screen headerLeft] + NS --> EWV[EditorWebView.tsx] + SH -->|editorRef.runCommand undo/redo| NS + NS -->|runCommand via ref| EWV + EWV -->|postMessage WebView bridge| RTEWV[RichTextEditorWebView.tsx] + RTEWV -->|editor.chain.undo/redo| TT2[TipTap History] + TT2 -->|onUpdate| OCC2[onContentChange] + OCC2 -->|schedule| DAS2[debouncedAutoSave] + end +``` + +- История редактирования полностью управляется TipTap History (через StarterKit). +- Веб: кнопки рендерятся напрямую в `MenuBar`, имеют доступ к `editor` объекту. +- Мобайл: команды проходят через существующий WebView bridge (`runCommand`). + +## Data Models +**What data do we need to manage?** + +Новых моделей данных не требуется. История живёт в памяти TipTap (ProseMirror transaction history) и сбрасывается при переходе между заметками по следующим причинам: + +- **Веб:** `NoteEditor` ремонтирует `RichTextEditor` через смену `key={editor-${inputResetKey}}` — создаётся новый экземпляр TipTap с чистой историей. +- **Мобайл-нейтив:** `NoteEditorScreen` полностью монтируется заново при навигации на новую заметку, WebView перезагружается — история сбрасывается. + +Важно: `setContent` сам по себе **не** очищает ProseMirror history. Сброс обеспечивается именно ремонтированием компонента. + +## API Design +**How do components communicate?** + +### Веб +Прямой вызов TipTap API в `MenuBar`: +```ts +editor.chain().focus().undo().run() +editor.chain().focus().redo().run() +editor.can().undo() // для disabled state +editor.can().redo() // для disabled state +``` + +### Мобайл — WebView Bridge + +Полный путь команды: +``` +note/[id].tsx (Pressable.onPress) + → editorRef.current?.runCommand('undo') // EditorWebViewHandle + → EditorWebView.tsx: postMessage to WebView + → RichTextEditorWebView.tsx: runCommand handler + → editor.chain().focus()['undo']().run() // TipTap History +``` + +```ts +// В note/[id].tsx: +editorRef.current?.runCommand('undo') +editorRef.current?.runCommand('redo') +``` + +Generic-обработчик в `RichTextEditorWebView.tsx` поддерживает любую TipTap-команду по имени. +**Ограничение:** работают только команды **без обязательных аргументов** — `undo` и `redo` подходят. + +## Component Breakdown +**What are the major building blocks?** + +### Изменяемые файлы + +| Файл | Изменение | +|---|---| +| `ui/web/components/RichTextEditor.tsx` | Добавить Undo/Redo кнопки в начало `MenuBar` | +| `ui/mobile/app/note/[id].tsx` | Убрать `title: 'Edit'`, добавить `headerLeft` с back + undo/redo | +| `ui/mobile/components/EditorToolbar.tsx` | Не изменяется (undo/redo идут в header, не в toolbar) | +| `ui/web/components/RichTextEditorWebView.tsx` | Не изменяется (bridge уже поддерживает undo/redo) | + +### Новые файлы +Нет — изменения только в существующих компонентах. + +## Design Decisions +**Why did we choose this approach?** + +### Веб: тулбар, а не шапка +- Соответствует стандартным ожиданиям пользователей (Google Docs, Notion, Word). +- Тулбар sticky — кнопки всегда видны при скролле. +- В шапке (`Editing | Read | Save`) логика document-level actions, а не editor-level. + +### Мобайл-нейтив: шапка, а не тулбар +- `EditorToolbar` появляется только при открытой клавиатуре — undo/redo нужны всегда. +- Надпись "Edit" в шапке не несёт функциональной нагрузки — замена обоснована. +- Шапка всегда видна независимо от фокуса. + +### Иконки +- Веб: `Undo` и `Redo` из `lucide-react` (уже используется в проекте). +- Мобайл: `Undo2` и `Redo2` из `lucide-react-native` (другой визуальный стиль от обычных стрелок навигации — чтобы не путать с кнопкой "назад"). + +### Disabled state +- Веб: реализуем через `editor.can().undo()` / `editor.can().redo()` — TipTap обновляет состояние после каждой транзакции, React перерисовывает кнопки реактивно. +- Мобайл MVP: кнопки всегда активны. Если история пустая, команда тихо игнорируется — без вибрации, без тоста, без визуальной реакции. Это осознанное решение MVP; disabled-state может быть добавлен позже через расширение bridge-протокола. + +## Non-Functional Requirements +**How should the system perform?** + +- Нажатие undo/redo на вебе: немедленный отклик (синхронное обновление TipTap, < 16ms). +- Мобайл bridge: задержка 50–150ms на реальных устройствах — приемлемо. Порог деградации UX: > 300ms (не ожидается при обычных условиях). +- Нет дополнительных сетевых запросов, нет изменений в БД непосредственно при undo/redo. +- Автосохранение **должно** срабатывать после undo/redo: TipTap вызывает `onUpdate` → `onContentChange()` → `debouncedAutoSave.schedule()`. Это ожидаемое и корректное поведение — откатившееся состояние сохраняется автоматически. + +## 2026-02-26 Web Note Switch History Reset Addendum + +### Session Boundary Rule +- `NoteEditor` owns an explicit editor session key. +- When user navigates to another existing note (`noteId` truly changes), session key increments and `RichTextEditor` remounts. +- Remount is the source of truth for history reset across notes. + +### Non-Remount Rule +- During autosave create (`undefined -> id` assignment for the same draft), session key is not changed. +- This preserves in-flight typing UX and avoids focus loss. + +### Important Clarification +- Do not use `setContent` on the existing editor instance as the primary switch mechanism. +- Use remount for note switch boundaries; use direct content updates only for in-session operations. + +## 2026-02-26 Mobile History Bridge Design Addendum + +This addendum supersedes earlier MVP notes that described mobile undo/redo as always-active without bridge-driven disabled state. + +### Bridge contract +- `RichTextEditorWebView` emits history state snapshots: `{ canUndo: boolean, canRedo: boolean }`. +- `EditorWebViewPage` deduplicates repeated `HISTORY_STATE` payloads before posting to React Native. +- `EditorWebView` forwards `HISTORY_STATE` to `note/[id].tsx` via `onHistoryStateChange`. +- `note/[id].tsx` stores the state and drives header button `disabled` and `accessibilityState`. + +### Command execution rule +- `undo` and `redo` must use explicit commands (`editor.commands.undo()` / `editor.commands.redo()`). +- They must not be routed through generic `chain().focus()[command]().run()` because focus transactions can invalidate redo stack behavior. + +### Refresh boundary rule (mobile) +- On same-note refresh (query invalidation/autosave refetch with identical `note.id`), keep current history UI state. +- Reset history UI state only when note identity changes. diff --git a/docs/ai/implementation/feature-editor-undo-redo.md b/docs/ai/implementation/feature-editor-undo-redo.md new file mode 100644 index 00000000000..af5bf39a8f9 --- /dev/null +++ b/docs/ai/implementation/feature-editor-undo-redo.md @@ -0,0 +1,194 @@ +--- +phase: implementation +title: Implementation Guide +description: Technical implementation notes, patterns, and code guidelines +--- + +# Implementation Guide — Editor Undo/Redo Controls + +## Development Setup +**How do we get started?** + +Все зависимости уже установлены. Изменяем только 2 файла: +1. `ui/web/components/RichTextEditor.tsx` — веб +2. `ui/mobile/app/note/[id].tsx` — мобайл-нейтив + +## Code Structure +**How is the code organized?** + +``` +ui/web/components/ +└── RichTextEditor.tsx ← MenuBar: добавить Undo/Redo первыми + +ui/mobile/app/note/ +└── [id].tsx ← Stack.Screen: headerLeft с undo/redo +``` + +## Implementation Notes + +### Core Features + +#### Веб: MenuBar в RichTextEditor.tsx + +Добавить в начало JSX внутри `
`, перед кнопкой Bold: + +```tsx +// Импорт (добавить к существующим из lucide-react): +import { Undo, Redo } from "lucide-react" + +// В MenuBar, первые элементы: + + + + + Undo (Ctrl+Z) + + + + + + + Redo (Ctrl+Shift+Z) + + +{/* Разделитель после Undo/Redo, перед Bold */} +
+``` + +#### Мобайл: headerLeft в note/[id].tsx + +Добавить импорты: +```tsx +import { Undo2, Redo2 } from 'lucide-react-native' +``` + +Изменить `Stack.Screen options`: +```tsx + ( + + router.back()} + accessibilityLabel="Go back" + accessibilityRole="button" + style={({ pressed }) => [styles.headerButton, pressed && { opacity: 0.5 }]} + > + {/* Используем нативную стрелку назад */} + + + editorRef.current?.runCommand('undo')} + accessibilityLabel="Undo" + accessibilityRole="button" + style={({ pressed }) => [styles.headerButton, pressed && { opacity: 0.5 }]} + > + + + editorRef.current?.runCommand('redo')} + accessibilityLabel="Redo" + accessibilityRole="button" + style={({ pressed }) => [styles.headerButton, pressed && { opacity: 0.5 }]} + > + + + + ), + headerRight: () => ( + // без изменений: Trash2 + ThemeToggle + ), + }} +/> +``` + +Добавить в стили: +```ts +headerLeftActions: { + flexDirection: 'row', + alignItems: 'center', + marginLeft: 4, +}, +``` + +Импортировать `ChevronLeft` (или использовать другую иконку "назад"): +```tsx +import { Trash2, ChevronLeft } from 'lucide-react-native' +``` + +### Patterns & Best Practices +- Не добавлять состояние в компоненты — использовать прямой вызов TipTap API. +- Для веба: `editor.can().undo()` возвращает актуальное значение после каждого ре-рендера (TipTap реактивен). +- Для мобайл: команды `undo`/`redo` уже поддерживаются generic-обработчиком в bridge — не нужно трогать `RichTextEditorWebView.tsx`. + +## Integration Points +**How do pieces connect?** + +- Мобайл bridge path: `Pressable.onPress` → `editorRef.current.runCommand('undo')` → `EditorWebView.runCommand` → `postMessage` → `RichTextEditorWebView.runCommand` → `editor.chain().focus().undo().run()` +- Веб: напрямую через `editor` объект в `MenuBar`. + +## Error Handling +- Если `editor` равен `null` в MenuBar — кнопки не рендерятся (существующая проверка `if (!editor) return null`). +- Если мобайл `editorRef.current` не инициализирован — `runCommand` не вызовется (optional chaining `?.`). + +## Performance Considerations +- Undo/Redo — синхронные операции ProseMirror, без задержек. +- Disabled-state на вебе пересчитывается при каждом ре-рендере TipTap — это нормально, TipTap оптимизирован. + +## 2026 Mobile Fix Addendum +- Mobile header undo/redo now uses explicit `canUndo/canRedo` state from WebView bridge. +- New bridge event: `HISTORY_STATE` with payload `{ canUndo: boolean, canRedo: boolean }`. +- `EditorWebView` forwards this state to `note/[id].tsx`, and header buttons are disabled when history is unavailable. +- Initial content synchronization in `RichTextEditorWebView.setContent` is dispatched with `addToHistory: false`. +- This prevents the first undo after opening a note from wiping the whole note body. + +## 2026-02-26 Web Note Switch Implementation Addendum +- `ui/web/components/features/notes/NoteEditor.tsx` now treats note switching as an editor session boundary. +- On real note change (`noteId` changes between existing notes), it increments a dedicated `editorSessionKey`. +- `editorSessionKey` is used in both title input key and `RichTextEditor` key to remount and clear per-note undo/redo history. +- On autosave create transition (`undefined -> id` for the same draft), it does not increment session key. +- `NoteEditor` no longer calls `editorRef.current?.setContent(initialDescription)` during note switch; remount is the single reset path. + +## 2026-02-26 Mobile Autosave Refresh Fix Addendum + +### Scope clarification +- The implementation footprint is broader than the original "2 files" MVP note. +- Undo/redo behavior now depends on coordinated updates across: + - `ui/mobile/app/note/[id].tsx` + - `ui/mobile/components/EditorWebView.tsx` + - `app/editor-webview/page.tsx` + - `ui/web/components/RichTextEditorWebView.tsx` + +### Key mobile fix +- `note/[id].tsx` keeps `historyState` stable across same-note refreshes. +- Introduced `lastHydratedNoteIdRef` guard: + - if `note.id` changed, reset history UI state to `{ canUndo: false, canRedo: false }` + - if `note.id` is the same, do not reset history UI state +- This prevents undo button from becoming permanently disabled after autosave refetch. + +### Current command path +- Header press -> `editorRef.current?.runCommand('undo' | 'redo')` +- Bridge -> WebView command +- Editor execution: + - `undo` -> `editor.commands.undo()` + - `redo` -> `editor.commands.redo()` +- Generic `chain().focus()` path remains only for non-history commands. diff --git a/docs/ai/planning/feature-editor-undo-redo.md b/docs/ai/planning/feature-editor-undo-redo.md new file mode 100644 index 00000000000..10c8de23d29 --- /dev/null +++ b/docs/ai/planning/feature-editor-undo-redo.md @@ -0,0 +1,73 @@ +--- +phase: planning +title: Project Planning & Task Breakdown +description: Break down work into actionable tasks and estimate timeline +--- + +# Project Planning & Task Breakdown — Editor Undo/Redo Controls + +## Milestones +**What are the major checkpoints?** + +- [ ] Milestone 1: Undo/Redo на вебе (desktop + mobile viewport) +- [ ] Milestone 2: Undo/Redo на мобайл-нейтив (Android/iOS) +- [ ] Milestone 3: Тесты и ревью + +## Task Breakdown +**What specific work needs to be done?** + +### Phase 1: Веб — RichTextEditor.tsx +- [x] Task 1.1: Импортировать иконки `Undo` и `Redo` из `lucide-react` в `RichTextEditor.tsx` +- [x] Task 1.2: Добавить кнопки Undo/Redo в начало `MenuBar` (перед Bold), с разделителем после +- [x] Task 1.3: Кнопка Undo: `onClick={() => editor.chain().focus().undo().run()}`, `disabled={!editor.can().undo()}` +- [x] Task 1.4: Кнопка Redo: `onClick={() => editor.chain().focus().redo().run()}`, `disabled={!editor.can().redo()}` +- [x] Task 1.5: Добавить Tooltip "Undo (Ctrl+Z)" и "Redo (Ctrl+Shift+Z)" по аналогии с остальными кнопками +- [ ] Task 1.6: Проверить визуально — desktop и mobile viewport + +### Phase 2: Мобайл-нейтив — note/[id].tsx +- [x] Task 2.1: Импортировать `Undo2`, `Redo2`, `ChevronLeft` из `lucide-react-native` в `note/[id].tsx` +- [x] Task 2.2: Убрать `title: 'Edit'` → `title: ''` в `Stack.Screen options` +- [x] Task 2.3: Добавить `headerLeft` — кастомный компонент с кнопкой "назад" (`router.back()`) и двумя кнопками Undo/Redo +- [x] Task 2.4: Undo: `editorRef.current?.runCommand('undo')`, Redo: `editorRef.current?.runCommand('redo')` +- [x] Task 2.5: Стилизовать кнопки — добавлен стиль `headerLeftActions`, переиспользован `headerButton` +- [ ] Task 2.6: Проверить на Android-эмуляторе / реальном устройстве + +### Phase 3: Тесты и финализация +- [x] Task 3.1: Cypress тесты для `MenuBar` — 7 тест-кейсов в `cypress/component/editor/RichTextEditor.cy.tsx` +- [x] Task 3.2: Jest тесты для `note/[id].tsx` — 6 тест-кейсов в `ui/mobile/tests/integration/noteEditorUndoRedo.test.tsx` (6/6 ✅) +- [ ] Task 3.3: Ручное тестирование по чеклисту (см. `docs/ai/testing/feature-editor-undo-redo.md`) +- [ ] Task 3.4: Code review + +## Dependencies +**What needs to happen in what order?** + +- Phase 1 и Phase 2 независимы — можно делать параллельно. +- Phase 3 зависит от Phase 1 и Phase 2. +- Внешних зависимостей нет: `lucide-react`, `lucide-react-native`, TipTap History — всё уже установлено. + +## Timeline & Estimates +**When will things be done?** + +| Phase | Оценка | +|---|---| +| Phase 1 (веб) | ~1-2 часа | +| Phase 2 (мобайл) | ~1-2 часа | +| Phase 3 (тесты + ревью) | ~1 час | +| **Итого** | **~3-5 часов** | + +## Risks & Mitigation +**What could go wrong?** + +| Риск | Вероятность | Митигация | +|---|---|---| +| `editor.can().undo()` не обновляется реактивно | Низкая | TipTap обновляет состояние после каждой транзакции; при необходимости — использовать `useEditorState` | +| Мобайл: команда `undo` не проходит через bridge | Низкая | `runCommand` уже тестируется для `toggleBold` и других команд | +| Визуальный конфликт headerLeft с нативной кнопкой "назад" | Средняя | Использовать `headerLeft` (заменяет дефолтный back) + рендерить свою кнопку "назад" явно | + +## Resources Needed +**What do we need to succeed?** + +- `lucide-react` (уже установлен) +- `lucide-react-native` (уже установлен) +- TipTap StarterKit History (уже включён) +- Expo Router `Stack.Screen` API (`headerLeft`) diff --git a/docs/ai/requirements/feature-editor-undo-redo.md b/docs/ai/requirements/feature-editor-undo-redo.md new file mode 100644 index 00000000000..e363d5f3cee --- /dev/null +++ b/docs/ai/requirements/feature-editor-undo-redo.md @@ -0,0 +1,84 @@ +--- +phase: requirements +title: Requirements & Problem Understanding +description: Clarify the problem space, gather requirements, and define success criteria +--- + +# Requirements & Problem Understanding — Editor Undo/Redo Controls + +## Problem Statement +**What problem are we solving?** + +- На мобильных устройствах нет стандартного Ctrl+Z, поэтому пользователи не могут отменить последнее действие в редакторе без сторонней клавиатуры. +- На вебе сочетания клавиш Ctrl+Z / Ctrl+Shift+Z работают, но они скрыты от пользователя — нет визуального элемента управления. +- Пострадавшие пользователи: все, кто редактирует заметки на мобайл-нейтив и на вебе. +- Текущий обходной путь: на мобайл — невозможно отменить без физической клавиатуры; на вебе — Ctrl+Z работает, но не очевидно. + +## Goals & Objectives +**What do we want to achieve?** + +- **Primary:** Добавить кнопки Undo и Redo в интерфейс на всех платформах: веб-десктоп, веб-мобайл, мобайл-нейтив (Android/iOS). +- **Secondary:** На вебе кнопки отражают состояние истории (disabled, если нечего отменять/повторять). На мобайл-нейтив — всегда активны (MVP, нет механизма передачи состояния через bridge). +- **Non-goals:** + - Кастомная история редактирования (используем встроенную в TipTap через StarterKit). + - История отмены для заголовка заметки и тегов (только тело редактора). + - Keyboard shortcut hints на мобайл-нейтив. + - Сохранение истории undo между сессиями (история живёт только в памяти TipTap). + +## User Stories & Use Cases +**How will users interact with the solution?** + +- Как пользователь веба, я хочу нажать кнопку ↩ в тулбаре редактора, чтобы отменить последнее изменение, не используя клавиатуру. +- Как пользователь веба, я хочу нажать кнопку ↪ в тулбаре, чтобы повторить отменённое действие. +- Как пользователь мобайл-приложения, я хочу нажать кнопку ↩ в шапке экрана редактирования, чтобы отменить последнее изменение одним нажатием. +- Как пользователь мобайл-приложения, я хочу нажать кнопку ↪ рядом с ↩, чтобы повторить действие. + +**Edge cases:** +- При переключении на другую заметку история undo сбрасывается — пользователь не может отменить изменения предыдущей заметки. +- На мобайл-нейтив, если история пустая, нажатие ↩ тихо игнорируется (без ошибки и визуальной реакции) — ожидаемое поведение. +- После закрытия и повторного открытия заметки (или перезагрузки страницы) история undo недоступна — ожидаемое поведение, аналогично Notion и Google Docs. + +## Success Criteria +**How will we know when we're done?** + +- [ ] На вебе кнопки Undo/Redo появляются первыми в тулбаре редактора. +- [ ] На вебе кнопка Undo задизаблена, если история пуста; Redo — если нечего повторять. +- [ ] На мобайл-нейтив кнопки Undo/Redo отображаются в шапке экрана (header), надпись "Edit" убрана. +- [ ] Undo отменяет последний ввод текста в редакторе (сценарий: ввёл слово → нажал ↩ → слово исчезло). +- [ ] Redo восстанавливает отменённое (сценарий: ↩ → ↪ → слово вернулось). +- [ ] После undo автосохранение срабатывает через debounce (~500–1000ms) и сохраняет откатившееся состояние. +- [ ] Кнопки имеют aria-labels ("Undo", "Redo") для доступности. +- [ ] Работает на Android и iOS (мобайл-нейтив). +- [ ] Работает в браузере desktop и mobile viewport (веб). + +## Constraints & Assumptions +**What limitations do we need to work within?** + +- TipTap StarterKit уже включает History extension — никаких дополнительных зависимостей не нужно. +- На мобайл-нейтив редактор работает через WebView (React Native → WebView → TipTap). Через bridge передаются только *команды*, не состояние истории. +- `runCommand('undo')` и `runCommand('redo')` уже поддерживаются generic-обработчиком в `RichTextEditorWebView.tsx` (`editor.chain().focus()[command]().run()`). +- На мобайл-нейтив в MVP не реализуем disabled-state для кнопок (потребовало бы расширения bridge-протокола для передачи `can().undo()`). +- Автосохранение после undo/redo работает автоматически: TipTap вызывает `onUpdate` → `onContentChange()` → `debouncedAutoSave.schedule()`. Специального кода не требуется. +- История undo хранится только в памяти TipTap и сбрасывается при `setContent` (переключение заметки) или перезагрузке страницы/приложения. +- Иконки: использовать `lucide-react` (веб) и `lucide-react-native` (мобайл). + +## Questions & Open Items +**What do we still need to clarify?** + +- **Закрыто:** Tooltips на вебе — реализуем ("Undo (Ctrl+Z)" / "Redo (Ctrl+Shift+Z)"). +- **Закрыто:** Автосохранение после undo — срабатывает автоматически через существующий `onUpdate` → debounce pipeline. +- **Открыто:** Нужен ли disabled-state на мобайл-нейтив в будущем? (потребует расширения bridge-протокола для передачи `can().undo()` из WebView в React Native — отложено после MVP). + +## 2026-02-26 Web Note Switch History Reset Addendum +- Web must reset editor history only on real note transitions (`noteId` changed to another existing note). +- Autosave create transition (`undefined -> id` for the same draft) must keep the same editor session and preserve focus/caret. +- The reset mechanism is an editor remount (new session key), not `setContent` mutation on the existing editor instance. +- Acceptance check: after switching A -> B, both undo and redo are disabled until user edits note B. + +## 2026-02-26 Mobile History State Alignment Addendum +- This addendum supersedes earlier MVP assumptions where mobile undo/redo was always active. +- Mobile undo/redo disabled state is no longer an open MVP item; it is implemented and required. +- `canUndo/canRedo` must be delivered from the WebView editor to React Native through bridge message `HISTORY_STATE`. +- Mobile header buttons must be disabled when history is unavailable and enabled when history is available. +- A refetch/autosave refresh of the same note (`note.id` unchanged) must not reset the displayed history state to disabled. +- History state reset is allowed only on a real note switch (`note.id` changes to another note). diff --git a/docs/ai/testing/feature-editor-undo-redo.md b/docs/ai/testing/feature-editor-undo-redo.md new file mode 100644 index 00000000000..0ae6e6c4c5b --- /dev/null +++ b/docs/ai/testing/feature-editor-undo-redo.md @@ -0,0 +1,134 @@ +--- +phase: testing +title: Testing Strategy +description: Define testing approach, test cases, and quality assurance +--- + +# Testing Strategy — Editor Undo/Redo Controls + +## Test Coverage Goals +- Unit tests: 100% новых/изменённых компонентов +- Integration: undo/redo через WebView bridge +- E2E: ключевые пользовательские сценарии +- Manual: визуальный контроль на реальных устройствах + +## Unit Tests + +### MenuBar (RichTextEditor.tsx) +- [ ] Кнопки Undo и Redo рендерятся в начале тулбара +- [ ] Кнопка Undo вызывает `editor.chain().focus().undo().run()` по клику +- [ ] Кнопка Redo вызывает `editor.chain().focus().redo().run()` по клику +- [ ] Кнопка Undo задизаблена, когда `editor.can().undo()` = false +- [ ] Кнопка Redo задизаблена, когда `editor.can().redo()` = false +- [ ] Tooltip Undo показывает "Undo (Ctrl+Z)" +- [ ] Tooltip Redo показывает "Redo (Ctrl+Shift+Z)" + +### note/[id].tsx (мобайл) +- [ ] `Stack.Screen title` равен '' (пустая строка, не 'Edit') +- [ ] `headerLeft` содержит кнопки Undo2 и Redo2 +- [ ] Кнопка Undo2 вызывает `editorRef.current?.runCommand('undo')` +- [ ] Кнопка Redo2 вызывает `editorRef.current?.runCommand('redo')` +- [ ] Кнопка "назад" вызывает `router.back()` + +## Integration Tests +- [ ] Undo отменяет последнее изменение текста в редакторе (веб) +- [ ] Redo повторяет отменённое изменение (веб) +- [ ] Последовательность: ввод текста → Undo → Redo восстанавливает текст (веб) +- [ ] Undo через WebView bridge: команда доходит до TipTap (мобайл) +- [ ] Redo через WebView bridge: команда доходит до TipTap (мобайл) + +## End-to-End Tests +- [ ] Веб: пользователь набирает текст → кликает Undo → текст исчезает +- [ ] Веб: кликает Redo → текст возвращается +- [ ] Веб mobile viewport: тулбар скроллится, Undo/Redo доступны первыми +- [ ] Мобайл: открывает заметку → нажимает Undo в шапке → изменение отменяется + +## Test Data +- Простой текст: "Hello World" — для базового undo/redo +- Форматирование: жирный текст — проверка отмены форматирования +- Пустой редактор — проверка disabled-state (Undo недоступен) + +## Test Reporting & Coverage +- Запуск: `npm run test -- --coverage` (веб), `npm run test` (мобайл) +- Покрытие: 100% новых строк в `RichTextEditor.tsx` и `note/[id].tsx` + +## Manual Testing +**Чеклист для ручного тестирования:** + +### Веб Desktop +- [ ] Кнопки Undo/Redo видны в тулбаре (самые первые) +- [ ] Undo задизаблен при открытии новой заметки +- [ ] После ввода текста Undo становится активным +- [ ] Ctrl+Z и кнопка Undo дают одинаковый результат +- [ ] Ctrl+Shift+Z и кнопка Redo дают одинаковый результат + +### Веб Mobile Viewport (DevTools) +- [ ] Тулбар показывает Undo/Redo первыми на всех breakpoints +- [ ] Кнопки нажимаемы на тач-экране (размер >= 44px) + +### Мобайл-нейтив Android +- [ ] Шапка: нет надписи "Edit" +- [ ] Шапка: кнопка ← (назад) + ↩ (undo) + ↪ (redo) +- [ ] Нажатие ↩ отменяет последний ввод +- [ ] Нажатие ↪ повторяет отменённый ввод +- [ ] Кнопка ← возвращает к списку заметок + +### Мобайл-нейтив iOS +- [ ] Те же проверки, что для Android + +## Performance Testing +- Undo/Redo должны отрабатывать мгновенно (< 50ms) — проверить в DevTools Performance. + +## Bug Tracking +- Приоритет багов: P1 — undo/redo не работает; P2 — неверный disabled-state; P3 — визуальные проблемы. + +## 2026-02-25 Mobile Undo/Redo Regression Coverage +- Added integration tests for mobile header undo/redo disabled state driven by history (`canUndo/canRedo`). +- Added component tests for `HISTORY_STATE` message handling in `EditorWebView`. +- Added Cypress component regression test for `RichTextEditorWebView`: first undo after `setContent` must not clear baseline content. + +### Execution status +- `npm --prefix ui/mobile test -- noteEditorUndoRedo.test.tsx editorWebViewMessages.test.tsx` passed. +- Cypress component run in this environment failed before test output with native process exit `-1073741795`; spec execution could not be verified here. + +## 2026-02-26 Web Note Switch History Reset Coverage + +### Added component test +- File: `cypress/component/features/notes/NoteEditor.cy.tsx` +- Scenario: switch between two existing notes (A -> B) after creating undo history in note A. +- Assertions: + - Before switch: undo becomes enabled after editing note A. + - After switch: title/content from note B are rendered. + - After switch: undo and redo are both disabled (fresh history for note B). + - After switch: content typed in note A is not present in note B. + +### Why this test matters +- It protects the architectural rule: history reset is guaranteed by note-session remount boundaries, not by in-place editor mutation. + +## 2026-02-26 Mobile Same-Note Refresh Regression Coverage + +### Added integration test +- File: `ui/mobile/tests/integration/noteEditorUndoRedo.test.tsx` +- Scenario: undo is enabled, then the same note is refreshed in query cache (`['note', 'note-id']` with unchanged id). +- Assertion: undo remains enabled after refresh. + +### Why this test matters +- It protects against a mobile-specific regression where autosave/refetch of the same note incorrectly reset header history state to disabled. +- It verifies that history UI reset is tied to note identity change, not to any note object refresh. + +## 2026-02-26 Additional Regression Scenarios + +### Added mobile integration scenario +- File: `ui/mobile/tests/integration/noteEditorUndoRedo.test.tsx` +- Scenario: route switches from note A to note B. +- Assertions: + - Before switch: undo/redo can be enabled by history events. + - After switch: header undo/redo are reset to disabled. + +### Added bridge component scenario +- File: `cypress/component/editor/EditorWebViewPageBridge.cy.tsx` +- Scenario: `HISTORY_STATE` transitions across type/undo/redo. +- Assertions: + - Consecutive duplicates are deduplicated. + - Real state transitions are emitted. + - A non-consecutive repeated state (e.g. back to `[true, false]`) is emitted again. diff --git a/ui/mobile/app/(tabs)/index.tsx b/ui/mobile/app/(tabs)/index.tsx index 7f59125b508..907f6b8a781 100644 --- a/ui/mobile/app/(tabs)/index.tsx +++ b/ui/mobile/app/(tabs)/index.tsx @@ -108,11 +108,13 @@ export default function NotesScreen() { { text: 'Delete', style: 'destructive', - onPress: async () => { - await bulkDelete([...selectedIds]) - deactivate() - setIsManualRefreshing(true) - void refetch().finally(() => setIsManualRefreshing(false)) + onPress: () => { + void (async () => { + await bulkDelete([...selectedIds]) + deactivate() + setIsManualRefreshing(true) + void refetch().finally(() => setIsManualRefreshing(false)) + })() }, }, ] diff --git a/ui/mobile/app/(tabs)/search.tsx b/ui/mobile/app/(tabs)/search.tsx index 7094c2addce..aa4a650b62c 100644 --- a/ui/mobile/app/(tabs)/search.tsx +++ b/ui/mobile/app/(tabs)/search.tsx @@ -1,5 +1,5 @@ import { View, TextInput, StyleSheet, ActivityIndicator, Text, Pressable, Alert, BackHandler } from 'react-native' -import { useEffect, useMemo, useState, useCallback } from 'react' +import { useEffect, useMemo, useState, useCallback, useRef } from 'react' import { useLocalSearchParams, useRouter, useNavigation } from 'expo-router' import { FlashList } from '@shopify/flash-list' import { useSearch, useDeleteNote, useOpenNote, useBulkSelection, useBulkDeleteNotes } from '@ui/mobile/hooks' @@ -78,11 +78,12 @@ export default function SearchScreen() { return () => clearTimeout(timeout) }, [query, user?.id]) - // Reset selection mode when search query changes. - // Intentionally excludes `isActive` and `deactivate` from deps — we only want to - // trigger on new search input, not on every re-render where isActive/deactivate change. - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => { if (isActive) deactivate() }, [query]) + const previousQueryRef = useRef(query) + useEffect(() => { + if (previousQueryRef.current === query) return + previousQueryRef.current = query + if (isActive) deactivate() + }, [query, isActive, deactivate]) // Transform header when selection mode is active useEffect(() => { @@ -154,9 +155,11 @@ export default function SearchScreen() { { text: 'Delete', style: 'destructive', - onPress: async () => { - await bulkDelete([...selectedIds]) - deactivate() + onPress: () => { + void (async () => { + await bulkDelete([...selectedIds]) + deactivate() + })() }, }, ] diff --git a/ui/mobile/app/note/[id].tsx b/ui/mobile/app/note/[id].tsx index e02b392d130..a564ce23b33 100644 --- a/ui/mobile/app/note/[id].tsx +++ b/ui/mobile/app/note/[id].tsx @@ -8,7 +8,7 @@ import { EditorToolbar, TOOLBAR_CONTENT_HEIGHT } from '@ui/mobile/components/Edi import { useTheme } from '@ui/mobile/providers' import { ThemeToggle } from '@ui/mobile/components/ThemeToggle' import { TagInput } from '@ui/mobile/components/tags/TagInput' -import { Trash2 } from 'lucide-react-native' +import { Trash2, ChevronLeft, Undo2, Redo2 } from 'lucide-react-native' import { Pressable } from 'react-native' import { createDebouncedLatest } from '@core/utils/debouncedLatest' @@ -79,7 +79,9 @@ export default function NoteEditorScreen() { const [tags, setTags] = useState([]) const [isEditorFocused, setIsEditorFocused] = useState(false) const [hasSelection, setHasSelection] = useState(false) + const [historyState, setHistoryState] = useState({ canUndo: false, canRedo: false }) const [keyboardHeight, setKeyboardHeight] = useState(0) + const lastHydratedNoteIdRef = useRef(null) const latestDraftRef = useRef<{ title: string; description: string; tags: string[] }>({ title: '', description: '', @@ -146,6 +148,11 @@ export default function NoteEditorScreen() { useEffect(() => { if (note) { + const hasNoteSwitched = lastHydratedNoteIdRef.current !== note.id + if (hasNoteSwitched) { + setHistoryState({ canUndo: false, canRedo: false }) + lastHydratedNoteIdRef.current = note.id + } setTitle(note.title || '') setTags(note.tags ?? []) lastSavedRef.current = { @@ -226,9 +233,50 @@ export default function NoteEditorScreen() { ( + + router.back()} + accessibilityLabel="Go back" + accessibilityRole="button" + style={({ pressed }) => [styles.headerButton, pressed && { opacity: 0.5 }]} + > + + + editorRef.current?.runCommand('undo')} + disabled={!historyState.canUndo} + accessibilityLabel="Undo" + accessibilityRole="button" + accessibilityState={{ disabled: !historyState.canUndo }} + style={({ pressed }) => [ + styles.headerButton, + !historyState.canUndo && styles.headerButtonDisabled, + pressed && historyState.canUndo && { opacity: 0.5 }, + ]} + > + + + editorRef.current?.runCommand('redo')} + disabled={!historyState.canRedo} + accessibilityLabel="Redo" + accessibilityRole="button" + accessibilityState={{ disabled: !historyState.canRedo }} + style={({ pressed }) => [ + styles.headerButton, + !historyState.canRedo && styles.headerButtonDisabled, + pressed && historyState.canRedo && { opacity: 0.5 }, + ]} + > + + + + ), headerRight: () => ( setIsEditorFocused(true)} onBlur={handleEditorBlur} onSelectionChange={setHasSelection} + onHistoryStateChange={setHistoryState} loadingFallback={} /> @@ -331,9 +380,17 @@ const createStyles = (colors: ReturnType['colors']) => StyleShe alignItems: 'center', marginRight: 12, }, + headerLeftActions: { + flexDirection: 'row', + alignItems: 'center', + marginLeft: 4, + }, headerButton: { padding: 8, }, + headerButtonDisabled: { + opacity: 0.35, + }, toolbarContainer: { position: 'absolute', left: 0, diff --git a/ui/mobile/components/EditorWebView.tsx b/ui/mobile/components/EditorWebView.tsx index faf05681084..9f6e170d144 100644 --- a/ui/mobile/components/EditorWebView.tsx +++ b/ui/mobile/components/EditorWebView.tsx @@ -21,6 +21,7 @@ type Props = { onFocus?: () => void onBlur?: () => void onSelectionChange?: (hasSelection: boolean) => void + onHistoryStateChange?: (state: { canUndo: boolean; canRedo: boolean }) => void loadingFallback?: React.ReactNode } @@ -64,7 +65,7 @@ const formatDebugValue = (value: string | null) => { } const EditorWebView = forwardRef( - ({ initialContent = '', onContentChange, onReady, onFocus, onBlur, onSelectionChange, loadingFallback }, ref) => { + ({ initialContent = '', onContentChange, onReady, onFocus, onBlur, onSelectionChange, onHistoryStateChange, loadingFallback }, ref) => { const webViewRef = useRef(null) const { colors, colorScheme } = useTheme() const styles = useMemo(() => createStyles(colors), [colors]) @@ -284,6 +285,14 @@ const EditorWebView = forwardRef( case 'SELECTION_CHANGE': onSelectionChange?.(Boolean(payload)) break + case 'HISTORY_STATE': { + const p = payload as { canUndo?: unknown; canRedo?: unknown } | null + onHistoryStateChange?.({ + canUndo: Boolean(p?.canUndo), + canRedo: Boolean(p?.canRedo), + }) + break + } case 'CONTENT_ON_BLUR': // Safety net: content sent on blur ensures nothing is lost onContentChange?.(String(payload ?? '')) diff --git a/ui/mobile/package.json b/ui/mobile/package.json index bd7dacf8c27..6df3f64341b 100644 --- a/ui/mobile/package.json +++ b/ui/mobile/package.json @@ -18,8 +18,8 @@ "android:stage:release": "cross-env APP_VARIANT=stage EXPO_PUBLIC_APP_VARIANT=stage expo run:android --variant stageRelease --app-id com.everfreenote.app.stage", "android:prod": "cross-env APP_VARIANT=prod EXPO_PUBLIC_APP_VARIANT=prod expo run:android --variant prodDebug --app-id com.everfreenote.app", "android:prod:release": "cross-env APP_VARIANT=prod EXPO_PUBLIC_APP_VARIANT=prod expo run:android --variant prodRelease --app-id com.everfreenote.app", - "adb:connect": "adb connect 192.168.0.17:44179", - "adb:pair": "adb pair 192.168.0.17:34311", + "adb:connect": "adb connect 192.168.0.17:46031", + "adb:pair": "adb pair 192.168.0.17:45619", "adb:kill": "adb kill-server", "adb:start": "adb start-server", "adb:restart": "adb kill-server && adb start-server", diff --git a/ui/mobile/run-tests.ps1 b/ui/mobile/run-tests.ps1 deleted file mode 100644 index 2da43456009..00000000000 --- a/ui/mobile/run-tests.ps1 +++ /dev/null @@ -1,5 +0,0 @@ -# Run all tests and save output -npm test 2>&1 | Tee-Object -FilePath "test-results.txt" - -# Show summary -Get-Content "test-results.txt" | Select-String -Pattern "Test Suites:|Tests:" | Select-Object -Last 2 diff --git a/ui/mobile/tests/component/editorWebViewMessages.test.tsx b/ui/mobile/tests/component/editorWebViewMessages.test.tsx index 1c1ee5a6e59..5f6f2c967e2 100644 --- a/ui/mobile/tests/component/editorWebViewMessages.test.tsx +++ b/ui/mobile/tests/component/editorWebViewMessages.test.tsx @@ -260,6 +260,56 @@ describe('EditorWebView message handling', () => { }) }) + describe('HISTORY_STATE handling', () => { + it('calls onHistoryStateChange with canUndo/canRedo from payload', async () => { + const onHistoryStateChange = jest.fn() + + render( + + ) + + await waitFor(() => { + expect(capturedOnMessage).not.toBeNull() + }) + + sendMessage('HISTORY_STATE', { canUndo: true, canRedo: false }) + + expect(onHistoryStateChange).toHaveBeenCalledWith({ canUndo: true, canRedo: false }) + }) + + it('coerces missing HISTORY_STATE fields to false', async () => { + const onHistoryStateChange = jest.fn() + + render( + + ) + + await waitFor(() => { + expect(capturedOnMessage).not.toBeNull() + }) + + sendMessage('HISTORY_STATE', null) + + expect(onHistoryStateChange).toHaveBeenCalledWith({ canUndo: false, canRedo: false }) + }) + + it('does not throw when onHistoryStateChange is not provided', async () => { + render() + + await waitFor(() => { + expect(capturedOnMessage).not.toBeNull() + }) + + expect(() => sendMessage('HISTORY_STATE', { canUndo: true, canRedo: true })).not.toThrow() + }) + }) + describe('EDITOR_BLUR handling', () => { it('calls onBlur when EDITOR_BLUR message is received', async () => { const onBlur = jest.fn() diff --git a/ui/mobile/tests/integration/noteEditorUndoRedo.test.tsx b/ui/mobile/tests/integration/noteEditorUndoRedo.test.tsx new file mode 100644 index 00000000000..7dccf2278f0 --- /dev/null +++ b/ui/mobile/tests/integration/noteEditorUndoRedo.test.tsx @@ -0,0 +1,348 @@ +import type { ReactNode } from 'react' +import { + act, + createMockNote, + createMockNoteService, + createMockNoteServiceState, + createQueryWrapper, + createTestQueryClient, + fireEvent, + render, + screen, + waitFor, +} from '../testUtils' + +const mockBack = jest.fn() +const mockPush = jest.fn() +const mockRouteParams: { id: string } = { id: 'note-id' } + +jest.mock('expo-router', () => ({ + useRouter: () => ({ + push: mockPush, + back: mockBack, + replace: jest.fn(), + }), + useLocalSearchParams: () => mockRouteParams, + Stack: { + Screen: ({ children, options }: { children?: ReactNode; options?: { title?: string; headerLeft?: () => ReactNode; headerRight?: () => ReactNode } }) => { + const { View } = require('react-native') + return ( + + {options?.headerLeft && ( + + {typeof options.headerLeft === 'function' ? options.headerLeft() : options.headerLeft} + + )} + {options?.headerRight && ( + + {typeof options.headerRight === 'function' ? options.headerRight() : options.headerRight} + + )} + {children} + + ) + }, + }, +})) + +jest.mock('@ui/mobile/providers', () => ({ + useSupabase: jest.fn(() => ({ + client: { + from: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + eq: jest.fn().mockResolvedValue({ error: null }), + update: jest.fn().mockReturnThis(), + single: jest.fn().mockResolvedValue({ + data: { + id: 'note-id', + title: 'Test Note', + description: '', + tags: [], + created_at: '2025-01-01T10:00:00.000Z', + updated_at: '2025-01-01T10:00:00.000Z', + user_id: 'test-user-id', + }, + error: null, + }), + })), + }, + user: { id: 'test-user-id' }, + })), + useTheme: () => ({ + colors: { + background: '#ffffff', + foreground: '#111111', + card: '#ffffff', + border: '#e0e0e0', + accent: '#f2f2f2', + primary: '#00aa00', + secondary: '#f7f7f7', + mutedForeground: '#666666', + secondaryForeground: '#222222', + destructive: '#ff0000', + destructiveForeground: '#ffffff', + }, + }), +})) + +jest.mock('@ui/mobile/services/database', () => ({ + databaseService: { + markDeleted: jest.fn().mockResolvedValue(undefined), + saveNotes: jest.fn().mockResolvedValue(undefined), + getLocalNotes: jest.fn().mockResolvedValue([]), + }, +})) + +jest.mock('@core/services/notes') +jest.mock('@ui/mobile/adapters/networkStatus', () => ({ + mobileNetworkStatusProvider: { + isOnline: jest.fn().mockReturnValue(true), + }, +})) + +jest.mock('@ui/mobile/services/sync', () => ({ + mobileSyncService: { + getManager: jest.fn().mockReturnValue({ + enqueue: jest.fn().mockResolvedValue(undefined), + }), + }, +})) + +// Capture runCommand mock at module scope to assert on it +const mockRunCommand = jest.fn() +type HistoryState = { canUndo: boolean; canRedo: boolean } +let emitHistoryState: ((state: HistoryState) => void) | null = null + +jest.mock('@ui/mobile/components/EditorWebView', () => { + const React = require('react') + const { View, Text } = require('react-native') + + return React.forwardRef((props: { onHistoryStateChange?: (state: HistoryState) => void }, ref: unknown) => { + emitHistoryState = props.onHistoryStateChange ?? null + + React.useEffect(() => { + props.onHistoryStateChange?.({ canUndo: false, canRedo: false }) + }, [props.onHistoryStateChange]) + + React.useImperativeHandle(ref, () => ({ + runCommand: mockRunCommand, + })) + + return ( + + Editor Content + + ) + }) +}) + +jest.mock('@ui/mobile/components/EditorToolbar', () => ({ + EditorToolbar: () => { + const { View } = require('react-native') + return + }, + TOOLBAR_CONTENT_HEIGHT: 48, +})) + +jest.mock('@ui/mobile/components/ThemeToggle', () => ({ + ThemeToggle: () => { + const { View } = require('react-native') + return + }, +})) + +jest.mock('@ui/mobile/components/tags/TagInput', () => ({ + TagInput: () => { + const { View } = require('react-native') + return + }, +})) + +import NoteEditorScreen from '@ui/mobile/app/note/[id]' +import { NoteService } from '@core/services/notes' + +const mockNoteService = NoteService as jest.MockedClass + +function renderScreen() { + const state = createMockNoteServiceState([ + createMockNote({ id: 'note-id', title: 'Test Note', description: '' }), + createMockNote({ id: 'note-id-2', title: 'Other Note', description: '

Other

' }), + ]) + const service = createMockNoteService(state) + + mockNoteService.prototype.getNote = service.getNote + mockNoteService.prototype.updateNote = service.updateNote + mockNoteService.prototype.deleteNote = service.deleteNote + + const queryClient = createTestQueryClient() + const Wrapper = createQueryWrapper(queryClient) + + const renderResult = render(, { wrapper: Wrapper }) + return { ...renderResult, queryClient } +} + +const waitForEditorReady = async () => { + await waitFor(() => { + expect(screen.queryByTestId('editor-webview')).toBeTruthy() + }) +} + +const setHistoryState = async (state: HistoryState) => { + await act(async () => { + emitHistoryState?.(state) + }) +} + +describe('NoteEditorScreen — Undo/Redo header buttons', () => { + beforeEach(() => { + mockRunCommand.mockClear() + mockBack.mockClear() + emitHistoryState = null + mockRouteParams.id = 'note-id' + }) + + it('renders headerLeft with back, undo, and redo buttons', async () => { + renderScreen() + await waitForEditorReady() + + const headerLeft = screen.getByTestId('header-left') + expect(headerLeft).toBeTruthy() + + expect(screen.getByLabelText('Go back')).toBeTruthy() + expect(screen.getByLabelText('Undo')).toBeTruthy() + expect(screen.getByLabelText('Redo')).toBeTruthy() + }) + + it('does not show the "Edit" title (title is empty)', async () => { + renderScreen() + await waitForEditorReady() + + // Stack.Screen mock does not render the title prop, but we verify + // the screen renders without "Edit" text (previous title) + expect(screen.queryByText('Edit')).toBeNull() + }) + + it('undo and redo buttons are disabled by default when history is empty', async () => { + renderScreen() + await waitForEditorReady() + + const undo = screen.getByLabelText('Undo') + const redo = screen.getByLabelText('Redo') + + expect(undo.props.accessibilityState?.disabled).toBe(true) + expect(redo.props.accessibilityState?.disabled).toBe(true) + }) + + it('updates undo/redo disabled state when history state changes', async () => { + renderScreen() + await waitForEditorReady() + + await setHistoryState({ canUndo: true, canRedo: false }) + expect(screen.getByLabelText('Undo').props.accessibilityState?.disabled).toBe(false) + expect(screen.getByLabelText('Redo').props.accessibilityState?.disabled).toBe(true) + + await setHistoryState({ canUndo: true, canRedo: true }) + expect(screen.getByLabelText('Undo').props.accessibilityState?.disabled).toBe(false) + expect(screen.getByLabelText('Redo').props.accessibilityState?.disabled).toBe(false) + + await setHistoryState({ canUndo: false, canRedo: false }) + expect(screen.getByLabelText('Undo').props.accessibilityState?.disabled).toBe(true) + expect(screen.getByLabelText('Redo').props.accessibilityState?.disabled).toBe(true) + }) + + it('disabled undo and redo buttons do not dispatch commands', async () => { + renderScreen() + await waitForEditorReady() + + fireEvent.press(screen.getByLabelText('Undo')) + fireEvent.press(screen.getByLabelText('Redo')) + + expect(mockRunCommand).not.toHaveBeenCalled() + }) + + it('pressing Undo button calls runCommand("undo") on editor', async () => { + renderScreen() + await waitForEditorReady() + await setHistoryState({ canUndo: true, canRedo: false }) + + fireEvent.press(screen.getByLabelText('Undo')) + + expect(mockRunCommand).toHaveBeenCalledTimes(1) + expect(mockRunCommand).toHaveBeenCalledWith('undo') + }) + + it('pressing Redo button calls runCommand("redo") on editor', async () => { + renderScreen() + await waitForEditorReady() + await setHistoryState({ canUndo: false, canRedo: true }) + + fireEvent.press(screen.getByLabelText('Redo')) + + expect(mockRunCommand).toHaveBeenCalledTimes(1) + expect(mockRunCommand).toHaveBeenCalledWith('redo') + }) + + it('pressing back button calls router.back()', async () => { + renderScreen() + await waitForEditorReady() + + fireEvent.press(screen.getByLabelText('Go back')) + + expect(mockBack).toHaveBeenCalledTimes(1) + }) + + it('undo and redo do not interfere with each other', async () => { + renderScreen() + await waitForEditorReady() + await setHistoryState({ canUndo: true, canRedo: true }) + + fireEvent.press(screen.getByLabelText('Undo')) + fireEvent.press(screen.getByLabelText('Redo')) + fireEvent.press(screen.getByLabelText('Undo')) + + expect(mockRunCommand).toHaveBeenCalledTimes(3) + expect(mockRunCommand).toHaveBeenNthCalledWith(1, 'undo') + expect(mockRunCommand).toHaveBeenNthCalledWith(2, 'redo') + expect(mockRunCommand).toHaveBeenNthCalledWith(3, 'undo') + }) + + it('keeps undo enabled when the same note is refreshed after autosave', async () => { + const { queryClient } = renderScreen() + await waitForEditorReady() + + await setHistoryState({ canUndo: true, canRedo: false }) + expect(screen.getByLabelText('Undo').props.accessibilityState?.disabled).toBe(false) + + await act(async () => { + queryClient.setQueryData( + ['note', 'note-id'], + createMockNote({ + id: 'note-id', + title: 'Test Note', + description: '', + }) + ) + }) + + expect(screen.getByLabelText('Undo').props.accessibilityState?.disabled).toBe(false) + }) + + it('resets undo/redo state when route switches to a different note', async () => { + const screenRender = renderScreen() + await waitForEditorReady() + + await setHistoryState({ canUndo: true, canRedo: true }) + expect(screen.getByLabelText('Undo').props.accessibilityState?.disabled).toBe(false) + expect(screen.getByLabelText('Redo').props.accessibilityState?.disabled).toBe(false) + + mockRouteParams.id = 'note-id-2' + await act(async () => { + screenRender.rerender() + }) + await waitForEditorReady() + + expect(screen.getByLabelText('Undo').props.accessibilityState?.disabled).toBe(true) + expect(screen.getByLabelText('Redo').props.accessibilityState?.disabled).toBe(true) + }) +}) diff --git a/ui/mobile/tests/integration/notesScreen.test.tsx b/ui/mobile/tests/integration/notesScreen.test.tsx index c963d8a2390..54666b24fcb 100644 --- a/ui/mobile/tests/integration/notesScreen.test.tsx +++ b/ui/mobile/tests/integration/notesScreen.test.tsx @@ -540,7 +540,9 @@ describe('NotesScreen - Delete Functionality', () => { await waitFor(() => expect(screen.getByText('No notes yet')).toBeTruthy()) const scrollView = screen.getByTestId('empty-state-scroll') - act(() => scrollView.props.refreshControl.props.onRefresh()) + await act(async () => { + await scrollView.props.refreshControl.props.onRefresh() + }) await waitFor(() => expect(screen.getByText('First Note')).toBeTruthy()) }) @@ -558,7 +560,9 @@ describe('NotesScreen - Delete Functionality', () => { await waitFor(() => expect(screen.getByText('No notes yet')).toBeTruthy()) const scrollView = screen.getByTestId('empty-state-scroll') - act(() => scrollView.props.refreshControl.props.onRefresh()) + await act(async () => { + await scrollView.props.refreshControl.props.onRefresh() + }) await waitFor(() => expect(screen.getByTestId('activity-indicator')).toBeTruthy()) diff --git a/ui/web/components/RichTextEditor.tsx b/ui/web/components/RichTextEditor.tsx index 4b0cff0f0eb..05fcbf92b41 100644 --- a/ui/web/components/RichTextEditor.tsx +++ b/ui/web/components/RichTextEditor.tsx @@ -7,6 +7,7 @@ import { type Editor, type Extensions, } from "@tiptap/react" +import { createDocument } from "@tiptap/core" import StarterKit from "@tiptap/starter-kit" import Underline from "@tiptap/extension-underline" import Highlight from "@tiptap/extension-highlight" @@ -23,6 +24,8 @@ import { AlignLeft, AlignRight, Bold, + Undo, + Redo, CheckSquare, Heading1, Heading2, @@ -73,14 +76,32 @@ type RichTextEditorProps = { type MenuBarProps = { editor: Editor | null + historyState: HistoryState + onUndo: () => void + onRedo: () => void hasSelection: boolean onApplyMarkdown: () => void } +type HistoryState = { + canUndo: boolean + canRedo: boolean +} + +const EMPTY_HISTORY_STATE: HistoryState = { canUndo: false, canRedo: false } + +const areHistoryStatesEqual = (left: HistoryState, right: HistoryState) => + left.canUndo === right.canUndo && left.canRedo === right.canRedo + +const getHistoryState = (editor: Editor): HistoryState => ({ + canUndo: editor.can().undo(), + canRedo: editor.can().redo(), +}) + const fontFamilies = ["Sans Serif", "Serif", "Monospace", "Cursive"] const fontSizes = ["10", "11", "12", "13", "14", "15", "18", "24", "30", "36"] -const MenuBar = ({ editor, hasSelection, onApplyMarkdown }: MenuBarProps) => { +const MenuBar = ({ editor, historyState, onUndo, onRedo, hasSelection, onApplyMarkdown }: MenuBarProps) => { if (!editor) { return null } @@ -95,6 +116,40 @@ const MenuBar = ({ editor, hasSelection, onApplyMarkdown }: MenuBarProps) => { return (
+ + + + + Undo (Ctrl+Z) + + + + + + + Redo (Ctrl+Shift+Z) + + +
+