diff --git a/res/css/views/rooms/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss index bce0ecf3251..e897352d7e4 100644 --- a/res/css/views/rooms/_BasicMessageComposer.scss +++ b/res/css/views/rooms/_BasicMessageComposer.scss @@ -16,6 +16,8 @@ limitations under the License. */ .mx_BasicMessageComposer { + position: relative; + .mx_BasicMessageComposer_inputEmpty > :first-child::before { content: var(--placeholder); opacity: 0.333; @@ -71,4 +73,69 @@ limitations under the License. position: relative; height: 0; } + + .mx_BasicMessageComposer_formatBar { + display: none; + width: calc(26px * 5); + height: 24px; + position: absolute; + cursor: pointer; + border-radius: 4px; + background-color: $message-action-bar-bg-color; + user-select: none; + + &.mx_BasicMessageComposer_formatBar_shown { + display: block; + } + + > * { + white-space: nowrap; + display: inline-block; + position: relative; + border: 1px solid $message-action-bar-border-color; + margin-left: -1px; + + &:hover { + border-color: $message-action-bar-hover-border-color; + } + } + + .mx_BasicMessageComposer_formatButton { + width: 27px; + height: 24px; + box-sizing: border-box; + } + + .mx_BasicMessageComposer_formatButton::after { + content: ''; + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + mask-repeat: no-repeat; + mask-position: center; + background-color: $message-action-bar-fg-color; + } + + .mx_BasicMessageComposer_formatBold::after { + mask-image: url('$(res)/img/format/bold.svg'); + } + + .mx_BasicMessageComposer_formatItalic::after { + mask-image: url('$(res)/img/format/italics.svg'); + } + + .mx_BasicMessageComposer_formatStrikethrough::after { + mask-image: url('$(res)/img/format/strikethrough.svg'); + } + + .mx_BasicMessageComposer_formatQuote::after { + mask-image: url('$(res)/img/format/quote.svg'); + } + + .mx_BasicMessageComposer_formatCode::after { + mask-image: url('$(res)/img/format/code.svg'); + } + } } diff --git a/res/img/format/bold.svg b/res/img/format/bold.svg new file mode 100644 index 00000000000..634d735031e --- /dev/null +++ b/res/img/format/bold.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/format/code.svg b/res/img/format/code.svg new file mode 100644 index 00000000000..0a29bcd7bdc --- /dev/null +++ b/res/img/format/code.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/res/img/format/italics.svg b/res/img/format/italics.svg new file mode 100644 index 00000000000..841afadffd8 --- /dev/null +++ b/res/img/format/italics.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/format/quote.svg b/res/img/format/quote.svg new file mode 100644 index 00000000000..82d34033148 --- /dev/null +++ b/res/img/format/quote.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/res/img/format/strikethrough.svg b/res/img/format/strikethrough.svg new file mode 100644 index 00000000000..fc02b0aae21 --- /dev/null +++ b/res/img/format/strikethrough.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index c5661e561c8..e0468e9969a 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -20,8 +20,13 @@ import React from 'react'; import PropTypes from 'prop-types'; import EditorModel from '../../../editor/model'; import HistoryManager from '../../../editor/history'; -import {setCaretPosition} from '../../../editor/caret'; -import {getCaretOffsetAndText} from '../../../editor/dom'; +import {setSelection} from '../../../editor/caret'; +import { + formatRangeAsQuote, + formatRangeAsCode, + formatInline, +} from '../../../editor/operations'; +import {getCaretOffsetAndText, getRangeForSelection} from '../../../editor/dom'; import Autocomplete from '../rooms/Autocomplete'; import {autoCompleteCreator} from '../../../editor/parts'; import {renderModel} from '../../../editor/render'; @@ -30,6 +35,7 @@ import TypingStore from "../../../stores/TypingStore"; import EMOJIBASE from 'emojibase-data/en/compact.json'; import SettingsStore from "../../../settings/SettingsStore"; import EMOTICON_REGEX from 'emojibase-regex/emoticon'; +import { _t } from '../../../languageHandler'; const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$'); @@ -74,8 +80,10 @@ export default class BasicMessageEditor extends React.Component { }; this._editorRef = null; this._autocompleteRef = null; + this._formatBarRef = null; this._modifiedFlag = false; this._isIMEComposing = false; + this._hasTextSelected = false; } _replaceEmoticon = (caretPosition, inputType, diff) => { @@ -108,11 +116,11 @@ export default class BasicMessageEditor extends React.Component { } } - _updateEditorState = (caret, inputType, diff) => { + _updateEditorState = (selection, inputType, diff) => { renderModel(this._editorRef, this.props.model); - if (caret) { + if (selection) { // set the caret/selection try { - setCaretPosition(this._editorRef, this.props.model, caret); + setSelection(this._editorRef, this.props.model, selection); } catch (err) { console.error(err); } @@ -126,7 +134,7 @@ export default class BasicMessageEditor extends React.Component { } } this.setState({autoComplete: this.props.model.autoComplete}); - this.historyManager.tryPush(this.props.model, caret, inputType, diff); + this.historyManager.tryPush(this.props.model, selection, inputType, diff); TypingStore.sharedInstance().setSelfTyping(this.props.room.roomId, !this.props.model.isEmpty); if (this.props.onChange) { @@ -239,6 +247,37 @@ export default class BasicMessageEditor extends React.Component { _onSelectionChange = () => { this._refreshLastCaretIfNeeded(); + const selection = document.getSelection(); + if (this._hasTextSelected && selection.isCollapsed) { + this._hasTextSelected = false; + if (this._formatBarRef) { + this._formatBarRef.classList.remove("mx_BasicMessageComposer_formatBar_shown"); + } + } else if (!selection.isCollapsed) { + this._hasTextSelected = true; + if (this._formatBarRef) { + this._formatBarRef.classList.add("mx_BasicMessageComposer_formatBar_shown"); + const selectionRect = selection.getRangeAt(0).getBoundingClientRect(); + + let leftOffset = 0; + let node = this._formatBarRef; + while (node.offsetParent) { + node = node.offsetParent; + leftOffset += node.offsetLeft; + } + + let topOffset = 0; + node = this._formatBarRef; + while (node.offsetParent) { + node = node.offsetParent; + topOffset += node.offsetTop; + } + + this._formatBarRef.style.left = `${selectionRect.left - leftOffset}px`; + // 12 is half the height of the bar (e.g. to center it) and 16 is an offset that felt ok. + this._formatBarRef.style.top = `${selectionRect.top - topOffset - 16 - 12}px`; + } + } } _onKeyDown = (event) => { @@ -392,6 +431,42 @@ export default class BasicMessageEditor extends React.Component { return caretPosition; } + _wrapSelectionAsInline(prefix, suffix = prefix) { + const range = getRangeForSelection( + this._editorRef, + this.props.model, + document.getSelection()); + formatInline(range, prefix, suffix); + } + + _formatBold = () => { + this._wrapSelectionAsInline("**"); + } + + _formatItalic = () => { + this._wrapSelectionAsInline("*"); + } + + _formatStrikethrough = () => { + this._wrapSelectionAsInline("", ""); + } + + _formatQuote = () => { + const range = getRangeForSelection( + this._editorRef, + this.props.model, + document.getSelection()); + formatRangeAsQuote(range); + } + + _formatCode = () => { + const range = getRangeForSelection( + this._editorRef, + this.props.model, + document.getSelection()); + formatRangeAsCode(range); + } + render() { let autoComplete; if (this.state.autoComplete) { @@ -413,6 +488,13 @@ export default class BasicMessageEditor extends React.Component { }); return (
{ autoComplete } +
this._formatBarRef = ref}> + + + + + +
= childNodeCount) { + selectionNode = selectionNode.lastChild; + if (selectionNode.nodeType === Node.TEXT_NODE) { + selectionOffset = selectionNode.textContent.length; + } else { + // this will select the last child node in the next iteration + selectionOffset = Number.MAX_SAFE_INTEGER; + } + } else { + selectionNode = selectionNode.childNodes[selectionOffset]; + // this will select the first child node in the next iteration + selectionOffset = 0; + } + } else { + // here node won't be a text node, + // but characterOffset should be 0, + // this happens under some circumstances + // when the editor is empty. + // In this case characterOffset=0 is the right thing to do + break; + } } - const {text, focusNodeOffset} = getTextAndFocusNodeOffset(editor, focusNode, focusOffset); - const caret = getCaret(focusNode, focusNodeOffset, focusOffset); - return {caret, text}; + return { + node: selectionNode, + characterOffset: selectionOffset, + }; +} + +function getSelectionOffsetAndText(editor, selectionNode, selectionOffset) { + const {node, characterOffset} = tryReduceSelectionToTextNode(selectionNode, selectionOffset); + const {text, offsetToNode} = getTextAndOffsetToNode(editor, node); + const offset = getCaret(node, offsetToNode, characterOffset); + return {offset, text}; } // gets the caret position details, ignoring and adjusting to // the ZWS if you're typing in a caret node -function getCaret(focusNode, focusNodeOffset, focusOffset) { - let atNodeEnd = focusOffset === focusNode.textContent.length; - if (focusNode.nodeType === Node.TEXT_NODE && isCaretNode(focusNode.parentElement)) { - const zwsIdx = focusNode.nodeValue.indexOf(CARET_NODE_CHAR); - if (zwsIdx !== -1 && zwsIdx < focusOffset) { - focusOffset -= 1; +function getCaret(node, offsetToNode, offsetWithinNode) { + let atNodeEnd = offsetWithinNode === node.textContent.length; + if (node.nodeType === Node.TEXT_NODE && isCaretNode(node.parentElement)) { + const zwsIdx = node.nodeValue.indexOf(CARET_NODE_CHAR); + if (zwsIdx !== -1 && zwsIdx < offsetWithinNode) { + offsetWithinNode -= 1; } // if typing in a caret node, you're either typing before or after the ZWS. // In both cases, you should be considered at node end because the ZWS is @@ -67,21 +104,21 @@ function getCaret(focusNode, focusNodeOffset, focusOffset) { // that caret node will be removed. atNodeEnd = true; } - return {offset: focusNodeOffset + focusOffset, atNodeEnd}; + return new DocumentOffset(offsetToNode + offsetWithinNode, atNodeEnd); } // gets the text of the editor as a string, -// and the offset in characters where the focusNode starts in that string +// and the offset in characters where the selectionNode starts in that string // all ZWS from caret nodes are filtered out -function getTextAndFocusNodeOffset(editor, focusNode, focusOffset) { - let focusNodeOffset = 0; - let foundCaret = false; +function getTextAndOffsetToNode(editor, selectionNode) { + let offsetToNode = 0; + let foundNode = false; let text = ""; function enterNodeCallback(node) { - if (!foundCaret) { - if (node === focusNode) { - foundCaret = true; + if (!foundNode) { + if (node === selectionNode) { + foundNode = true; } } // usually newlines are entered as new DIV elements, @@ -89,13 +126,15 @@ function getTextAndFocusNodeOffset(editor, focusNode, focusOffset) { // converted to BRs, so also take these into account when they // are not the last element in the DIV. if (node.tagName === "BR" && node.nextSibling) { + if (!foundNode) { + offsetToNode += 1; + } text += "\n"; - focusNodeOffset += 1; } const nodeText = node.nodeType === Node.TEXT_NODE && getTextNodeValue(node); if (nodeText) { - if (!foundCaret) { - focusNodeOffset += nodeText.length; + if (!foundNode) { + offsetToNode += nodeText.length; } text += nodeText; } @@ -109,15 +148,15 @@ function getTextAndFocusNodeOffset(editor, focusNode, focusOffset) { // whereas you just want it to be appended to the current line if (node.tagName === "DIV" && node.nextSibling && node.nextSibling.tagName === "DIV") { text += "\n"; - if (!foundCaret) { - focusNodeOffset += 1; + if (!foundNode) { + offsetToNode += 1; } } } walkDOMDepthFirst(editor, enterNodeCallback, leaveNodeCallback); - return {text, focusNodeOffset}; + return {text, offsetToNode}; } // get text value of text node, ignoring ZWS if it's a caret node @@ -137,3 +176,19 @@ function getTextNodeValue(node) { return nodeText; } } + +export function getRangeForSelection(editor, model, selection) { + const focusOffset = getSelectionOffsetAndText( + editor, + selection.focusNode, + selection.focusOffset, + ).offset; + const anchorOffset = getSelectionOffsetAndText( + editor, + selection.anchorNode, + selection.anchorOffset, + ).offset; + const focusPosition = focusOffset.asPosition(model); + const anchorPosition = anchorOffset.asPosition(model); + return model.startRange(focusPosition, anchorPosition); +} diff --git a/src/editor/model.js b/src/editor/model.js index 613be5b4bd7..ea6b05570c1 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -388,21 +388,25 @@ export default class EditorModel { currentOffset += partLen; return false; }); - - return new DocumentPosition(index, totalOffset - currentOffset); + if (index === -1) { + return this.getPositionAtEnd(); + } else { + return new DocumentPosition(index, totalOffset - currentOffset); + } } /** * Starts a range, which can span across multiple parts, to find and replace text. - * @param {DocumentPosition} position where to start the range + * @param {DocumentPosition} positionA a boundary of the range + * @param {DocumentPosition?} positionB the other boundary of the range, optional * @return {Range} */ - startRange(position) { - return new Range(this, position); + startRange(positionA, positionB = positionA) { + return new Range(this, positionA, positionB); } - //mostly internal, called from Range.replace - replaceRange(startPosition, endPosition, parts) { + // called from Range.replace + _replaceRange(startPosition, endPosition, parts) { // convert end position to offset, so it is independent of how the document is split into parts // which we'll change when splitting up at the start position const endOffset = endPosition.asOffset(this); @@ -429,7 +433,12 @@ export default class EditorModel { */ transform(callback) { const pos = callback(); - const acPromise = this._setActivePart(pos, true); + let acPromise = null; + if (!(pos instanceof Range)) { + acPromise = this._setActivePart(pos, true); + } else { + acPromise = Promise.resolve(); + } this._updateCallback(pos); return acPromise; } diff --git a/src/editor/offset.js b/src/editor/offset.js index 7054836bdc9..785f16bc6df 100644 --- a/src/editor/offset.js +++ b/src/editor/offset.js @@ -15,12 +15,16 @@ limitations under the License. */ export default class DocumentOffset { - constructor(offset, atEnd) { + constructor(offset, atNodeEnd) { this.offset = offset; - this.atEnd = atEnd; + this.atNodeEnd = atNodeEnd; } asPosition(model) { - return model.positionForOffset(this.offset, this.atEnd); + return model.positionForOffset(this.offset, this.atNodeEnd); + } + + add(delta, atNodeEnd = false) { + return new DocumentOffset(this.offset + delta, atNodeEnd); } } diff --git a/src/editor/operations.js b/src/editor/operations.js new file mode 100644 index 00000000000..e6ed2ba0e50 --- /dev/null +++ b/src/editor/operations.js @@ -0,0 +1,97 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Some common queries and transformations on the editor model + */ + +export function replaceRangeAndExpandSelection(model, range, newParts) { + model.transform(() => { + const oldLen = range.length; + const addedLen = range.replace(newParts); + const firstOffset = range.start.asOffset(model); + const lastOffset = firstOffset.add(oldLen + addedLen); + return model.startRange(firstOffset.asPosition(model), lastOffset.asPosition(model)); + }); +} + +export function rangeStartsAtBeginningOfLine(range) { + const {model} = range; + const startsWithPartial = range.start.offset !== 0; + const isFirstPart = range.start.index === 0; + const previousIsNewline = !isFirstPart && model.parts[range.start.index - 1].type === "newline"; + return !startsWithPartial && (isFirstPart || previousIsNewline); +} + +export function rangeEndsAtEndOfLine(range) { + const {model} = range; + const lastPart = model.parts[range.end.index]; + const endsWithPartial = range.end.offset !== lastPart.length; + const isLastPart = range.end.index === model.parts.length - 1; + const nextIsNewline = !isLastPart && model.parts[range.end.index + 1].type === "newline"; + return !endsWithPartial && (isLastPart || nextIsNewline); +} + +export function formatRangeAsQuote(range) { + const {model, parts} = range; + const {partCreator} = model; + for (let i = 0; i < parts.length; ++i) { + const part = parts[i]; + if (part.type === "newline") { + parts.splice(i + 1, 0, partCreator.plain("> ")); + } + } + parts.unshift(partCreator.plain("> ")); + if (!rangeStartsAtBeginningOfLine(range)) { + parts.unshift(partCreator.newline()); + } + if (!rangeEndsAtEndOfLine(range)) { + parts.push(partCreator.newline()); + } + + parts.push(partCreator.newline()); + replaceRangeAndExpandSelection(model, range, parts); +} + +export function formatRangeAsCode(range) { + const {model, parts} = range; + const {partCreator} = model; + const needsBlock = parts.some(p => p.type === "newline"); + if (needsBlock) { + parts.unshift(partCreator.plain("```"), partCreator.newline()); + if (!rangeStartsAtBeginningOfLine(range)) { + parts.unshift(partCreator.newline()); + } + parts.push( + partCreator.newline(), + partCreator.plain("```")); + if (!rangeEndsAtEndOfLine(range)) { + parts.push(partCreator.newline()); + } + } else { + parts.unshift(partCreator.plain("`")); + parts.push(partCreator.plain("`")); + } + replaceRangeAndExpandSelection(model, range, parts); +} + +export function formatInline(range, prefix, suffix = prefix) { + const {model, parts} = range; + const {partCreator} = model; + parts.unshift(partCreator.plain(prefix)); + parts.push(partCreator.plain(suffix)); + replaceRangeAndExpandSelection(model, range, parts); +} diff --git a/src/editor/range.js b/src/editor/range.js index 1aaf4807339..822c3b13a71 100644 --- a/src/editor/range.js +++ b/src/editor/range.js @@ -15,10 +15,11 @@ limitations under the License. */ export default class Range { - constructor(model, startPosition, endPosition = startPosition) { + constructor(model, positionA, positionB = positionA) { this._model = model; - this._start = startPosition; - this._end = endPosition; + const bIsLarger = positionA.compare(positionB) < 0; + this._start = bIsLarger ? positionA : positionB; + this._end = bIsLarger ? positionB : positionA; } moveStart(delta) { @@ -32,6 +33,10 @@ export default class Range { this._start = this._start.backwardsWhile(this._model, predicate); } + get model() { + return this._model; + } + get text() { let text = ""; this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => { @@ -53,7 +58,38 @@ export default class Range { this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => { oldLength += endIdx - startIdx; }); - this._model.replaceRange(this._start, this._end, parts); + this._model._replaceRange(this._start, this._end, parts); return newLength - oldLength; } + + /** + * Returns a copy of the (partial) parts within the range. + * For partial parts, only the text is adjusted to the part that intersects with the range. + */ + get parts() { + const parts = []; + this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => { + const serializedPart = part.serialize(); + serializedPart.text = part.text.substring(startIdx, endIdx); + const newPart = this._model.partCreator.deserializePart(serializedPart); + parts.push(newPart); + }); + return parts; + } + + get length() { + let len = 0; + this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => { + len += endIdx - startIdx; + }); + return len; + } + + get start() { + return this._start; + } + + get end() { + return this._end; + } } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 2498b018e3d..990363d105a 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -757,6 +757,11 @@ " (unsupported)": " (unsupported)", "Join as voice or video.": "Join as voice or video.", "Ongoing conference call%(supportedText)s.": "Ongoing conference call%(supportedText)s.", + "Bold": "Bold", + "Italics": "Italics", + "Strikethrough": "Strikethrough", + "Code block": "Code block", + "Quote": "Quote", "Some devices for this user are not trusted": "Some devices for this user are not trusted", "Some devices in this encrypted room are not trusted": "Some devices in this encrypted room are not trusted", "All devices for this user are trusted": "All devices for this user are trusted", @@ -1397,7 +1402,6 @@ "Unhide Preview": "Unhide Preview", "Share Permalink": "Share Permalink", "Share Message": "Share Message", - "Quote": "Quote", "Source URL": "Source URL", "Collapse Reply Thread": "Collapse Reply Thread", "End-to-end encryption information": "End-to-end encryption information",