feat: hand composer plugin panels the composer id, the draft and the selected text

This commit is contained in:
Linus Rath
2026-10-20 17:21:12 +02:00
parent 416e59fa54
commit a2136319fb
4 changed files with 106 additions and 11 deletions
@@ -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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
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(<EmailComposer initialData={{ ...DRAFT, body: 'Hello there' }} onClose={vi.fn()} />);
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']);
@@ -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<string, unknown>;
}) {
const t = useTranslations("email_viewer");
if (!mode) return null;
@@ -181,8 +184,8 @@ export function ComposerPluginSidebar({
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{side === "left"
? <PluginSlot name="composer-sidebar" className="flex flex-col" />
: <PluginSlot name="composer-sidebar-right" className="flex flex-col" />}
? <PluginSlot name="composer-sidebar" className="flex flex-col" extraProps={extraProps} />
: <PluginSlot name="composer-sidebar-right" className="flex flex-col" extraProps={extraProps} />}
</div>
</div>
);
+61 -5
View File
@@ -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<ReturnType<typeof draftSnapshot> | 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<Editor | null>(null);
useEffect(() => {
if (!wantsPluginSelection) return;
let timer: ReturnType<typeof setTimeout> | 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}
>
<ComposerPluginSidebar side="left" mode={pluginSidebars.modes.left} onOpen={() => pluginSidebars.open('left')} onClose={() => pluginSidebars.close('left')} />
<ComposerPluginSidebar side="left" mode={pluginSidebars.modes.left} onOpen={() => pluginSidebars.open('left')} onClose={() => pluginSidebars.close('left')} extraProps={pluginSidebarProps} />
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
<div
className="flex flex-col h-full bg-background relative flex-1 min-w-0"
@@ -4064,6 +4119,7 @@ export function EmailComposer({
toolbarPlacement={mvWindow ? (mvShowFormatting ? 'bottom' : 'hidden') : 'top'}
onEditorReady={(ed) => {
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({
/>
)}
</div>
<ComposerPluginSidebar side="right" mode={pluginSidebars.modes.right} onOpen={() => pluginSidebars.open('right')} onClose={() => pluginSidebars.close('right')} />
<ComposerPluginSidebar side="right" mode={pluginSidebars.modes.right} onOpen={() => pluginSidebars.open('right')} onClose={() => pluginSidebars.close('right')} extraProps={pluginSidebarProps} />
</div>
);
}
+4 -4
View File
@@ -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'),