diff --git a/res/css/structures/_LeftPanelWidget.scss b/res/css/structures/_LeftPanelWidget.scss index 4df651d7b6..6e2d99bb37 100644 --- a/res/css/structures/_LeftPanelWidget.scss +++ b/res/css/structures/_LeftPanelWidget.scss @@ -134,7 +134,7 @@ limitations under the License. mask-position: center; mask-size: contain; mask-repeat: no-repeat; - mask-image: url('$(res)/img/feather-customised/widget/maximise.svg'); + mask-image: url('$(res)/img/feather-customised/maximise.svg'); background: $muted-fg-color; } } diff --git a/res/css/views/messages/_ViewSourceEvent.scss b/res/css/views/messages/_ViewSourceEvent.scss index 076932ee97..66825030e0 100644 --- a/res/css/views/messages/_ViewSourceEvent.scss +++ b/res/css/views/messages/_ViewSourceEvent.scss @@ -35,13 +35,13 @@ limitations under the License. mask-size: auto 12px; visibility: hidden; background-color: $accent-color; - mask-image: url('$(res)/img/feather-customised/widget/maximise.svg'); + mask-image: url('$(res)/img/feather-customised/maximise.svg'); } &.mx_ViewSourceEvent_expanded .mx_ViewSourceEvent_toggle { mask-position: 0 bottom; margin-bottom: 7px; - mask-image: url('$(res)/img/feather-customised/widget/minimise.svg'); + mask-image: url('$(res)/img/feather-customised/minimise.svg'); } &:hover .mx_ViewSourceEvent_toggle { diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 42d91b3eb5..da94f914f7 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -491,7 +491,6 @@ $left-gutter: 64px; // https://github.com/vector-im/vector-web/issues/754 overflow-x: overlay; overflow-y: visible; - max-height: 30vh; } code { @@ -500,6 +499,22 @@ $left-gutter: 64px; } } +.mx_EventTile_lineNumbers { + float: left; + margin: 0 0.5em 0 -1.5em; + color: gray; +} + +.mx_EventTile_lineNumber { + text-align: right; + display: block; + padding-left: 1em; +} + +.mx_EventTile_collapsedCodeBlock { + max-height: 30vh; +} + .mx_EventTile:hover .mx_EventTile_body pre, .mx_EventTile.focus-visible:focus-within .mx_EventTile_body pre { border: 1px solid #e5e5e5; // deliberate constant as we're behind an invert filter @@ -511,7 +526,7 @@ $left-gutter: 64px; } // Inserted adjacent to
 blocks, (See TextualBody)
-.mx_EventTile_copyButton {
+.mx_EventTile_button {
     position: absolute;
     display: inline-block;
     visibility: hidden;
@@ -520,12 +535,33 @@ $left-gutter: 64px;
     right: 6px;
     width: 19px;
     height: 19px;
-    mask-image: url($copy-button-url);
     background-color: $message-action-bar-fg-color;
 }
+.mx_EventTile_buttonBottom {
+    top: 31px;
+}
+.mx_EventTile_copyButton {
+    mask-image: url($copy-button-url);
+}
+.mx_EventTile_collapseButton {
+    mask-size: 75%;
+    mask-position: center;
+    mask-repeat: no-repeat;
+    mask-image: url($collapse-button-url);
+}
+.mx_EventTile_expandButton {
+    mask-size: 75%;
+    mask-position: center;
+    mask-repeat: no-repeat;
+    mask-image: url($expand-button-url);
+}
 
 .mx_EventTile_body .mx_EventTile_pre_container:focus-within .mx_EventTile_copyButton,
-.mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_copyButton {
+.mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_copyButton,
+.mx_EventTile_body .mx_EventTile_pre_container:focus-within .mx_EventTile_collapseButton,
+.mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_collapseButton,
+.mx_EventTile_body .mx_EventTile_pre_container:focus-within .mx_EventTile_expandButton,
+.mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_expandButton {
     visibility: visible;
 }
 
diff --git a/res/img/feather-customised/widget/maximise.svg b/res/img/feather-customised/maximise.svg
similarity index 100%
rename from res/img/feather-customised/widget/maximise.svg
rename to res/img/feather-customised/maximise.svg
diff --git a/res/img/feather-customised/widget/minimise.svg b/res/img/feather-customised/minimise.svg
similarity index 100%
rename from res/img/feather-customised/widget/minimise.svg
rename to res/img/feather-customised/minimise.svg
diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss
index 085d6d7f10..a740ba155c 100644
--- a/res/themes/legacy-light/css/_legacy-light.scss
+++ b/res/themes/legacy-light/css/_legacy-light.scss
@@ -237,7 +237,8 @@ $event-redacted-border-color: #cccccc;
 $event-timestamp-color: #acacac;
 
 $copy-button-url: "$(res)/img/feather-customised/clipboard.svg";
-
+$collapse-button-url: "$(res)/img/feather-customised/minimise.svg";
+$expand-button-url: "$(res)/img/feather-customised/maximise.svg";
 
 // e2e
 $e2e-verified-color: #76cfa5; // N.B. *NOT* the same as $accent-color
diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss
index 4cfeeae05e..1c89d83c01 100644
--- a/res/themes/light/css/_light.scss
+++ b/res/themes/light/css/_light.scss
@@ -237,6 +237,8 @@ $event-redacted-border-color: #cccccc;
 $event-timestamp-color: #acacac;
 
 $copy-button-url: "$(res)/img/feather-customised/clipboard.svg";
+$collapse-button-url: "$(res)/img/feather-customised/minimise.svg";
+$expand-button-url: "$(res)/img/feather-customised/maximise.svg";
 
 // e2e
 $e2e-verified-color: #76cfa5; // N.B. *NOT* the same as $accent-color
diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js
index bf0884a2f3..71f7572a25 100644
--- a/src/components/views/messages/TextualBody.js
+++ b/src/components/views/messages/TextualBody.js
@@ -81,6 +81,7 @@ export default class TextualBody extends React.Component {
     }
 
     _applyFormatting() {
+        const showLineNumbers = SettingsStore.getValue("showCodeLineNumbers");
         this.activateSpoilers([this._content.current]);
 
         // pillifyLinks BEFORE linkifyElement because plain room/user URLs in the composer
@@ -91,29 +92,136 @@ export default class TextualBody extends React.Component {
         this.calculateUrlPreview();
 
         if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") {
-            const blocks = ReactDOM.findDOMNode(this).getElementsByTagName("code");
-            if (blocks.length > 0) {
-                // Do this asynchronously: parsing code takes time and we don't
-                // need to block the DOM update on it.
-                setTimeout(() => {
-                    if (this._unmounted) return;
-                    for (let i = 0; i < blocks.length; i++) {
-                        if (SettingsStore.getValue("enableSyntaxHighlightLanguageDetection")) {
-                            highlight.highlightBlock(blocks[i]);
-                        } else {
-                            // Only syntax highlight if there's a class starting with language-
-                            const classes = blocks[i].className.split(/\s+/).filter(function(cl) {
-                                return cl.startsWith('language-') && !cl.startsWith('language-_');
-                            });
-
-                            if (classes.length != 0) {
-                                highlight.highlightBlock(blocks[i]);
-                            }
-                        }
+            // Handle expansion and add buttons
+            const pres = ReactDOM.findDOMNode(this).getElementsByTagName("pre");
+            if (pres.length > 0) {
+                for (let i = 0; i < pres.length; i++) {
+                    // Wrap a div around 
 so that the copy button can be correctly positioned
+                    // when the 
 overflows and is scrolled horizontally.
+                    const div = this._wrapInDiv(pres[i]);
+                    this._handleCodeBlockExpansion(pres[i]);
+                    this._addCodeExpansionButton(div, pres[i]);
+                    this._addCodeCopyButton(div);
+                    if (showLineNumbers) {
+                        this._addLineNumbers(pres[i]);
                     }
-                }, 10);
+                }
+            }
+            // Highlight code
+            const codes = ReactDOM.findDOMNode(this).getElementsByTagName("code");
+            if (codes.length > 0) {
+                for (let i = 0; i < codes.length; i++) {
+                    // Do this asynchronously: parsing code takes time and we don't
+                    // need to block the DOM update on it.
+                    setTimeout(() => {
+                        if (this._unmounted) return;
+                        for (let i = 0; i < pres.length; i++) {
+                            this._highlightCode(codes[i]);
+                        }
+                    }, 10);
+                }
+            }
+        }
+    }
+
+    _addCodeExpansionButton(div, pre) {
+        // Calculate how many percent does the pre element take up.
+        // If it's less than 30% we don't add the expansion button.
+        const percentageOfViewport = pre.offsetHeight / window.innerHeight * 100;
+        if (percentageOfViewport < 30) return;
+
+        const button = document.createElement("span");
+        button.className = "mx_EventTile_button ";
+        if (pre.className == "mx_EventTile_collapsedCodeBlock") {
+            button.className += "mx_EventTile_expandButton";
+        } else {
+            button.className += "mx_EventTile_collapseButton";
+        }
+
+        button.onclick = async () => {
+            button.className = "mx_EventTile_button ";
+            if (pre.className == "mx_EventTile_collapsedCodeBlock") {
+                pre.className = "";
+                button.className += "mx_EventTile_collapseButton";
+            } else {
+                pre.className = "mx_EventTile_collapsedCodeBlock";
+                button.className += "mx_EventTile_expandButton";
+            }
+
+            // By expanding/collapsing we changed
+            // the height, therefore we call this
+            this.props.onHeightChanged();
+        };
+
+        div.appendChild(button);
+    }
+
+    _addCodeCopyButton(div) {
+        const button = document.createElement("span");
+        button.className = "mx_EventTile_button mx_EventTile_copyButton ";
+
+        // Check if expansion button exists. If so
+        // we put the copy button to the bottom
+        const expansionButtonExists = div.getElementsByClassName("mx_EventTile_button");
+        if (expansionButtonExists.length > 0) button.className += "mx_EventTile_buttonBottom";
+
+        button.onclick = async () => {
+            const copyCode = button.parentNode.getElementsByTagName("code")[0];
+            const successful = await copyPlaintext(copyCode.textContent);
+
+            const buttonRect = button.getBoundingClientRect();
+            const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu');
+            const {close} = ContextMenu.createMenu(GenericTextContextMenu, {
+                ...toRightOf(buttonRect, 2),
+                message: successful ? _t('Copied!') : _t('Failed to copy'),
+            });
+            button.onmouseleave = close;
+        };
+
+        div.appendChild(button);
+    }
+
+    _wrapInDiv(pre) {
+        const div = document.createElement("div");
+        div.className = "mx_EventTile_pre_container";
+
+        // Insert containing div in place of 
 block
+        pre.parentNode.replaceChild(div, pre);
+        // Append 
 block and copy button to container
+        div.appendChild(pre);
+
+        return div;
+    }
+
+    _handleCodeBlockExpansion(pre) {
+        if (!SettingsStore.getValue("expandCodeByDefault")) {
+            pre.className = "mx_EventTile_collapsedCodeBlock";
+        }
+    }
+
+    _addLineNumbers(pre) {
+        pre.innerHTML = '' + pre.innerHTML + '';
+        const lineNumbers = pre.getElementsByClassName("mx_EventTile_lineNumbers")[0];
+        // Calculate number of lines in pre
+        const number = pre.innerHTML.split(/\n/).length;
+        // Iterate through lines starting with 1 (number of the first line is 1)
+        for (let i = 1; i < number; i++) {
+            lineNumbers.innerHTML += '' + i + '';
+        }
+    }
+
+    _highlightCode(code) {
+        if (SettingsStore.getValue("enableSyntaxHighlightLanguageDetection")) {
+            highlight.highlightBlock(code);
+        } else {
+            // Only syntax highlight if there's a class starting with language-
+            const classes = code.className.split(/\s+/).filter(function(cl) {
+                return cl.startsWith('language-') && !cl.startsWith('language-_');
+            });
+
+            if (classes.length != 0) {
+                highlight.highlightBlock(code);
             }
-            this._addCodeCopyButton();
         }
     }
 
@@ -254,38 +362,6 @@ export default class TextualBody extends React.Component {
         }
     }
 
-    _addCodeCopyButton() {
-        // Add 'copy' buttons to pre blocks
-        Array.from(ReactDOM.findDOMNode(this).querySelectorAll('.mx_EventTile_body pre')).forEach((p) => {
-            const button = document.createElement("span");
-            button.className = "mx_EventTile_copyButton";
-            button.onclick = async () => {
-                const copyCode = button.parentNode.getElementsByTagName("pre")[0];
-                const successful = await copyPlaintext(copyCode.textContent);
-
-                const buttonRect = button.getBoundingClientRect();
-                const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu');
-                const {close} = ContextMenu.createMenu(GenericTextContextMenu, {
-                    ...toRightOf(buttonRect, 2),
-                    message: successful ? _t('Copied!') : _t('Failed to copy'),
-                });
-                button.onmouseleave = close;
-            };
-
-            // Wrap a div around 
 so that the copy button can be correctly positioned
-            // when the 
 overflows and is scrolled horizontally.
-            const div = document.createElement("div");
-            div.className = "mx_EventTile_pre_container";
-
-            // Insert containing div in place of 
 block
-            p.parentNode.replaceChild(div, p);
-
-            // Append 
 block and copy button to container
-            div.appendChild(p);
-            div.appendChild(button);
-        });
-    }
-
     onCancelClick = event => {
         this.setState({ widgetHidden: true });
         // FIXME: persist this somewhere smarter than local storage
diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js
index 91df7cb2eb..04fcea39dc 100644
--- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js
+++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js
@@ -47,6 +47,8 @@ export default class PreferencesUserSettingsTab extends React.Component {
         'alwaysShowTimestamps',
         'showRedactions',
         'enableSyntaxHighlightLanguageDetection',
+        'expandCodeByDefault',
+        'showCodeLineNumbers',
         'showJoinLeaves',
         'showAvatarChanges',
         'showDisplaynameChanges',
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 06aeec75fd..7986184d97 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -806,6 +806,8 @@
     "Always show message timestamps": "Always show message timestamps",
     "Autoplay GIFs and videos": "Autoplay GIFs and videos",
     "Enable automatic language detection for syntax highlighting": "Enable automatic language detection for syntax highlighting",
+    "Expand code blocks by default": "Expand code blocks by default",
+    "Show line numbers in code blocks": "Show line numbers in code blocks",
     "Show avatars in user and room mentions": "Show avatars in user and room mentions",
     "Enable big emoji in chat": "Enable big emoji in chat",
     "Send typing notifications": "Send typing notifications",
diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts
index 9e29df94b6..385b892478 100644
--- a/src/settings/Settings.ts
+++ b/src/settings/Settings.ts
@@ -305,6 +305,16 @@ export const SETTINGS: {[setting: string]: ISetting} = {
         displayName: _td('Enable automatic language detection for syntax highlighting'),
         default: false,
     },
+    "expandCodeByDefault": {
+        supportedLevels: LEVELS_ACCOUNT_SETTINGS,
+        displayName: _td('Expand code blocks by default'),
+        default: false,
+    },
+    "showCodeLineNumbers": {
+        supportedLevels: LEVELS_ACCOUNT_SETTINGS,
+        displayName: _td('Show line numbers in code blocks'),
+        default: true,
+    },
     "Pill.shouldShowPillAvatar": {
         supportedLevels: LEVELS_ACCOUNT_SETTINGS,
         displayName: _td('Show avatars in user and room mentions'),