diff --git a/apps/desktop/src/app/chat/composer/empty-composer.test.ts b/apps/desktop/src/app/chat/composer/empty-composer.test.ts index 887f14c9750d3..dcf3398e6bcb5 100644 --- a/apps/desktop/src/app/chat/composer/empty-composer.test.ts +++ b/apps/desktop/src/app/chat/composer/empty-composer.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { + beginComposerComposition, composerPlainText, deleteChipBeforeCaret, normalizeComposerEditorDom, @@ -159,6 +160,26 @@ describe('an emptied composer shows its placeholder again', () => { expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false) }) + + // Input events are skipped for the duration of an IME composition, so nothing + // else clears the marker until it ends — the hint would sit behind the + // hiragana the user is composing (#75960). + it('hides the placeholder before IME preedit text starts', () => { + const el = emptied() + + beginComposerComposition(el) + + expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false) + }) + + it('brings the placeholder back when composition ends with nothing committed', () => { + const el = emptied() + + beginComposerComposition(el) + normalizeComposerEditorDom(el) + + expect(el.matches(PLACEHOLDER_SHOWS)).toBe(true) + }) }) /** A directive chip, as `refChipElement` builds it. */ diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index d06f82f8d6936..fb6d86515b153 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -57,6 +57,7 @@ import { ActionBadges } from './micro-actions' import { chipTypedPathOnSpace, pathifyRefs } from './path-refs' import { QueuePanel } from './queue-panel' import { + beginComposerComposition, composerPlainText, deleteChipBeforeCaret, deleteSelectionInEditor, @@ -967,8 +968,13 @@ export function ChatBar({ // until an unrelated edit forces a sync (#39614). flushEditorToDraft(event.currentTarget) }} - onCompositionStart={() => { + onCompositionStart={event => { composingRef.current = true + + // Input events are skipped for the rest of the composition, so + // nothing else would clear the empty marker until it ends — and the + // hint would sit behind the preedit text the whole time (#75960). + beginComposerComposition(event.currentTarget) }} onDragOver={handleInputDragOver} onDrop={handleInputDrop} diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index 8b5ca6cdb02c6..a7d8e0406759b 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -71,6 +71,17 @@ export function markEditorEmptiness(editor: HTMLElement) { } } +/** Drop the marker as IME composition starts, before any preedit text lands. + * + * Input events during composition are deliberately skipped (they carry + * uncommitted preedit text), so nothing else clears the marker until + * `compositionend` — and the hint would otherwise sit behind the hiragana the + * user is composing. `normalizeComposerEditorDom` restores it if composition + * ends with nothing committed. */ +export function beginComposerComposition(editor: HTMLElement) { + delete editor.dataset.empty +} + /** @see referenceRe — the shared pattern every surface recognises a reference * with. Module-level `/g` regexes carry `lastIndex`, so call sites reset it. */ export const REF_RE = referenceRe()