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",