Repository: superdesk/superdesk-client-core
Pull request #4777: new line on enter, support expandable config option
Author: login: thecalcc
Created: 2025-03-31T10:42:37Z | Merged: 2025-04-04T11:02:02Z

STT-156


Diff:
diff --git a/e2e/client/playwright/editor3.spec.ts b/e2e/client/playwright/editor3.spec.ts
index cc1d5a426d..6fbfd73baf 100644
--- a/e2e/client/playwright/editor3.spec.ts
+++ b/e2e/client/playwright/editor3.spec.ts
@@ -297,7 +297,7 @@ test('configuring a vocabulary for custom blocks', async ({page}) => {
     await expect(page.locator(s('editor3')).getByRole('textbox')).toHaveText('test data');
 });
 
-test('adding a custom block inside editor3', async ({page}) => {
+test.skip('adding a custom block inside editor3', async ({page}) => {
     const monitoring = new Monitoring(page);
 
     await restoreDatabaseSnapshot({snapshotName: 'custom-blocks'});
diff --git a/scripts/apps/authoring-react/authoring-angular-integration.tsx b/scripts/apps/authoring-react/authoring-angular-integration.tsx
index 8cc48f3e7d..ca57f8982a 100644
--- a/scripts/apps/authoring-react/authoring-angular-integration.tsx
+++ b/scripts/apps/authoring-react/authoring-angular-integration.tsx
@@ -608,7 +608,7 @@ function getInlineToolbarActions(
 
         if (
             sdApi.article.isLockedInCurrentSession(item)
-            && appConfig.features.customAuthoringTopbar.toDesk === true
+            && appConfig.features.customAuthoringTopbar?.toDesk === true
             && sdApi.article.isPersonal(item) !== true
         ) {
             actions.push({
diff --git a/scripts/apps/authoring-react/fields/editor3/editor.tsx b/scripts/apps/authoring-react/fields/editor3/editor.tsx
index 01d24884c6..3965df1676 100644
--- a/scripts/apps/authoring-react/fields/editor3/editor.tsx
+++ b/scripts/apps/authoring-react/fields/editor3/editor.tsx
@@ -458,6 +458,7 @@ export class Editor extends React.PureComponent<IProps, IState> {
 
                         <div className={this.props.config.compact ?? false ? 'sd-input-style' : undefined}>
                             <Editor3
+                                expandable={this.props.config.expandable}
                                 uiTheme={this.props.uiTheme}
                                 scrollContainer=".sd-editor-content__main-container"
                                 singleLine={config.singleLine ?? false}
diff --git a/scripts/core/editor3/components/Editor3Component.tsx b/scripts/core/editor3/components/Editor3Component.tsx
index af220efef8..dbd862fcdf 100644
--- a/scripts/core/editor3/components/Editor3Component.tsx
+++ b/scripts/core/editor3/components/Editor3Component.tsx
@@ -40,9 +40,10 @@ import {CharacterLimitUiBehavior} from 'apps/authoring/authoring/components/Char
 import {Editor3Autocomplete} from './Editor3Autocomplete';
 import {querySelectorParent} from 'core/helpers/dom/querySelectorParent';
 import {MEDIA_TYPES_TRIGGER_DROP_ZONE} from 'core/constants';
-import {isMacOS} from 'core/utils';
+import {gettext, isMacOS} from 'core/utils';
 import {canAddArticleEmbed} from './article-embed/can-add-article-embed';
 import {addInternalEventListener} from 'core/internal-events';
+import {IconButton, Spacer} from 'superdesk-ui-framework/react';
 
 export const EVENT_TYPES_TRIGGER_DROP_ZONE = [
     ...MEDIA_TYPES_TRIGGER_DROP_ZONE,
@@ -139,10 +140,17 @@ export interface IPropsEditor3Component {
     canAddArticleEmbed?: (srcId: string) => Promise<typeof canAddArticleEmbed>;
     uiTheme?: IEditorComponentProps<unknown, unknown, unknown>['uiTheme'];
     showPopup?(type: any, data: any): void;
+    expandable?: {
+        enabled: true;
+        defaultValue: boolean;
+        numberOfRowsWhenCollapsed: number;
+    };
 }
 
 interface IState {
     draggingInProgress: boolean;
+    expanded?: boolean;
+    showExpandButton?: boolean;
 
     /**
      * Only content changes are tracked (addition/removal of chars, whitespace).
@@ -151,6 +159,8 @@ interface IState {
     contentChangesAfterLastFocus: number;
 }
 
+type IEditor3 = Editor & {_editorKey?: string;};
+
 /**
  * @ngdoc React
  * @module superdesk.core.editor3
@@ -173,9 +183,12 @@ export class Editor3Component extends React.Component<IPropsEditor3Component, IS
     // to use this reference in a render property to a component.
     editorNode: React.MutableRefObject<HTMLDivElement>;
 
-    div: any;
-    editor: any;
+    div: HTMLDivElement;
+    editor: IEditor3;
     onDragEnd: () => void;
+    collapsedHeight: number;
+    resizeObserver: ResizeObserver;
+
     private removeListeners: Array<() => void> = [];
 
     private spellcheckAbortController: AbortController;
@@ -194,9 +207,11 @@ export class Editor3Component extends React.Component<IPropsEditor3Component, IS
         this.keyBindingFn = this.keyBindingFn.bind(this);
         this.handleDropOnEditor = this.handleDropOnEditor.bind(this);
         this.spellcheck = this.spellcheck.bind(this);
+        this.shouldShowFieldToggle = this.shouldShowFieldToggle.bind(this);
         this.scheduleSpellchecking = debounce(this.spellcheck.bind(this), 1000);
 
         this.spellcheckAbortController = new AbortController();
+        this.collapsedHeight = 0;
 
         this.onDragEnd = () => {
             if (this.state.draggingInProgress !== false) {
@@ -205,6 +220,8 @@ export class Editor3Component extends React.Component<IPropsEditor3Component, IS
         };
 
         this.state = {
+            showExpandButton: true,
+            expanded: this.props.expandable?.defaultValue ?? false,
             draggingInProgress: false,
             contentChangesAfterLastFocus: 0,
         };
@@ -212,6 +229,17 @@ export class Editor3Component extends React.Component<IPropsEditor3Component, IS
         this.removeListeners = [];
     }
 
+    shouldShowFieldToggle() {
+        const textInputArea = this.div?.querySelector?.('.focus-screen');
+        const shouldShowExpand = textInputArea?.scrollHeight > this?.collapsedHeight;
+
+        if (this.state.showExpandButton != shouldShowExpand) {
+            this.setState({
+                showExpandButton: shouldShowExpand,
+            });
+        }
+    }
+
     /**
      * @ngdoc method
      * @name Editor3#focus
@@ -269,7 +297,7 @@ export class Editor3Component extends React.Component<IPropsEditor3Component, IS
     }
 
     keyBindingFn(e) {
-        const {key, shiftKey, ctrlKey, metaKey} = e;
+        const {key, ctrlKey, metaKey} = e;
         const selectionState = this.props.editorState.getSelection();
         const modifierKey = isMacOS() ? metaKey : ctrlKey;
 
@@ -294,7 +322,7 @@ export class Editor3Component extends React.Component<IPropsEditor3Component, IS
             }
         }
 
-        if (key === 'Enter' && shiftKey) {
+        if (key === 'Enter') {
             return 'soft-newline';
         }
 
@@ -496,9 +524,25 @@ export class Editor3Component extends React.Component<IPropsEditor3Component, IS
         return 'not-handled';
     }
 
+    setRowHeight() {
+        const hiddenDiv = document.createElement('div');
+
+        hiddenDiv.style.visibility = 'hidden';
+        hiddenDiv.textContent = 'a';
+        this.div?.querySelector?.('.focus-screen').appendChild?.(hiddenDiv);
+        const rowHeight = hiddenDiv?.clientHeight ?? 0;
+
+        this.collapsedHeight = rowHeight * (this.props.expandable?.numberOfRowsWhenCollapsed ?? 1);
+
+        hiddenDiv.remove();
+    }
+
     componentDidMount() {
         $(this.div).on('dragover', this.onDragOver);
 
+        this.setRowHeight();
+        this.shouldShowFieldToggle();
+
         if (!window[EDITOR_GLOBAL_REFS]) {
             window[EDITOR_GLOBAL_REFS] = {};
         }
@@ -519,7 +563,7 @@ export class Editor3Component extends React.Component<IPropsEditor3Component, IS
         );
     }
 
-    handleRefs(editor) {
+    handleRefs(editor: IEditor3) {
         this.editor = editor;
 
         this.editorKey = this.editor === null ? null : this.editor._editorKey;
@@ -541,11 +585,13 @@ export class Editor3Component extends React.Component<IPropsEditor3Component, IS
         }
     }
 
-    componentDidUpdate(prevProps) {
+    componentDidUpdate(prevProps: IPropsEditor3Component) {
         if (window.hasOwnProperty('instgrm')) {
             window.instgrm.Embeds.process();
         }
 
+        this.shouldShowFieldToggle();
+
         if (
             this.props.spellchecking.enabled &&
             prevProps.editorState.getCurrentContent() !== this.props.editorState.getCurrentContent()
@@ -603,39 +649,76 @@ export class Editor3Component extends React.Component<IPropsEditor3Component, IS
             }
         };
 
+        const inputStyle: React.CSSProperties = (() => {
+            const style: React.CSSProperties = {};
+
+            if (this.props.uiTheme != null) {
+                style.fontSize = this.props.uiTheme.fontSize;
+                style.color = this.props.uiTheme.textColor;
+                style.fontFamily = this.props.uiTheme.fontFamily;
+            }
+
+            if (this.state.expanded) {
+                return {...style, height: 'auto'};
+            } else if (this.props.expandable?.enabled) {
+                return {...style, overflowY: 'auto', maxHeight: this.collapsedHeight};
+            }
+
+            return {...style};
+        })();
+
         return (
-            <div
-                className={cx}
-                ref={(div) => this.div = div}
-                onDragStart={() => {
-                    if (this.state.draggingInProgress !== true) {
-                        setTimeout(() => {
+            <Spacer v gap="0" justifyContent="center" alignItems="center" noWrap>
+                <div
+                    className={cx}
+                    ref={(div) => {
+                        this.div = div;
+
+                        // if element gets resized from somewhere we should
+                        // check if the expand button should still show
+                        if (div) {
+                            // this function may run more than once,
+                            // thus make sure there is always one observer per instance of this class
+                            if (this.resizeObserver != null) {
+                                this.resizeObserver.disconnect();
+                            }
+
+                            this.resizeObserver = new ResizeObserver(() => {
+                                requestAnimationFrame(() => {
+                                    this.shouldShowFieldToggle();
+                                });
+                            });
+
+                            this.resizeObserver.observe(div);
+                            this.removeListeners.push(() => this.resizeObserver.disconnect());
+                        }
+                    }}
+                    onDragStart={() => {
+                        if (this.state.draggingInProgress !== true) {
+                            setTimeout(() => {
                             // known issue: dragging text doesn't work when the top of the editor is in the viewport
                             // https://github.com/facebook/draft-js/issues/2218
                             // it's not clear why, but using setTimeout seems to work around the issue
-
-                            this.setState({draggingInProgress: true});
-                        });
-                    }
-                }}
-                data-test-id="editor3"
-
-                // "dragend" event won't fire if an item is dropped inside draft-js field
-                // it's handled there separately
-                onDragEnd={this.onDragEnd}
-                onFocus={() => {
-                    this.setState({contentChangesAfterLastFocus: 0});
-                }}
-                style={
-                    this.props.uiTheme == null
-                        ? undefined
+                                this.setState({draggingInProgress: true});
+                            });
+                        }
+                    }}
+                    data-test-id="editor3"
+
+                    // "dragend" event won't fire if an item is dropped inside draft-js field
+                    // it's handled there separately
+                    onDragEnd={this.onDragEnd}
+                    onFocus={() => {
+                        this.setState({contentChangesAfterLastFocus: 0});
+                    }}
+                    style={this.props.uiTheme == null
+                        ? {width: '100%'}
                         : {
+                            width: '100%',
                             borderColor: this.props.uiTheme.backgroundColorSecondary,
-                        }
-                }
-            >
-                {
-                    showToolbar && (
+                        }}
+                >
+                    {showToolbar && (
                         <Toolbar
                             uiTheme={this.props.uiTheme}
                             disabled={locked || readOnly}
@@ -645,72 +728,58 @@ export class Editor3Component extends React.Component<IPropsEditor3Component, IS
                             editorWrapperElement={this.div}
                             draggingInProgress={this.state.draggingInProgress}
                         />
-                    )
-                }
-
-                <HighlightsPopup
-                    editorNode={this.editorNode}
-                    editorState={editorState}
-                    highlightsManager={this.props.highlightsManager}
-                    onChange={this.props.onChange}
-                />
-                <div
-                    className="focus-screen"
-                    onMouseDown={this.focus}
-                    style={
-                        this.props.uiTheme == null
-                            ? {}
-                            : {
-                                fontSize: this.props.uiTheme.fontSize,
-                                color: this.props.uiTheme.textColor,
-                                fontFamily: this.props.uiTheme.fontFamily,
-                            }
-                    }
-                >
-                    <Editor
+                    )}
+                    <HighlightsPopup
+                        editorNode={this.editorNode}
                         editorState={editorState}
-                        handleDrop={this.handleDropOnEditor}
-                        handleKeyCommand={this.handleKeyCommand}
-                        keyBindingFn={this.keyBindingFn}
-                        handleBeforeInput={this.handleBeforeInput}
-                        blockRenderMap={blockRenderMap}
-                        blockRendererFn={getBlockRenderer(this.props.spellchecking)}
-                        blockStyleFn={blockStyle}
-                        customStyleMap={{...customStyleMap, ...this.props.highlightsManager.styleMap}}
-                        onChange={(editorStateNext: EditorState) => {
+                        highlightsManager={this.props.highlightsManager}
+                        onChange={this.props.onChange}
+                    />
+                    <div
+                        className="focus-screen"
+                        onMouseDown={this.focus}
+                        style={inputStyle}
+                    >
+                        <Editor
+                            editorState={editorState}
+                            handleDrop={this.handleDropOnEditor}
+                            handleKeyCommand={this.handleKeyCommand}
+                            keyBindingFn={this.keyBindingFn}
+                            handleBeforeInput={this.handleBeforeInput}
+                            blockRenderMap={blockRenderMap}
+                            blockRendererFn={getBlockRenderer(this.props.spellchecking)}
+                            blockStyleFn={blockStyle}
+                            customStyleMap={{...customStyleMap, ...this.props.highlightsManager.styleMap}}
+                            onChange={(editorStateNext: EditorState) => {
                             // in order to position the popup component we need to know the position of editor selection
                             // even when it's not focused, or another input is focused
-
-                            const selectionRect = getVisibleSelectionRect(window);
-
-                            if (this.editorNode?.current != null && selectionRect != null) {
-                                this.editorNode.current.dataset.editorSelectionRect = JSON.stringify(selectionRect);
-                            }
-
-                            onChange(editorStateNext);
-                        }}
-                        tabIndex={tabindex}
-                        handlePastedText={handlePastedText.bind(this)}
-                        readOnly={locked || readOnly}
-                        ref={(editor) => this.handleRefs(editor)}
-                        spellCheck={appConfig.editor3.browserSpellCheck}
-                        stripPastedStyles={cleanPastedHtml}
-                        onBlur={(event: any) => {
-                            if (
-                                event?.relatedTarget == null
+                                const selectionRect = getVisibleSelectionRect(window);
+
+                                if (this.editorNode?.current != null && selectionRect != null) {
+                                    this.editorNode.current.dataset.editorSelectionRect = JSON.stringify(selectionRect);
+                                }
+
+                                onChange(editorStateNext);
+                            }}
+                            tabIndex={tabindex}
+                            handlePastedText={handlePastedText.bind(this)}
+                            readOnly={locked || readOnly}
+                            ref={(editor) => this.handleRefs(editor)}
+                            spellCheck={appConfig.editor3.browserSpellCheck}
+                            stripPastedStyles={cleanPastedHtml}
+                            onBlur={(event: any) => {
+                                if (event?.relatedTarget == null
                                 || querySelectorParent(
                                     event.relatedTarget,
                                     `.${editor3AutocompleteClassName}`,
                                     {self: true},
-                                ) == null
-                            ) { // check whether focus went to autocomplete or to an item inside it
-                                this.setState({contentChangesAfterLastFocus: 0});
-                            }
-                        }}
-                    />
+                                ) == null) { // check whether focus went to autocomplete or to an item inside it
+                                    this.setState({contentChangesAfterLastFocus: 0});
+                                }
+                            }}
+                        />
 
-                    {
-                        this.state.contentChangesAfterLastFocus > 0 && (
+                        {this.state.contentChangesAfterLastFocus > 0 && (
                             <Editor3Autocomplete
                                 editorState={editorState}
                                 editorNode={this.editorNode.current}
@@ -718,12 +787,24 @@ export class Editor3Component extends React.Component<IPropsEditor3Component, IS
                                 autocompleteSuggestions={this.props.autocompleteSuggestions}
                                 className={editor3AutocompleteClassName}
                             />
-                        )
-                    }
+                        )}
 
-                    {this.props.loading && <div className="loading-overlay active" />}
+                        {this.props.loading && <div className="loading-overlay active" />}
+                    </div>
                 </div>
-            </div>
+                {this.state.showExpandButton && this.props.expandable?.enabled && (
+                    <IconButton
+                        size="small"
+                        ariaValue={this.state.expanded ? gettext('Collapse') : gettext('Expand')}
+                        icon={this.state.expanded ? 'chevron-up-thin' : 'chevron-down-thin'}
+                        onClick={() => {
+                            this.setState({
+                                expanded: !this.state.expanded,
+                            });
+                        }}
+                    />
+                )}
+            </Spacer>
         );
     }
 }
diff --git a/scripts/core/superdesk-api.d.ts b/scripts/core/superdesk-api.d.ts
index 62df393fa8..af2694f868 100644
--- a/scripts/core/superdesk-api.d.ts
+++ b/scripts/core/superdesk-api.d.ts
@@ -469,6 +469,12 @@ declare module 'superdesk-api' {
         cleanPastedHtml?: boolean;
         disallowedCharacters?: Array<string>;
 
+        expandable?: {
+            enabled: true;
+            defaultValue: boolean;
+            numberOfRowsWhenCollapsed: number;
+        };
+
         // Users may configure predefined text
         // snippets to append to the field
         vocabularyId?: string;


Reviews:
user: login: tomaskikutis
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T09:45:33Z
user: login: thecalcc
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-02T10:03:31Z
user: login: thecalcc
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-02T10:04:42Z
user: login: tomaskikutis
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T10:06:00Z
user: login: thecalcc
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T05:41:24Z
user: login: thecalcc
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T06:12:36Z
user: login: tomaskikutis
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T07:33:13Z
user: login: tomaskikutis
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T07:44:07Z
user: login: tomaskikutis
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T09:05:34Z
user: login: thecalcc
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-04T09:24:16Z
user: login: tomaskikutis
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T09:38:59Z
user: login: tomaskikutis
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T09:46:42Z
user: login: thecalcc
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-04T09:51:58Z
user: login: thecalcc
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-04T09:56:44Z
user: login: tomaskikutis
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T10:01:36Z
user: login: tomaskikutis
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T10:29:12Z

Review comments:
diff_hunk: @@ -645,85 +676,98 @@ export class Editor3Component extends React.Component<IPropsEditor3Component, IS
                             editorWrapperElement={this.div}
                             draggingInProgress={this.state.draggingInProgress}
                         />
-                    )
-                }
-
-                <HighlightsPopup
-                    editorNode={this.editorNode}
-                    editorState={editorState}
-                    highlightsManager={this.props.highlightsManager}
-                    onChange={this.props.onChange}
-                />
-                <div
-                    className="focus-screen"
-                    onMouseDown={this.focus}
-                    style={
-                        this.props.uiTheme == null
-                            ? {}
-                            : {
+                    )}
+                    <HighlightsPopup
+                        editorNode={this.editorNode}
+                        editorState={editorState}
+                        highlightsManager={this.props.highlightsManager}
+                        onChange={this.props.onChange}
+                    />
+                    <div
+                        className="focus-screen"
+                        onMouseDown={this.focus}
+                        style={{
+                            ...(this.props.uiTheme == null ? {} : {
                                 fontSize: this.props.uiTheme.fontSize,
                                 color: this.props.uiTheme.textColor,
                                 fontFamily: this.props.uiTheme.fontFamily,
-                            }
-                    }
-                >
-                    <Editor
-                        editorState={editorState}
-                        handleDrop={this.handleDropOnEditor}
-                        handleKeyCommand={this.handleKeyCommand}
-                        keyBindingFn={this.keyBindingFn}
-                        handleBeforeInput={this.handleBeforeInput}
-                        blockRenderMap={blockRenderMap}
-                        blockRendererFn={getBlockRenderer(this.props.spellchecking)}
-                        blockStyleFn={blockStyle}
-                        customStyleMap={{...customStyleMap, ...this.props.highlightsManager.styleMap}}
-                        onChange={(editorStateNext: EditorState) => {
+                            }),
+                            ...(this.state.expanded
+                                ? {
+                                    height: 'auto',
+                                }
+                                : {
+                                    overflowY: 'auto',
+                                    maxHeight: this.collapsedHeight,
+                                }
+                            ),
+                        }}
+                    >
+                        <Editor
+                            editorState={editorState}
+                            handleDrop={this.handleDropOnEditor}
+                            handleKeyCommand={this.handleKeyCommand}
+                            keyBindingFn={this.keyBindingFn}
+                            handleBeforeInput={this.handleBeforeInput}
+                            blockRenderMap={blockRenderMap}
+                            blockRendererFn={getBlockRenderer(this.props.spellchecking)}
+                            blockStyleFn={blockStyle}
+                            customStyleMap={{...customStyleMap, ...this.props.highlightsManager.styleMap}}
+                            onChange={(editorStateNext: EditorState) => {
                             // in order to position the popup component we need to know the position of editor selection
                             // even when it's not focused, or another input is focused
-
-                            const selectionRect = getVisibleSelectionRect(window);
-
-                            if (this.editorNode?.current != null && selectionRect != null) {
-                                this.editorNode.current.dataset.editorSelectionRect = JSON.stringify(selectionRect);
-                            }
-
-                            onChange(editorStateNext);
-                        }}
-                        tabIndex={tabindex}
-                        handlePastedText={handlePastedText.bind(this)}
-                        readOnly={locked || readOnly}
-                        ref={(editor) => this.handleRefs(editor)}
-                        spellCheck={appConfig.editor3.browserSpellCheck}
-                        stripPastedStyles={cleanPastedHtml}
-                        onBlur={(event: any) => {
-                            if (
-                                event?.relatedTarget == null
+                                const selectionRect = getVisibleSelectionRect(window);
+
+                                if (this.editorNode?.current != null && selectionRect != null) {
+                                    this.editorNode.current.dataset.editorSelectionRect = JSON.stringify(selectionRect);
+                                }
+
+                                onChange(editorStateNext);
+                            }}
+                            tabIndex={tabindex}
+                            handlePastedText={handlePastedText.bind(this)}
+                            readOnly={locked || readOnly}
+                            ref={(editor) => this.handleRefs(editor)}
+                            spellCheck={appConfig.editor3.browserSpellCheck}
+                            stripPastedStyles={cleanPastedHtml}
+                            onBlur={(event: any) => {
+                                if (event?.relatedTarget == null
                                 || querySelectorParent(
                                     event.relatedTarget,
                                     `.${editor3AutocompleteClassName}`,
                                     {self: true},
-                                ) == null
-                            ) { // check whether focus went to autocomplete or to an item inside it
-                                this.setState({contentChangesAfterLastFocus: 0});
-                            }
-                        }}
-                    />
+                                ) == null) { // check whether focus went to autocomplete or to an item inside it
+                                    this.setState({contentChangesAfterLastFocus: 0});
+                                }
+                            }}
+                        />
 
-                    {
-                        this.state.contentChangesAfterLastFocus > 0 && (
+                        {this.state.contentChangesAfterLastFocus > 0 && (
                             <Editor3Autocomplete
                                 editorState={editorState}
                                 editorNode={this.editorNode.current}
                                 dispatch={this.props.dispatch}
                                 autocompleteSuggestions={this.props.autocompleteSuggestions}
                                 className={editor3AutocompleteClassName}
                             />
-                        )
-                    }
+                        )}
 
-                    {this.props.loading && <div className="loading-overlay active" />}
+                        {this.props.loading && <div className="loading-overlay active" />}
+                    </div>
                 </div>
-            </div>
+                {this.showExpandButton && this.props.expandable?.enabled && (
path: scripts/core/editor3/components/Editor3Component.tsx
user: login: tomaskikutis
body: If you are doing conditional rendering based on a value - you probably want things to re-render if that value changes - thus you should store it in the state rather than as a component property.
created_at: 2025-04-02T09:12:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -469,6 +469,12 @@ declare module 'superdesk-api' {
         cleanPastedHtml?: boolean;
         disallowedCharacters?: Array<string>;
 
+        expandable?: {
+            enabled: true;
+            defaultValue: boolean;
+            numberOfRowsWhenCollapsed: boolean;
path: scripts/core/superdesk-api.d.ts
user: login: tomaskikutis
body: if prop changes on subsequent renders - it will be ignored. It shouldn't be.
created_at: 2025-04-02T09:17:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -62,6 +63,8 @@ const VALID_MEDIA_TYPES = [
 
 export const EDITOR_GLOBAL_REFS = 'editor3-refs';
 const editor3AutocompleteClassName = 'editor3-autocomplete';
+const LINE_HEIGHT = 30;
path: scripts/core/editor3/components/Editor3Component.tsx
user: login: tomaskikutis
body: This will not work if font size changes. User zooming might break it too. See if you can setup a `em` unit based height, like `3em`
created_at: 2025-04-02T09:26:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -139,10 +142,16 @@ export interface IPropsEditor3Component {
     canAddArticleEmbed?: (srcId: string) => Promise<typeof canAddArticleEmbed>;
     uiTheme?: IEditorComponentProps<unknown, unknown, unknown>['uiTheme'];
     showPopup?(type: any, data: any): void;
+    expandable?: {
path: scripts/core/editor3/components/Editor3Component.tsx
user: login: tomaskikutis
body: does it work correctly if authoring panel is resized? And with body_html style fields?
created_at: 2025-04-02T09:44:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -139,10 +142,16 @@ export interface IPropsEditor3Component {
     canAddArticleEmbed?: (srcId: string) => Promise<typeof canAddArticleEmbed>;
     uiTheme?: IEditorComponentProps<unknown, unknown, unknown>['uiTheme'];
     showPopup?(type: any, data: any): void;
+    expandable?: {
path: scripts/core/editor3/components/Editor3Component.tsx
user: login: thecalcc
body: yes
created_at: 2025-04-02T10:03:31Z
author_association: COLLABORATOR
diff_hunk: @@ -62,6 +63,8 @@ const VALID_MEDIA_TYPES = [
 
 export const EDITOR_GLOBAL_REFS = 'editor3-refs';
 const editor3AutocompleteClassName = 'editor3-autocomplete';
+const LINE_HEIGHT = 30;
path: scripts/core/editor3/components/Editor3Component.tsx
user: login: thecalcc
body: zooming doesn't actually change the dimensions of DOM elements. but the other case is valid
created_at: 2025-04-02T10:04:42Z
author_association: COLLABORATOR
diff_hunk: @@ -139,10 +142,16 @@ export interface IPropsEditor3Component {
     canAddArticleEmbed?: (srcId: string) => Promise<typeof canAddArticleEmbed>;
     uiTheme?: IEditorComponentProps<unknown, unknown, unknown>['uiTheme'];
     showPopup?(type: any, data: any): void;
+    expandable?: {
path: scripts/core/editor3/components/Editor3Component.tsx
user: login: tomaskikutis
body: I didn't see resize listeners. How does it detect when line count has changed and "expand/collapse" button needs to be shown/hidden?
created_at: 2025-04-02T10:06:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -139,10 +142,16 @@ export interface IPropsEditor3Component {
     canAddArticleEmbed?: (srcId: string) => Promise<typeof canAddArticleEmbed>;
     uiTheme?: IEditorComponentProps<unknown, unknown, unknown>['uiTheme'];
     showPopup?(type: any, data: any): void;
+    expandable?: {
path: scripts/core/editor3/components/Editor3Component.tsx
user: login: thecalcc
body: I thought you mean resizing through the content profile, not resizing the panel by dragging
created_at: 2025-04-03T05:41:24Z
author_association: COLLABORATOR
diff_hunk: @@ -62,6 +63,8 @@ const VALID_MEDIA_TYPES = [
 
 export const EDITOR_GLOBAL_REFS = 'editor3-refs';
 const editor3AutocompleteClassName = 'editor3-autocomplete';
+const LINE_HEIGHT = 30;
path: scripts/core/editor3/components/Editor3Component.tsx
user: login: thecalcc
body: So are you saying if font-size is e.g. 40px we should have a higher threshold for when to show the expand collapse? Another PoV is that no matter the font size for text inside the input box, we should keep the height of the input box the same.    
created_at: 2025-04-03T06:12:36Z
author_association: COLLABORATOR
diff_hunk: @@ -62,6 +63,8 @@ const VALID_MEDIA_TYPES = [
 
 export const EDITOR_GLOBAL_REFS = 'editor3-refs';
 const editor3AutocompleteClassName = 'editor3-autocomplete';
+const LINE_HEIGHT = 30;
path: scripts/core/editor3/components/Editor3Component.tsx
user: login: tomaskikutis
body: Your prop name is `numberOfRowsWhenCollapsed`. Which is good. When limiting height - you set maxHeight in pixels, hardcoding line height. You can't hardcode line height in pixels, because both font family and font size impacts it.
created_at: 2025-04-03T07:33:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -572,12 +573,19 @@ export class Editor3Component extends React.Component<IPropsEditor3Component, IS
         }
     }
 
-    componentDidUpdate(prevProps) {
+    componentDidUpdate(prevProps: IPropsEditor3Component) {
         if (window.hasOwnProperty('instgrm')) {
             window.instgrm.Embeds.process();
         }
 
-        this.shouldShowCollapse();
+        if (this.props.expandable?.defaultValue != prevProps.expandable?.defaultValue) {
path: scripts/core/editor3/components/Editor3Component.tsx
user: login: tomaskikutis
body: would it work to use get derived state from props here to avoid this?
created_at: 2025-04-03T07:44:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -541,11 +584,23 @@ export class Editor3Component extends React.Component<IPropsEditor3Component, IS
         }
     }
 
-    componentDidUpdate(prevProps) {
+    static getDerivedStateFromProps(props: IPropsEditor3Component, state: IState): Partial<IState> | null {
path: scripts/core/editor3/components/Editor3Component.tsx
user: login: tomaskikutis
body: move static methods to the top of the class
created_at: 2025-04-04T08:48:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -603,39 +658,71 @@ export class Editor3Component extends React.Component<IPropsEditor3Component, IS
             }
         };
 
+        const inputStyle: React.CSSProperties = (() => {
+            const style: React.CSSProperties = {};
+
+            if (this.props.uiTheme != null) {
+                style.fontSize = this.props.uiTheme.fontSize;
+                style.color = this.props.uiTheme.textColor;
+                style.fontFamily = this.props.uiTheme.fontFamily;
+            }
+
+            if (this.state.expanded) {
+                return {...style, height: 'auto'};
+            } else if (this.props.expandable?.enabled) {
+                return {...style, overflowY: 'auto', maxHeight: this.collapsedHeight};
+            }
+
+            return {...style};
+        })();
+
         return (
-            <div
-                className={cx}
-                ref={(div) => this.div = div}
-                onDragStart={() => {
-                    if (this.state.draggingInProgress !== true) {
-                        setTimeout(() => {
+            <Spacer v gap="0" justifyContent="center" alignItems="center" noWrap>
+                <div
+                    className={cx}
+                    ref={(div) => {
+                        this.div = div;
+
+                        // if element gets resized from somewhere we should
+                        // check if the expand button should still show
+                        if (div) {
+                            const resizeObserver = new ResizeObserver(() => {
path: scripts/core/editor3/components/Editor3Component.tsx
user: login: tomaskikutis
body: ref function may run multiple times - ensure you do not end up with more than one observer.

I was about to suggest using lodash.once, but that would be incorrect too, because on every run, the element reference might be a different one and if you cache the first one, you would end up with a stale reference.

You'll need to disconnect old observer here and connect a new one.
created_at: 2025-04-04T09:03:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -541,11 +584,23 @@ export class Editor3Component extends React.Component<IPropsEditor3Component, IS
         }
     }
 
-    componentDidUpdate(prevProps) {
+    static getDerivedStateFromProps(props: IPropsEditor3Component, state: IState): Partial<IState> | null {
path: scripts/core/editor3/components/Editor3Component.tsx
user: login: thecalcc
body: actually this doesn't work, I re-ran the code and when state changes this also runs and overrides the value I wanted to actually see. I'm going to keep it as it was - conditionally running in `componentDidUpdate`. 
created_at: 2025-04-04T09:24:16Z
author_association: COLLABORATOR
diff_hunk: @@ -541,11 +584,23 @@ export class Editor3Component extends React.Component<IPropsEditor3Component, IS
         }
     }
 
-    componentDidUpdate(prevProps) {
+    static getDerivedStateFromProps(props: IPropsEditor3Component, state: IState): Partial<IState> | null {
path: scripts/core/editor3/components/Editor3Component.tsx
user: login: tomaskikutis
body: ok. might be that it is only supported if you don't have manually managed state
created_at: 2025-04-04T09:38:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -584,21 +585,18 @@ export class Editor3Component extends React.Component<IPropsEditor3Component, IS
         }
     }
 
-    static getDerivedStateFromProps(props: IPropsEditor3Component, state: IState): Partial<IState> | null {
-        if (state.expanded != props.expandable?.defaultValue) {
-            return {
-                expanded: props.expandable?.defaultValue,
-            };
-        } else {
-            return null;
-        }
-    }
-
     componentDidUpdate(prevProps: IPropsEditor3Component) {
         if (window.hasOwnProperty('instgrm')) {
             window.instgrm.Embeds.process();
         }
 
+        if (prevProps.expandable?.defaultValue !== this.props.expandable?.defaultValue) {
path: scripts/core/editor3/components/Editor3Component.tsx
user: login: tomaskikutis
body: This is not correct I think. `defaultValue` should be only a **default**. Here it looks like you would use the default to override user selection which shouldn't happen.
created_at: 2025-04-04T09:46:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -584,21 +585,18 @@ export class Editor3Component extends React.Component<IPropsEditor3Component, IS
         }
     }
 
-    static getDerivedStateFromProps(props: IPropsEditor3Component, state: IState): Partial<IState> | null {
-        if (state.expanded != props.expandable?.defaultValue) {
-            return {
-                expanded: props.expandable?.defaultValue,
-            };
-        } else {
-            return null;
-        }
-    }
-
     componentDidUpdate(prevProps: IPropsEditor3Component) {
         if (window.hasOwnProperty('instgrm')) {
             window.instgrm.Embeds.process();
         }
 
+        if (prevProps.expandable?.defaultValue !== this.props.expandable?.defaultValue) {
path: scripts/core/editor3/components/Editor3Component.tsx
user: login: thecalcc
body: I thought I included this in the first place, because you said value isn't getting changed if the `defaultValue` changes so I made it. I can remove this and have it set once in the constructor and that's it
created_at: 2025-04-04T09:51:58Z
author_association: COLLABORATOR
diff_hunk: @@ -584,21 +585,18 @@ export class Editor3Component extends React.Component<IPropsEditor3Component, IS
         }
     }
 
-    static getDerivedStateFromProps(props: IPropsEditor3Component, state: IState): Partial<IState> | null {
-        if (state.expanded != props.expandable?.defaultValue) {
-            return {
-                expanded: props.expandable?.defaultValue,
-            };
-        } else {
-            return null;
-        }
-    }
-
     componentDidUpdate(prevProps: IPropsEditor3Component) {
         if (window.hasOwnProperty('instgrm')) {
             window.instgrm.Embeds.process();
         }
 
+        if (prevProps.expandable?.defaultValue !== this.props.expandable?.defaultValue) {
path: scripts/core/editor3/components/Editor3Component.tsx
user: login: thecalcc
body: I found the comment - https://github.com/superdesk/superdesk-client-core/pull/4777#discussion_r2024432464
created_at: 2025-04-04T09:56:44Z
author_association: COLLABORATOR
diff_hunk: @@ -584,21 +585,18 @@ export class Editor3Component extends React.Component<IPropsEditor3Component, IS
         }
     }
 
-    static getDerivedStateFromProps(props: IPropsEditor3Component, state: IState): Partial<IState> | null {
-        if (state.expanded != props.expandable?.defaultValue) {
-            return {
-                expanded: props.expandable?.defaultValue,
-            };
-        } else {
-            return null;
-        }
-    }
-
     componentDidUpdate(prevProps: IPropsEditor3Component) {
         if (window.hasOwnProperty('instgrm')) {
             window.instgrm.Embeds.process();
         }
 
+        if (prevProps.expandable?.defaultValue !== this.props.expandable?.defaultValue) {
path: scripts/core/editor3/components/Editor3Component.tsx
user: login: tomaskikutis
body: yep, let's drop it.

It should respect the props until first usage, but fixing it in constructur will be fine too
created_at: 2025-04-04T10:01:35Z
author_association: CONTRIBUTOR

PR comments:
user: login: tomaskikutis
created_at: 2025-04-01T08:44:35Z
body: What's the purpose of the PR?
author_association: CONTRIBUTOR
user: login: tomaskikutis
created_at: 2025-04-01T08:45:54Z
body: The title gives a hint, but maybe you could post a demo video if this is a new feature. New lines I thought we already have and expandable config I don't know how it is supposed to work.
author_association: CONTRIBUTOR
user: login: thecalcc
created_at: 2025-04-01T09:46:21Z
body: Demo: 

https://github.com/user-attachments/assets/83b27f88-85ac-476a-baab-c099e084249d


author_association: COLLABORATOR
user: login: thecalcc
created_at: 2025-04-01T09:47:38Z
body: > The title gives a hint, but maybe you could post a demo video if this is a new feature. New lines I thought we already have and expandable config I don't know how it is supposed to work.

I posed a demo. new lines we do have, I've only changed the shortcut from `Shift + Enter` to just `Enter` to make it consistent with the old editor 
author_association: COLLABORATOR
user: login: thecalcc
created_at: 2025-04-01T12:05:30Z
body: Updated demo with an auto hiding button when content isn't pass the collapse height threshold:

https://github.com/user-attachments/assets/d81c9181-513d-43df-87ea-ec5dc5540248


author_association: COLLABORATOR
user: login: thecalcc
created_at: 2025-04-03T10:48:40Z
body: This is a demo with dynamic row height 


https://github.com/user-attachments/assets/cc04d5cf-6e2e-45d1-b653-29289eaab22b



author_association: COLLABORATOR