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 aroundso that the copy button can be correctly positioned + // when theoverflows 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 ofblock + pre.parentNode.replaceChild(div, pre); + // Appendblock 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 aroundso that the copy button can be correctly positioned - // when theoverflows and is scrolled horizontally. - const div = document.createElement("div"); - div.className = "mx_EventTile_pre_container"; - - // Insert containing div in place ofblock - p.parentNode.replaceChild(div, p); - - // Appendblock 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'),