diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx index 05772d4f5..60f6691c9 100644 --- a/components/email/__tests__/composer-plugin-surfaces.test.tsx +++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx @@ -466,6 +466,42 @@ describe('composer-sidebar and composer-sidebar-right', () => { expect(panel('right').dataset.composerSidebarMode).toBe('folded'); }); + it('hand the panels the composer id, the draft and the selection', () => { + stubWidth(1400); + offerSlots('p1', ['composer-sidebar-right']); + render(); + + const props = slotProps(screen.getByTestId('slot-composer-sidebar-right')); + expect(typeof props.composerId).toBe('string'); + expect((props.draft as { subject: string }).subject).toBe('Lunch'); + expect(props.selection).toEqual({ text: '', empty: true }); + }); + + it('send the panels the selected text a moment after it changes', () => { + useSettingsStore.setState({ plainTextMode: true }); + try { + stubWidth(1400); + offerSlots('p1', ['composer-sidebar-right']); + const { container } = render(); + vi.useFakeTimers(); + const textarea = container.querySelector('textarea') as HTMLTextAreaElement; + + textarea.focus(); + textarea.setSelectionRange(0, 5); + act(() => { document.dispatchEvent(new Event('selectionchange')); }); + expect(slotProps(screen.getByTestId('slot-composer-sidebar-right')).selection).toEqual({ text: '', empty: true }); + + act(() => { vi.advanceTimersByTime(200); }); + expect(slotProps(screen.getByTestId('slot-composer-sidebar-right')).selection).toEqual({ text: 'Hello', empty: false }); + + textarea.setSelectionRange(2, 2); + act(() => { document.dispatchEvent(new Event('selectionchange')); vi.advanceTimersByTime(200); }); + expect(slotProps(screen.getByTestId('slot-composer-sidebar-right')).selection).toEqual({ text: '', empty: true }); + } finally { + useSettingsStore.setState({ plainTextMode: false }); + } + }); + it('open a folded panel over the composer when there is no room beside it', () => { stubWidth(600); offerSlots('p1', ['composer-sidebar-right']); diff --git a/components/email/composer-plugin-sidebars.tsx b/components/email/composer-plugin-sidebars.tsx index c2fa7860a..46982930f 100644 --- a/components/email/composer-plugin-sidebars.tsx +++ b/components/email/composer-plugin-sidebars.tsx @@ -124,11 +124,14 @@ export function ComposerPluginSidebar({ mode, onOpen, onClose, + extraProps, }: { side: ComposerSidebarSide; mode: ComposerSidebarMode | null; onOpen: () => void; onClose: () => void; + /** What the panel's plugins get: the composer id, the draft and the selection (see the slot catalogue). */ + extraProps?: Record; }) { const t = useTranslations("email_viewer"); if (!mode) return null; @@ -181,8 +184,8 @@ export function ComposerPluginSidebar({
{side === "left" - ? - : } + ? + : }
); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index c0daa416b..ddd33ec0d 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -390,6 +390,9 @@ const DRAFT_HOOK_TIMEOUT_MS = 5000; /** Longest text a plugin may insert into the message at once. */ const MAX_PLUGIN_INSERT_LENGTH = 100_000; +/** How long after the last caret move the selection goes to the plugin frames. */ +const PLUGIN_SELECTION_DELAY_MS = 150; +const EMPTY_SELECTION = { text: '', empty: true } as const; export function EmailComposer({ onSend, @@ -2279,6 +2282,9 @@ export function EmailComposer({ const pluginAttachmentSourceOffers = usePluginSlotOffers('composer-attachment-source'); const hasAboveBodyOffers = usePluginSlotOffers('composer-above-body').length > 0; const hasBelowBodyOffers = usePluginSlotOffers('composer-below-body').length > 0; + const hasLeftSidebarOffers = usePluginSlotOffers('composer-sidebar').length > 0; + const hasRightSidebarOffers = usePluginSlotOffers('composer-sidebar-right').length > 0; + const hasSidebarOffers = hasLeftSidebarOffers || hasRightSidebarOffers; // Plugin entries make the Send and Attach buttons menus even where the app // itself has nothing else to put there. const hasSendMenuActions = usePluginActions('composer-send-menu').length > 0; @@ -2619,7 +2625,7 @@ export function EmailComposer({ // composer-above-body / composer-below-body get the draft when they appear // and then 2 seconds after the last change, like onDraftChange: a new prop // object on every keystroke would post the whole body into their iframes. - const wantsPluginDraft = hasAboveBodyOffers || hasBelowBodyOffers; + const wantsPluginDraft = hasAboveBodyOffers || hasBelowBodyOffers || hasSidebarOffers; const [pluginDraft, setPluginDraft] = useState | null>(null); const pluginDraftShownRef = useRef(false); useEffect(() => { @@ -2737,9 +2743,58 @@ export function EmailComposer({ return unregister; }, []); + // The selected text of the message, for the plugin frames around it and + // the panels beside it (an assistant works on what the user highlights). + // Read from the editor's own selection state, which stays put while the + // user clicks into a panel, and sent only when it changed, a moment after + // the last change: a prop object per caret move would flood the frames. + const wantsPluginSelection = wantsPluginDraft; + const [pluginSelection, setPluginSelection] = useState<{ text: string; empty: boolean }>(EMPTY_SELECTION); + const [pluginEditor, setPluginEditor] = useState(null); + useEffect(() => { + if (!wantsPluginSelection) return; + let timer: ReturnType | null = null; + const publish = () => { + timer = null; + let next: { text: string; empty: boolean } = EMPTY_SELECTION; + if (plainTextModeRef.current) { + const textarea = bodyRef.current; + if (textarea) { + const text = textarea.value.slice(textarea.selectionStart ?? 0, textarea.selectionEnd ?? 0); + next = text ? { text, empty: false } : EMPTY_SELECTION; + } + } else if (pluginEditor) { + const { text, empty } = readEditorSelection(pluginEditor); + next = empty ? EMPTY_SELECTION : { text, empty: false }; + } + setPluginSelection((prev) => (prev.empty === next.empty && prev.text === next.text ? prev : next)); + }; + const schedule = () => { + if (timer) clearTimeout(timer); + timer = setTimeout(publish, PLUGIN_SELECTION_DELAY_MS); + }; + // The textarea has no selection event of its own that covers the + // keyboard; the document's selectionchange fires for it. + const onDocumentSelection = () => { + if (plainTextModeRef.current && document.activeElement === bodyRef.current) schedule(); + }; + document.addEventListener('selectionchange', onDocumentSelection); + pluginEditor?.on('selectionUpdate', schedule); + publish(); + return () => { + if (timer) clearTimeout(timer); + document.removeEventListener('selectionchange', onDocumentSelection); + pluginEditor?.off('selectionUpdate', schedule); + }; + }, [wantsPluginSelection, pluginEditor, plainTextMode]); + const pluginDraftProps = useMemo( - () => ({ draft: pluginDraft, insertText: pluginInsertText, getSelectedText: pluginSelectedText, composerId: pluginComposerId }), - [pluginDraft, pluginInsertText, pluginSelectedText, pluginComposerId], + () => ({ draft: pluginDraft, selection: pluginSelection, insertText: pluginInsertText, getSelectedText: pluginSelectedText, composerId: pluginComposerId }), + [pluginDraft, pluginSelection, pluginInsertText, pluginSelectedText, pluginComposerId], + ); + const pluginSidebarProps = useMemo( + () => ({ draft: pluginDraft, selection: pluginSelection, composerId: pluginComposerId }), + [pluginDraft, pluginSelection, pluginComposerId], ); const pluginToolbarProps = useMemo( () => ({ insertText: pluginInsertText, getSelectedText: pluginSelectedText, composerId: pluginComposerId }), @@ -3519,7 +3574,7 @@ export function EmailComposer({ className={cn("flex h-full bg-background relative", className)} onKeyDownCapture={handleComposerKeyDown} > - pluginSidebars.open('left')} onClose={() => pluginSidebars.close('left')} /> + pluginSidebars.open('left')} onClose={() => pluginSidebars.close('left')} extraProps={pluginSidebarProps} /> {/* Right-side composer sidebar slot is rendered after the main content div below. */}
{ editorRef.current = ed; + setPluginEditor(ed); if (!replyFocusedRef.current && (mode === 'reply' || mode === 'replyAll')) { replyFocusedRef.current = true; ed.commands.focus('start'); @@ -4670,7 +4726,7 @@ export function EmailComposer({ /> )}
- pluginSidebars.open('right')} onClose={() => pluginSidebars.close('right')} /> + pluginSidebars.open('right')} onClose={() => pluginSidebars.close('right')} extraProps={pluginSidebarProps} /> ); } diff --git a/lib/plugin-slot-catalog.ts b/lib/plugin-slot-catalog.ts index e0b9354b4..455202c0f 100644 --- a/lib/plugin-slot-catalog.ts +++ b/lib/plugin-slot-catalog.ts @@ -77,10 +77,10 @@ export const SLOT_CATALOG = { // ─── Composer ────────────────────────────────────────────── 'composer-toolbar': S('composer', 'frame', ['ui:composer-toolbar'], { insertText: 'compose:open', getSelectedText: 'email:send', composerId: null }, 'the composer\'s plugin row above its bottom bar, for controls that need their own UI (a button belongs in composer-toolbar-actions)', true), - 'composer-above-body': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { draft: 'email:send', insertText: 'compose:open', getSelectedText: 'email:send', composerId: null }, 'between the subject and the message text'), - 'composer-below-body': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { draft: 'email:send', insertText: 'compose:open', getSelectedText: 'email:send', composerId: null }, 'below the message text, above the attachments'), - 'composer-sidebar': S('composer', 'frame', ['ui:composer-sidebar'], {}, 'a panel left of the composer'), - 'composer-sidebar-right': S('composer', 'frame', ['ui:composer-sidebar'], {}, 'a panel right of the composer'), + 'composer-above-body': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { draft: 'email:send', selection: 'compose:read', insertText: 'compose:open', getSelectedText: 'email:send', composerId: null }, 'between the subject and the message text'), + 'composer-below-body': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { draft: 'email:send', selection: 'compose:read', insertText: 'compose:open', getSelectedText: 'email:send', composerId: null }, 'below the message text, above the attachments'), + 'composer-sidebar': S('composer', 'frame', ['ui:composer-sidebar'], { draft: 'email:send', selection: 'compose:read', composerId: null }, 'a panel left of the composer; `selection` is the selected text of the message as the user changes it ({ text, empty })'), + 'composer-sidebar-right': S('composer', 'frame', ['ui:composer-sidebar'], { draft: 'email:send', selection: 'compose:read', composerId: null }, 'a panel right of the composer, with the same props as composer-sidebar'), 'composer-attachment-source': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { onAttach: null, insertText: 'compose:open', composerId: null }, 'the composer\'s plugin row, next to composer-toolbar, as a source of files (older plugins; an entry in the Attach menu, composer-attach-menu, with api.composer.attach looks like the app\'s own)', true), 'composer-send-menu': S('composer', 'action', ['ui:composer-toolbar'], { draft: 'email:send', composerId: null }, 'entries in the menu next to Send'), 'composer-attach-menu': S('composer', 'action', ['ui:composer-toolbar'], { composerId: null }, 'entries in the Attach menu (for example "From Nextcloud"); the click\'s composerId lets api.composer.attach add files'),