mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
fix(desktop): give the composer its own undo stack so Cmd+Z sees a paste
The rich editor mutates its DOM through `Range` rather than the browser's
editing commands, because `execCommand('insertText')` is ~O(n²) on large
multiline blobs and froze the composer for seconds on a big paste (#45812).
Those mutations never reach Chromium's undo stack, so a paste was invisible to
it — Cmd+Z skipped straight past the pasted text and undid whatever edit came
before it, leaving the paste stranded and the earlier edit destroyed.
Owning the stack outright is the only coherent fix; a half-owned one interleaves
our snapshots with Chromium's own typing entries and undoes them out of order.
Every edit path now banks its pre-edit state, and the editor claims Cmd+Z /
Cmd+Shift+Z (plus Ctrl+Y) instead of letting the native command run. Snapshots
are plain text + a caret offset rather than DOM, since the editor already
round-trips losslessly through composerPlainText/renderComposerContents.
Consecutive keystrokes coalesce inside a 600ms window, so undo steps back by a
typing burst the way a native editor does rather than one character at a time.
Electron's Edit menu `{ role: 'undo' }` fires the native command without a
keystroke the renderer can see, so a capture-phase `beforeinput` listener claims
historyUndo/historyRedo too and keeps the menu item and the shortcut in
agreement. Switching drafts resets the history — undoing into another
conversation's text is worse than having none.
Co-authored-by: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com>
This commit is contained in:
parent
8896fc7500
commit
bc2ddf5bab
5 changed files with 553 additions and 5 deletions
|
|
@ -408,6 +408,7 @@ export function useComposerDraft({
|
|||
requestMainFocus,
|
||||
sessionIdRef,
|
||||
setComposerText,
|
||||
stashAt
|
||||
stashAt,
|
||||
syncDraftFromEditor
|
||||
}
|
||||
}
|
||||
|
|
|
|||
124
apps/desktop/src/app/chat/composer/hooks/use-composer-undo.ts
Normal file
124
apps/desktop/src/app/chat/composer/hooks/use-composer-undo.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { type RefObject, useCallback, useEffect, useMemo } from 'react'
|
||||
|
||||
import {
|
||||
caretOffsetInEditor,
|
||||
composerPlainText,
|
||||
placeCaretAtOffset,
|
||||
renderComposerContents
|
||||
} from '../rich-editor'
|
||||
import { type ComposerSnapshot, createComposerUndoHistory } from '../undo-history'
|
||||
|
||||
interface UseComposerUndoArgs {
|
||||
editorRef: RefObject<HTMLDivElement | null>
|
||||
/** Push a restored snapshot back into draftRef + composer state. */
|
||||
syncDraftFromEditor: () => string
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo/redo for the rich composer.
|
||||
*
|
||||
* The editor mutates its DOM through `Range` to dodge Chromium's O(n²) editing
|
||||
* pipeline (#45812), which also dodges Chromium's undo stack — so a paste was
|
||||
* invisible to ⌘Z and the keystroke undid whatever edit came before it instead.
|
||||
* We own the stack outright rather than half of it: every edit path records the
|
||||
* pre-edit state here, and the editor claims ⌘Z / ⌘⇧Z itself.
|
||||
*/
|
||||
export function useComposerUndo({ editorRef, syncDraftFromEditor }: UseComposerUndoArgs) {
|
||||
const history = useMemo(() => createComposerUndoHistory(), [])
|
||||
|
||||
const snapshot = useCallback((): ComposerSnapshot => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor) {
|
||||
return { caret: 0, text: '' }
|
||||
}
|
||||
|
||||
return { caret: caretOffsetInEditor(editor), text: composerPlainText(editor) }
|
||||
}, [editorRef])
|
||||
|
||||
/** Bank the current state before mutating the editor. `coalesce` marks a
|
||||
* keystroke, so a run of typing collapses into one undo step. */
|
||||
const recordUndoPoint = useCallback(
|
||||
(options?: { coalesce?: boolean }) => {
|
||||
if (editorRef.current) {
|
||||
history.record(snapshot(), options)
|
||||
}
|
||||
},
|
||||
[editorRef, history, snapshot]
|
||||
)
|
||||
|
||||
const applySnapshot = useCallback(
|
||||
(next: ComposerSnapshot | null) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!next || !editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
renderComposerContents(editor, next.text)
|
||||
placeCaretAtOffset(editor, next.caret)
|
||||
syncDraftFromEditor()
|
||||
|
||||
return true
|
||||
},
|
||||
[editorRef, syncDraftFromEditor]
|
||||
)
|
||||
|
||||
/** Run a conditional edit, banking its pre-edit state only if it actually
|
||||
* ran. The snapshot has to be taken first (the edit destroys the state we'd
|
||||
* be saving), but recording unconditionally would clear the redo stack on
|
||||
* every Backspace that falls through to the native path. */
|
||||
const withUndoPoint = useCallback(
|
||||
(edit: () => boolean) => {
|
||||
const before = snapshot()
|
||||
const ran = edit()
|
||||
|
||||
if (ran) {
|
||||
history.record(before)
|
||||
}
|
||||
|
||||
return ran
|
||||
},
|
||||
[history, snapshot]
|
||||
)
|
||||
|
||||
const undo = useCallback(() => applySnapshot(history.undo(snapshot())), [applySnapshot, history, snapshot])
|
||||
const redo = useCallback(() => applySnapshot(history.redo(snapshot())), [applySnapshot, history, snapshot])
|
||||
|
||||
// A session/draft swap makes prior history meaningless — undoing into another
|
||||
// conversation's text is worse than having no history at all.
|
||||
const resetUndoHistory = useCallback(() => history.reset(), [history])
|
||||
|
||||
// Electron's Edit menu ships `{ role: 'undo' }`, whose accelerator the macOS
|
||||
// menu bar consumes before the web contents sees the keystroke (the same
|
||||
// hazard main.ts documents for ⌘W). It fires the native editing command,
|
||||
// which knows nothing about our stack. Claim it at the document level while
|
||||
// the composer holds focus, so the menu item and the keystroke agree.
|
||||
useEffect(() => {
|
||||
const onBeforeInput = (event: Event) => {
|
||||
const inputType = (event as InputEvent).inputType
|
||||
|
||||
if (inputType !== 'historyUndo' && inputType !== 'historyRedo') {
|
||||
return
|
||||
}
|
||||
|
||||
if (document.activeElement !== editorRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
if (inputType === 'historyUndo') {
|
||||
undo()
|
||||
} else {
|
||||
redo()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('beforeinput', onBeforeInput, true)
|
||||
|
||||
return () => document.removeEventListener('beforeinput', onBeforeInput, true)
|
||||
}, [editorRef, redo, undo])
|
||||
|
||||
return { recordUndoPoint, redo, resetUndoHistory, undo, withUndoPoint }
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ import { useComposerPopout } from './hooks/use-composer-popout'
|
|||
import { useComposerQueue } from './hooks/use-composer-queue'
|
||||
import { useComposerSubmit } from './hooks/use-composer-submit'
|
||||
import { useComposerTrigger } from './hooks/use-composer-trigger'
|
||||
import { useComposerUndo } from './hooks/use-composer-undo'
|
||||
import { useComposerUrlDialog } from './hooks/use-composer-url-dialog'
|
||||
import { useComposerVoice } from './hooks/use-composer-voice'
|
||||
import { useSlashCompletions } from './hooks/use-slash-completions'
|
||||
|
|
@ -59,6 +60,7 @@ import { CodingStatusRow } from './status-stack/coding-row'
|
|||
import { extractClipboardImageBlobs } from './text-utils'
|
||||
import { ComposerTriggerPopover } from './trigger-popover'
|
||||
import type { ChatBarProps } from './types'
|
||||
import { isRedoShortcut, isUndoShortcut } from './undo-history'
|
||||
import { UrlDialog } from './url-dialog'
|
||||
import { chipTypedUrlOnSpace, linkifyUrls } from './url-refs'
|
||||
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
|
||||
|
|
@ -173,9 +175,24 @@ export function ChatBar({
|
|||
requestMainFocus,
|
||||
sessionIdRef,
|
||||
setComposerText,
|
||||
stashAt
|
||||
stashAt,
|
||||
syncDraftFromEditor
|
||||
} = useComposerDraft({ activeQueueSessionKey, focusKey, inputDisabled, queueEditRef, sessionId })
|
||||
|
||||
// Undo/redo. The rich editor bypasses Chromium's editing pipeline for speed,
|
||||
// which also bypasses its undo stack — so we own the stack and every edit
|
||||
// path below banks its pre-edit state through `recordUndoPoint`.
|
||||
const { recordUndoPoint, redo, resetUndoHistory, undo, withUndoPoint } = useComposerUndo({
|
||||
editorRef,
|
||||
syncDraftFromEditor
|
||||
})
|
||||
|
||||
// Prior history belongs to the draft that just left — undoing into another
|
||||
// conversation's text is worse than having none.
|
||||
useEffect(() => {
|
||||
resetUndoHistory()
|
||||
}, [activeQueueSessionKey, resetUndoHistory])
|
||||
|
||||
// "Add URL" dialog — open/value state, autofocus, and submit (host onAddUrl or
|
||||
// an @url: directive into the draft).
|
||||
const { openUrlDialog, setUrlOpen, setUrlValue, submitUrl, urlInputRef, urlOpen, urlValue } = useComposerUrlDialog({
|
||||
|
|
@ -357,6 +374,23 @@ export function ChatBar({
|
|||
scheduleFlushEditorToDraft(event.currentTarget)
|
||||
}
|
||||
|
||||
// Native typing/deleting mutates the DOM through Chromium's editing pipeline,
|
||||
// whose undo stack we've taken over — so bank the pre-edit state here, before
|
||||
// the change lands. `beforeinput` is the only hook that still sees the old
|
||||
// text. Consecutive keystrokes coalesce into one entry, so ⌘Z steps back by a
|
||||
// burst rather than a character.
|
||||
const handleEditorBeforeInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
const inputType = (event.nativeEvent as InputEvent).inputType
|
||||
|
||||
// Undo/redo are ours (handled in useComposerUndo + keydown), and IME preedit
|
||||
// is not a committed edit — compositionend is where that text becomes real.
|
||||
if (inputType === 'historyUndo' || inputType === 'historyRedo' || composingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
recordUndoPoint({ coalesce: inputType === 'insertText' || inputType === 'deleteContentBackward' })
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
||||
|
||||
|
|
@ -407,6 +441,7 @@ export function ChatBar({
|
|||
// Links in the paste land as `@url:` chips rather than a wall of URL text —
|
||||
// the same reference the "Add URL" dialog inserts, parsed in place so a link
|
||||
// mid-sentence keeps its position.
|
||||
recordUndoPoint()
|
||||
insertComposerContentsAtCaret(event.currentTarget, linkifyUrls(pastedText))
|
||||
scheduleFlushEditorToDraft(event.currentTarget)
|
||||
}
|
||||
|
|
@ -421,6 +456,23 @@ export function ChatBar({
|
|||
return
|
||||
}
|
||||
|
||||
// Undo/redo before anything else — we own the stack (see useComposerUndo),
|
||||
// so these never reach Chromium's native history, which has no record of
|
||||
// the Range-based edits the rich editor makes.
|
||||
if (isUndoShortcut(event.nativeEvent)) {
|
||||
event.preventDefault()
|
||||
undo()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (isRedoShortcut(event.nativeEvent)) {
|
||||
event.preventDefault()
|
||||
redo()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Plain Backspace right after a directive chip: remove the chip + its
|
||||
// auto-inserted trailing space as one unit, so deleting a directive never
|
||||
// leaves an orphaned space. (Modified backspaces stay native.)
|
||||
|
|
@ -429,7 +481,7 @@ export function ChatBar({
|
|||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
deleteChipBeforeCaret(event.currentTarget)
|
||||
withUndoPoint(() => deleteChipBeforeCaret(event.currentTarget))
|
||||
) {
|
||||
event.preventDefault()
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
|
|
@ -439,7 +491,10 @@ export function ChatBar({
|
|||
|
||||
// Non-collapsed Backspace/Delete: native selection-delete is ~O(n²) on large
|
||||
// drafts (Ctrl+A → Delete froze ~1.3s). Collapsed carets fall through.
|
||||
if ((event.key === 'Backspace' || event.key === 'Delete') && deleteSelectionInEditor(event.currentTarget)) {
|
||||
if (
|
||||
(event.key === 'Backspace' || event.key === 'Delete') &&
|
||||
withUndoPoint(() => deleteSelectionInEditor(event.currentTarget))
|
||||
) {
|
||||
event.preventDefault()
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
|
||||
|
|
@ -448,7 +503,7 @@ export function ChatBar({
|
|||
|
||||
// A typed link finished with a space chips like a pasted one — the space
|
||||
// itself rides along inside the insert.
|
||||
if (chipTypedUrlOnSpace(event)) {
|
||||
if (withUndoPoint(() => chipTypedUrlOnSpace(event))) {
|
||||
event.preventDefault()
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
|
||||
|
|
@ -791,6 +846,7 @@ export function ChatBar({
|
|||
contentEditable={!inputDisabled}
|
||||
data-placeholder={placeholder}
|
||||
data-slot={RICH_INPUT_SLOT}
|
||||
onBeforeInput={handleEditorBeforeInput}
|
||||
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
||||
onCompositionEnd={event => {
|
||||
composingRef.current = false
|
||||
|
|
|
|||
241
apps/desktop/src/app/chat/composer/undo-history.test.ts
Normal file
241
apps/desktop/src/app/chat/composer/undo-history.test.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
caretOffsetInEditor,
|
||||
composerPlainText,
|
||||
placeCaretAtOffset,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
import { createComposerUndoHistory, isRedoShortcut, isUndoShortcut } from './undo-history'
|
||||
|
||||
const key = (over: Partial<KeyboardEvent> = {}) =>
|
||||
({ altKey: false, ctrlKey: false, key: 'z', metaKey: false, shiftKey: false, ...over }) as KeyboardEvent
|
||||
|
||||
describe('undo/redo shortcut recognition', () => {
|
||||
it('claims Cmd+Z and Ctrl+Z as undo, but not with Shift or Alt', () => {
|
||||
expect(isUndoShortcut(key({ metaKey: true }))).toBe(true)
|
||||
expect(isUndoShortcut(key({ ctrlKey: true }))).toBe(true)
|
||||
expect(isUndoShortcut(key({ metaKey: true, shiftKey: true }))).toBe(false)
|
||||
expect(isUndoShortcut(key({ altKey: true, metaKey: true }))).toBe(false)
|
||||
expect(isUndoShortcut(key({ key: 'a', metaKey: true }))).toBe(false)
|
||||
expect(isUndoShortcut(key())).toBe(false)
|
||||
})
|
||||
|
||||
it('claims Cmd+Shift+Z everywhere and Ctrl+Y as redo', () => {
|
||||
expect(isRedoShortcut(key({ metaKey: true, shiftKey: true }))).toBe(true)
|
||||
expect(isRedoShortcut(key({ ctrlKey: true, shiftKey: true }))).toBe(true)
|
||||
expect(isRedoShortcut(key({ ctrlKey: true, key: 'y' }))).toBe(true)
|
||||
expect(isRedoShortcut(key({ metaKey: true }))).toBe(false)
|
||||
expect(isRedoShortcut(key({ altKey: true, ctrlKey: true, key: 'y' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('never treats one keystroke as both undo and redo', () => {
|
||||
const chords = [
|
||||
key({ metaKey: true }),
|
||||
key({ ctrlKey: true }),
|
||||
key({ metaKey: true, shiftKey: true }),
|
||||
key({ ctrlKey: true, shiftKey: true }),
|
||||
key({ ctrlKey: true, key: 'y' })
|
||||
]
|
||||
|
||||
for (const chord of chords) {
|
||||
expect(isUndoShortcut(chord) && isRedoShortcut(chord)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('composer undo history', () => {
|
||||
const snap = (text: string, caret = text.length) => ({ caret, text })
|
||||
|
||||
it('steps back through discrete edits, newest first', () => {
|
||||
const history = createComposerUndoHistory()
|
||||
|
||||
history.record(snap(''))
|
||||
history.record(snap('a'))
|
||||
history.record(snap('ab'))
|
||||
|
||||
expect(history.undo(snap('abc'))?.text).toBe('ab')
|
||||
expect(history.undo(snap('ab'))?.text).toBe('a')
|
||||
expect(history.undo(snap('a'))?.text).toBe('')
|
||||
expect(history.undo(snap(''))).toBeNull()
|
||||
})
|
||||
|
||||
it('redoes back to the state undo left, and stops at the newest', () => {
|
||||
const history = createComposerUndoHistory()
|
||||
|
||||
history.record(snap('before'))
|
||||
|
||||
const undone = history.undo(snap('before + pasted'))
|
||||
|
||||
expect(undone?.text).toBe('before')
|
||||
expect(history.redo(snap('before'))?.text).toBe('before + pasted')
|
||||
expect(history.redo(snap('before + pasted'))).toBeNull()
|
||||
})
|
||||
|
||||
it('restores the caret along with the text', () => {
|
||||
const history = createComposerUndoHistory()
|
||||
|
||||
history.record({ caret: 3, text: 'hello world' })
|
||||
|
||||
expect(history.undo({ caret: 0, text: 'changed' })).toEqual({ caret: 3, text: 'hello world' })
|
||||
})
|
||||
|
||||
it('collapses a run of typing into one entry, so undo steps back by a burst', () => {
|
||||
let now = 1_000
|
||||
const history = createComposerUndoHistory(200, () => now)
|
||||
|
||||
history.record(snap(''), { coalesce: true })
|
||||
now += 50
|
||||
history.record(snap('h'), { coalesce: true })
|
||||
now += 50
|
||||
history.record(snap('he'), { coalesce: true })
|
||||
|
||||
// One burst → one entry, back to the state before the burst started.
|
||||
expect(history.undo(snap('hel'))?.text).toBe('')
|
||||
expect(history.undo(snap(''))).toBeNull()
|
||||
})
|
||||
|
||||
it('starts a new entry once the typing pause exceeds the coalesce window', () => {
|
||||
let now = 1_000
|
||||
const history = createComposerUndoHistory(200, () => now)
|
||||
|
||||
history.record(snap(''), { coalesce: true })
|
||||
now += 5_000
|
||||
history.record(snap('word one'), { coalesce: true })
|
||||
|
||||
expect(history.undo(snap('word one two'))?.text).toBe('word one')
|
||||
expect(history.undo(snap('word one'))?.text).toBe('')
|
||||
})
|
||||
|
||||
it('does not coalesce a paste into the typing burst that preceded it', () => {
|
||||
let now = 1_000
|
||||
const history = createComposerUndoHistory(200, () => now)
|
||||
|
||||
history.record(snap(''), { coalesce: true })
|
||||
now += 20
|
||||
// A paste is a discrete edit — no coalesce flag.
|
||||
history.record(snap('typed '))
|
||||
|
||||
expect(history.undo(snap('typed PASTED'))?.text).toBe('typed ')
|
||||
expect(history.undo(snap('typed '))?.text).toBe('')
|
||||
})
|
||||
|
||||
it('drops the redo stack once a new edit lands', () => {
|
||||
const history = createComposerUndoHistory()
|
||||
|
||||
history.record(snap('one'))
|
||||
history.undo(snap('one two'))
|
||||
history.record(snap('one'))
|
||||
|
||||
expect(history.redo(snap('one three'))).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores a no-op edit so undo never looks stuck for a press', () => {
|
||||
const history = createComposerUndoHistory()
|
||||
|
||||
history.record(snap('same'))
|
||||
history.record(snap('same'))
|
||||
|
||||
expect(history.undo(snap('same'))?.text).toBe('same')
|
||||
expect(history.undo(snap('same'))).toBeNull()
|
||||
})
|
||||
|
||||
it('bounds the stack, discarding the oldest entries', () => {
|
||||
const history = createComposerUndoHistory(3)
|
||||
|
||||
for (const text of ['a', 'b', 'c', 'd', 'e']) {
|
||||
history.record(snap(text))
|
||||
}
|
||||
|
||||
expect(history.undo(snap('f'))?.text).toBe('e')
|
||||
expect(history.undo(snap('e'))?.text).toBe('d')
|
||||
expect(history.undo(snap('d'))?.text).toBe('c')
|
||||
expect(history.undo(snap('c'))).toBeNull()
|
||||
})
|
||||
|
||||
it('reset clears both directions', () => {
|
||||
const history = createComposerUndoHistory()
|
||||
|
||||
history.record(snap('a'))
|
||||
history.undo(snap('ab'))
|
||||
history.reset()
|
||||
|
||||
expect(history.undo(snap('ab'))).toBeNull()
|
||||
expect(history.redo(snap('ab'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('caret offsets in composerPlainText coordinates', () => {
|
||||
let editor: HTMLDivElement
|
||||
|
||||
beforeEach(() => {
|
||||
editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
document.body.append(editor)
|
||||
})
|
||||
|
||||
const caretAfter = (node: Node, offset: number) => {
|
||||
const range = document.createRange()
|
||||
const selection = window.getSelection()!
|
||||
range.setStart(node, offset)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
it('round-trips a caret in plain text', () => {
|
||||
renderComposerContents(editor, 'hello world')
|
||||
placeCaretAtOffset(editor, 5)
|
||||
|
||||
expect(caretOffsetInEditor(editor)).toBe(5)
|
||||
})
|
||||
|
||||
it('counts a chip as its whole @kind:value text', () => {
|
||||
editor.append(document.createTextNode('see '), refChipElement('file', '`src/a.ts`'), document.createTextNode(' now'))
|
||||
|
||||
const chipText = '@file:`src/a.ts`'
|
||||
// Caret at the very end = everything before it.
|
||||
placeCaretAtOffset(editor, composerPlainText(editor).length)
|
||||
|
||||
expect(caretOffsetInEditor(editor)).toBe(4 + chipText.length + 4)
|
||||
})
|
||||
|
||||
it('lands the caret before a chip rather than splitting it', () => {
|
||||
editor.append(document.createTextNode('a '), refChipElement('file', '`x.ts`'))
|
||||
|
||||
// An offset that falls midway through the chip's serialized text.
|
||||
placeCaretAtOffset(editor, 2 + 3)
|
||||
|
||||
expect(caretOffsetInEditor(editor)).toBe(2)
|
||||
})
|
||||
|
||||
it('counts a line break as one character', () => {
|
||||
renderComposerContents(editor, 'one\ntwo')
|
||||
placeCaretAtOffset(editor, 5)
|
||||
|
||||
expect(caretOffsetInEditor(editor)).toBe(5)
|
||||
expect(composerPlainText(editor)).toBe('one\ntwo')
|
||||
})
|
||||
|
||||
it('clamps past-the-end offsets to the end instead of throwing', () => {
|
||||
renderComposerContents(editor, 'short')
|
||||
placeCaretAtOffset(editor, 999)
|
||||
|
||||
expect(caretOffsetInEditor(editor)).toBe(5)
|
||||
})
|
||||
|
||||
it('reports the end when the selection is outside the editor', () => {
|
||||
renderComposerContents(editor, 'hello')
|
||||
|
||||
const outside = document.createElement('div')
|
||||
outside.textContent = 'elsewhere'
|
||||
document.body.append(outside)
|
||||
caretAfter(outside.firstChild!, 3)
|
||||
|
||||
expect(caretOffsetInEditor(editor)).toBe(5)
|
||||
|
||||
outside.remove()
|
||||
})
|
||||
})
|
||||
126
apps/desktop/src/app/chat/composer/undo-history.ts
Normal file
126
apps/desktop/src/app/chat/composer/undo-history.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* The composer's own undo stack.
|
||||
*
|
||||
* The rich editor mutates its DOM through `Range` rather than the browser's
|
||||
* editing commands — `execCommand('insertText')` is ~O(n²) on large multiline
|
||||
* blobs and froze the composer for seconds on a big paste (#45812). The cost of
|
||||
* that bypass is that those mutations never reach Chromium's undo stack, so
|
||||
* ⌘Z skipped straight past a paste and undid whatever came *before* it, leaving
|
||||
* the pasted text stranded.
|
||||
*
|
||||
* Owning the whole stack is the only coherent fix: a half-owned one interleaves
|
||||
* our snapshots with Chromium's own typing entries and undoes them out of order.
|
||||
* So every composer edit — typed or programmatic — records here, and the editor
|
||||
* intercepts ⌘Z / ⌘⇧Z instead of letting the native command run.
|
||||
*
|
||||
* Snapshots are plain text + a caret offset, not DOM: the editor already
|
||||
* round-trips losslessly through `composerPlainText`/`renderComposerContents`,
|
||||
* so text is the smallest thing that fully restores a state.
|
||||
*/
|
||||
|
||||
export interface ComposerSnapshot {
|
||||
caret: number
|
||||
text: string
|
||||
}
|
||||
|
||||
/** Consecutive typing inside this window collapses into one undo entry, so ⌘Z
|
||||
* steps back by a burst the way a native editor does — not one character. */
|
||||
const COALESCE_WINDOW_MS = 600
|
||||
|
||||
const DEFAULT_LIMIT = 200
|
||||
|
||||
export interface ComposerUndoHistory {
|
||||
/** Drop all history and start over from `snapshot`'s state (session swap). */
|
||||
reset: () => void
|
||||
/** Redo one step. `current` is the live state, banked for a subsequent undo. */
|
||||
redo: (current: ComposerSnapshot) => ComposerSnapshot | null
|
||||
/** Bank the state that existed *before* an edit. `coalesce` merges this into
|
||||
* the previous entry when it lands inside the typing window. */
|
||||
record: (previous: ComposerSnapshot, options?: { coalesce?: boolean }) => void
|
||||
/** Undo one step. `current` is the live state, banked for a subsequent redo. */
|
||||
undo: (current: ComposerSnapshot) => ComposerSnapshot | null
|
||||
}
|
||||
|
||||
export function createComposerUndoHistory(
|
||||
limit = DEFAULT_LIMIT,
|
||||
now: () => number = () => Date.now()
|
||||
): ComposerUndoHistory {
|
||||
let past: ComposerSnapshot[] = []
|
||||
let future: ComposerSnapshot[] = []
|
||||
let lastRecordedAt = 0
|
||||
let lastWasCoalescable = false
|
||||
|
||||
const record: ComposerUndoHistory['record'] = (previous, options) => {
|
||||
const coalesce = options?.coalesce ?? false
|
||||
const at = now()
|
||||
const merges = coalesce && lastWasCoalescable && past.length > 0 && at - lastRecordedAt < COALESCE_WINDOW_MS
|
||||
|
||||
lastRecordedAt = at
|
||||
lastWasCoalescable = coalesce
|
||||
// A fresh edit invalidates anything the user had redone past.
|
||||
future = []
|
||||
|
||||
// Merging keeps the OLDER snapshot — the entry already holds the state from
|
||||
// the start of the burst, which is what ⌘Z should step back to.
|
||||
if (merges) {
|
||||
return
|
||||
}
|
||||
|
||||
// A no-op edit (same text) would make ⌘Z look broken for one press.
|
||||
if (past[past.length - 1]?.text === previous.text) {
|
||||
return
|
||||
}
|
||||
|
||||
past.push(previous)
|
||||
|
||||
if (past.length > limit) {
|
||||
past = past.slice(past.length - limit)
|
||||
}
|
||||
}
|
||||
|
||||
const step = (from: ComposerSnapshot[], to: ComposerSnapshot[], current: ComposerSnapshot) => {
|
||||
const next = from.pop()
|
||||
|
||||
if (!next) {
|
||||
return null
|
||||
}
|
||||
|
||||
to.push(current)
|
||||
// Any traversal ends the typing burst, so the next keystroke opens a new entry.
|
||||
lastWasCoalescable = false
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
return {
|
||||
record,
|
||||
redo: current => step(future, past, current),
|
||||
reset: () => {
|
||||
past = []
|
||||
future = []
|
||||
lastRecordedAt = 0
|
||||
lastWasCoalescable = false
|
||||
},
|
||||
undo: current => step(past, future, current)
|
||||
}
|
||||
}
|
||||
|
||||
/** True for the keystroke that means "undo" (⌘Z / Ctrl+Z, without Shift). */
|
||||
export function isUndoShortcut(event: Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>) {
|
||||
return (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'z'
|
||||
}
|
||||
|
||||
/** True for "redo" — ⌘⇧Z everywhere, plus Ctrl+Y on Windows/Linux. */
|
||||
export function isRedoShortcut(event: Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>) {
|
||||
if (event.altKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
|
||||
if ((event.metaKey || event.ctrlKey) && event.shiftKey && key === 'z') {
|
||||
return true
|
||||
}
|
||||
|
||||
return event.ctrlKey && !event.metaKey && !event.shiftKey && key === 'y'
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue