From 648ae37ff48de15eb4073befcc155a3b201bf169 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 3 Sep 2019 15:58:05 +0200 Subject: [PATCH 01/27] make DocumentOffset compatible with what is returned from dom/getCaret so we can return a DocumentOffset from there without breakage --- src/editor/offset.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/editor/offset.js b/src/editor/offset.js index 7054836bdc..c638640f6f 100644 --- a/src/editor/offset.js +++ b/src/editor/offset.js @@ -15,12 +15,12 @@ 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); } } From eb87301855347e76a1244135895c25778e30cce2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 3 Sep 2019 15:58:50 +0200 Subject: [PATCH 02/27] allow getting the DocumentOffset for any node+offset, not just focusNode we need this to get both offsets of the selection boundaries getSelectionOffsetAndText offers the extra flexibility, getCaretOffsetAndText keeps the old api for focusNode/focusOffset Also did some renaming here now that it's not just for the caret anymore --- src/editor/dom.js | 51 ++++++++++++++++++++++++++--------------------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/src/editor/dom.js b/src/editor/dom.js index 4f15a57303..45e30d421a 100644 --- a/src/editor/dom.js +++ b/src/editor/dom.js @@ -16,6 +16,7 @@ limitations under the License. */ import {CARET_NODE_CHAR, isCaretNode} from "./render"; +import DocumentOffset from "./offset"; export function walkDOMDepthFirst(rootNode, enterNodeCallback, leaveNodeCallback) { let node = rootNode.firstChild; @@ -40,26 +41,30 @@ export function walkDOMDepthFirst(rootNode, enterNodeCallback, leaveNodeCallback } export function getCaretOffsetAndText(editor, sel) { - let {focusNode, focusOffset} = sel; - // sometimes focusNode is an element, and then focusOffset means + const {offset, text} = getSelectionOffsetAndText(editor, sel.focusNode, sel.focusOffset); + return {caret: offset, text}; +} + +export function getSelectionOffsetAndText(editor, selectionNode, selectionOffset) { + // sometimes selectionNode is an element, and then selectionOffset means // the index of a child element ... - 1 🤷 - if (focusNode.nodeType === Node.ELEMENT_NODE && focusOffset !== 0) { - focusNode = focusNode.childNodes[focusOffset - 1]; - focusOffset = focusNode.textContent.length; + if (selectionNode.nodeType === Node.ELEMENT_NODE && selectionOffset !== 0) { + selectionNode = selectionNode.childNodes[selectionOffset - 1]; + selectionOffset = selectionNode.textContent.length; } - const {text, focusNodeOffset} = getTextAndFocusNodeOffset(editor, focusNode, focusOffset); - const caret = getCaret(focusNode, focusNodeOffset, focusOffset); - return {caret, text}; + const {text, offsetToNode} = getTextAndOffsetToNode(editor, selectionNode); + const offset = getCaret(selectionNode, offsetToNode, selectionOffset); + 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,20 +72,20 @@ 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; +function getTextAndOffsetToNode(editor, selectionNode) { + let offsetToNode = 0; let foundCaret = false; let text = ""; function enterNodeCallback(node) { if (!foundCaret) { - if (node === focusNode) { + if (node === selectionNode) { foundCaret = true; } } @@ -90,12 +95,12 @@ function getTextAndFocusNodeOffset(editor, focusNode, focusOffset) { // are not the last element in the DIV. if (node.tagName === "BR" && node.nextSibling) { text += "\n"; - focusNodeOffset += 1; + offsetToNode += 1; } const nodeText = node.nodeType === Node.TEXT_NODE && getTextNodeValue(node); if (nodeText) { if (!foundCaret) { - focusNodeOffset += nodeText.length; + offsetToNode += nodeText.length; } text += nodeText; } @@ -110,14 +115,14 @@ function getTextAndFocusNodeOffset(editor, focusNode, focusOffset) { if (node.tagName === "DIV" && node.nextSibling && node.nextSibling.tagName === "DIV") { text += "\n"; if (!foundCaret) { - focusNodeOffset += 1; + 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 From 0d02ab59d68c1700ca78326683daf98c2a062d4e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 3 Sep 2019 16:00:50 +0200 Subject: [PATCH 03/27] allow starting a range with both positions known already we'll need this to start a range for the selection --- src/editor/model.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/editor/model.js b/src/editor/model.js index 613be5b4bd..75ab1d7706 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -394,11 +394,12 @@ export default class EditorModel { /** * 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 From 65ddfc0a50a58af63dd139a7719e0ed505af4336 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 3 Sep 2019 16:02:37 +0200 Subject: [PATCH 04/27] show format bar when text is selected --- .../views/rooms/_BasicMessageComposer.scss | 68 +++++++++++++++++++ res/img/format/bold.svg | 3 + res/img/format/code.svg | 7 ++ res/img/format/italics.svg | 3 + res/img/format/quote.svg | 5 ++ res/img/format/strikethrough.svg | 6 ++ .../views/rooms/BasicMessageComposer.js | 58 ++++++++++++++++ 7 files changed, 150 insertions(+) create mode 100644 res/img/format/bold.svg create mode 100644 res/img/format/code.svg create mode 100644 res/img/format/italics.svg create mode 100644 res/img/format/quote.svg create mode 100644 res/img/format/strikethrough.svg diff --git a/res/css/views/rooms/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss index bce0ecf325..23d61f5218 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,70 @@ limitations under the License. position: relative; height: 0; } + + .mx_BasicMessageComposer_formatBar { + display: none; + background-color: red; + width: calc(26px * 4); + height: 24px; + position: absolute; + cursor: pointer; + border-radius: 4px; + background: $message-action-bar-bg-color; + + &.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 0000000000..634d735031 --- /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 0000000000..0a29bcd7bd --- /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 0000000000..841afadffd --- /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 0000000000..82d3403314 --- /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 0000000000..fc02b0aae2 --- /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 c5661e561c..291c179e46 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -74,8 +74,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) => { @@ -239,6 +241,36 @@ 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`; + this._formatBarRef.style.top = `${selectionRect.top - topOffset - 16 - 12}px`; + } + } } _onKeyDown = (event) => { @@ -392,6 +424,25 @@ export default class BasicMessageEditor extends React.Component { return caretPosition; } + _formatBold = () => { + } + + _formatItalic = () => { + + } + + _formatStrikethrough = () => { + + } + + _formatQuote = () => { + + } + + _formatCodeBlock = () => { + + } + render() { let autoComplete; if (this.state.autoComplete) { @@ -413,6 +464,13 @@ export default class BasicMessageEditor extends React.Component { }); return (
{ autoComplete } +
this._formatBarRef = ref}> + + + + + +
Date: Tue, 3 Sep 2019 16:03:03 +0200 Subject: [PATCH 05/27] sort positions in Range constructor, so start always comes before end --- src/editor/range.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/editor/range.js b/src/editor/range.js index 1aaf480733..1eebfeeb91 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) { From 77b14beb1f770ce973fd45b30d68c490b5bcd216 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 3 Sep 2019 16:03:29 +0200 Subject: [PATCH 06/27] add getter for intersecting parts of range, and total length we'll need this when replacing selection, to preserve newlines, etc ... in the selected range (e.g. we can't just use range.text). --- src/editor/range.js | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/editor/range.js b/src/editor/range.js index 1eebfeeb91..d04bbbbfb1 100644 --- a/src/editor/range.js +++ b/src/editor/range.js @@ -57,4 +57,27 @@ export default class Range { 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; + } } From 7dc39baaf3f9bb50bb2919172abbae1ade158968 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 3 Sep 2019 16:04:47 +0200 Subject: [PATCH 07/27] implement bold support in format bar --- .../views/rooms/BasicMessageComposer.js | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 291c179e46..9ba3e603b9 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -21,7 +21,7 @@ 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 {getCaretOffsetAndText, getSelectionOffsetAndText} from '../../../editor/dom'; import Autocomplete from '../rooms/Autocomplete'; import {autoCompleteCreator} from '../../../editor/parts'; import {renderModel} from '../../../editor/render'; @@ -424,7 +424,45 @@ export default class BasicMessageEditor extends React.Component { return caretPosition; } + _replaceSelection(callback) { + const selection = document.getSelection(); + if (selection.isCollapsed) { + return; + } + const focusOffset = getSelectionOffsetAndText( + this._editorRef, + selection.focusNode, + selection.focusOffset, + ).offset; + const anchorOffset = getSelectionOffsetAndText( + this._editorRef, + selection.anchorNode, + selection.anchorOffset, + ).offset; + const {model} = this.props; + const focusPosition = focusOffset.asPosition(model); + const anchorPosition = anchorOffset.asPosition(model); + const range = model.startRange(focusPosition, anchorPosition); + const firstPosition = focusPosition.compare(anchorPosition) < 0 ? focusPosition : anchorPosition; + + model.transform(() => { + const oldLen = range.length; + const newParts = callback(range); + const addedLen = range.replace(newParts); + const lastOffset = firstPosition.asOffset(model); + lastOffset.offset += oldLen + addedLen; + return lastOffset.asPosition(model); + }); + } + _formatBold = () => { + const {partCreator} = this.props.model; + this._replaceSelection(range => { + const parts = range.parts; + parts.splice(0, 0, partCreator.plain("**")); + parts.push(partCreator.plain("**")); + return parts; + }); } _formatItalic = () => { From c15dfc3c05531abe18d7ff8aecfdd048c28f63bc Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 4 Sep 2019 16:38:42 +0200 Subject: [PATCH 08/27] make Range start and end public --- src/editor/range.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/editor/range.js b/src/editor/range.js index d04bbbbfb1..2163076515 100644 --- a/src/editor/range.js +++ b/src/editor/range.js @@ -80,4 +80,12 @@ export default class Range { }); return len; } + + get start() { + return this._start; + } + + get end() { + return this._end; + } } From e7db660820b57773464c68f7ada16d8a47b40a1d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 4 Sep 2019 16:39:07 +0200 Subject: [PATCH 09/27] fixup: css, we have 5 buttons --- res/css/views/rooms/_BasicMessageComposer.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/res/css/views/rooms/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss index 23d61f5218..72a10bf074 100644 --- a/res/css/views/rooms/_BasicMessageComposer.scss +++ b/res/css/views/rooms/_BasicMessageComposer.scss @@ -77,7 +77,7 @@ limitations under the License. .mx_BasicMessageComposer_formatBar { display: none; background-color: red; - width: calc(26px * 4); + width: calc(26px * 5); height: 24px; position: absolute; cursor: pointer; From d4c7992f5a499efff2374c8e26d4fb604a19eee3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 4 Sep 2019 16:39:56 +0200 Subject: [PATCH 10/27] first impl of inline formatting --- src/components/views/rooms/BasicMessageComposer.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 9ba3e603b9..b578aa9e0c 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -455,22 +455,26 @@ export default class BasicMessageEditor extends React.Component { }); } - _formatBold = () => { + _wrapSelection(prefix, suffix = prefix) { const {partCreator} = this.props.model; this._replaceSelection(range => { const parts = range.parts; - parts.splice(0, 0, partCreator.plain("**")); - parts.push(partCreator.plain("**")); + parts.splice(0, 0, partCreator.plain(prefix)); + parts.push(partCreator.plain(suffix)); return parts; }); } - _formatItalic = () => { + _formatBold = () => { + this._wrapSelection("**"); + } + _formatItalic = () => { + this._wrapSelection("*"); } _formatStrikethrough = () => { - + this._wrapSelection("", ""); } _formatQuote = () => { From 7f501b2aefdf709adcc9cd783484496a97c282b2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 4 Sep 2019 16:40:13 +0200 Subject: [PATCH 11/27] first impl of quote formatting --- .../views/rooms/BasicMessageComposer.js | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index b578aa9e0c..81f4dcc8fe 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -478,7 +478,30 @@ export default class BasicMessageEditor extends React.Component { } _formatQuote = () => { - + const {model} = this.props; + const {partCreator} = this.props.model; + this._replaceSelection(range => { + const parts = range.parts; + parts.splice(0, 0, partCreator.plain("> ")); + const startsWithPartial = range.start.offset !== 0; + const isFirstPart = range.start.index === 0; + const previousIsNewline = !isFirstPart && model.parts[range.start.index - 1].type === "newline"; + // prepend a newline if there is more text before the range on this line + if (startsWithPartial || (!isFirstPart && !previousIsNewline)) { + parts.splice(0, 0, partCreator.newline()); + } + // start at position 1 to make sure we skip the potentially inserted newline above, + // as we already inserted a quote sign for it above + for (let i = 1; i < parts.length; ++i) { + const part = parts[i]; + if (part.type === "newline") { + parts.splice(i + 1, 0, partCreator.plain("> ")); + } + } + parts.push(partCreator.newline()); + parts.push(partCreator.newline()); + return parts; + }); } _formatCodeBlock = () => { From 47d8d86bbe224ecfc4a5bc1aaf87ff4ea6e108c9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 4 Sep 2019 16:40:23 +0200 Subject: [PATCH 12/27] whitespace (in model) --- src/editor/model.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/editor/model.js b/src/editor/model.js index 75ab1d7706..34a796f733 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -388,7 +388,6 @@ export default class EditorModel { currentOffset += partLen; return false; }); - return new DocumentPosition(index, totalOffset - currentOffset); } From b72d1a78eca2afedfd223acd88d61296742fcb97 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 4 Sep 2019 12:37:27 +0200 Subject: [PATCH 13/27] move inline formatting code out of react component --- .../views/rooms/BasicMessageComposer.js | 25 ++++++------ src/editor/dom.js | 16 ++++++++ src/editor/operations.js | 38 +++++++++++++++++++ 3 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 src/editor/operations.js diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 81f4dcc8fe..39d24b1bab 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -21,7 +21,10 @@ import PropTypes from 'prop-types'; import EditorModel from '../../../editor/model'; import HistoryManager from '../../../editor/history'; import {setCaretPosition} from '../../../editor/caret'; -import {getCaretOffsetAndText, getSelectionOffsetAndText} from '../../../editor/dom'; +import { + formatInline, +} from '../../../editor/operations'; +import {getCaretOffsetAndText, getRangeForSelection, getSelectionOffsetAndText} from '../../../editor/dom'; import Autocomplete from '../rooms/Autocomplete'; import {autoCompleteCreator} from '../../../editor/parts'; import {renderModel} from '../../../editor/render'; @@ -455,26 +458,24 @@ export default class BasicMessageEditor extends React.Component { }); } - _wrapSelection(prefix, suffix = prefix) { - const {partCreator} = this.props.model; - this._replaceSelection(range => { - const parts = range.parts; - parts.splice(0, 0, partCreator.plain(prefix)); - parts.push(partCreator.plain(suffix)); - return parts; - }); + _wrapSelectionAsInline(prefix, suffix = prefix) { + const range = getRangeForSelection( + this._editorRef, + this.props.model, + document.getSelection()); + formatInline(range, prefix, suffix); } _formatBold = () => { - this._wrapSelection("**"); + this._wrapSelectionAsInline("**"); } _formatItalic = () => { - this._wrapSelection("*"); + this._wrapSelectionAsInline("*"); } _formatStrikethrough = () => { - this._wrapSelection("", ""); + this._wrapSelectionAsInline("", ""); } _formatQuote = () => { diff --git a/src/editor/dom.js b/src/editor/dom.js index 45e30d421a..03ee7f2cfc 100644 --- a/src/editor/dom.js +++ b/src/editor/dom.js @@ -142,3 +142,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/operations.js b/src/editor/operations.js new file mode 100644 index 0000000000..4f0757948a --- /dev/null +++ b/src/editor/operations.js @@ -0,0 +1,38 @@ +/* +Copyright 2019 New Vector Ltd +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 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); +} From b35a3531bb57d89f8098314491f4f0a953204121 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 4 Sep 2019 12:40:03 +0200 Subject: [PATCH 14/27] move quote formatting out of react component --- .../views/rooms/BasicMessageComposer.js | 64 +++---------------- src/editor/dom.js | 2 +- src/editor/offset.js | 4 ++ src/editor/operations.js | 38 +++++++++++ src/editor/range.js | 4 ++ 5 files changed, 55 insertions(+), 57 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 39d24b1bab..38cfff9b65 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -22,9 +22,11 @@ import EditorModel from '../../../editor/model'; import HistoryManager from '../../../editor/history'; import {setCaretPosition} from '../../../editor/caret'; import { + replaceRangeAndExpandSelection, + formatRangeAsQuote, formatInline, } from '../../../editor/operations'; -import {getCaretOffsetAndText, getRangeForSelection, getSelectionOffsetAndText} from '../../../editor/dom'; +import {getCaretOffsetAndText, getRangeForSelection} from '../../../editor/dom'; import Autocomplete from '../rooms/Autocomplete'; import {autoCompleteCreator} from '../../../editor/parts'; import {renderModel} from '../../../editor/render'; @@ -427,37 +429,6 @@ export default class BasicMessageEditor extends React.Component { return caretPosition; } - _replaceSelection(callback) { - const selection = document.getSelection(); - if (selection.isCollapsed) { - return; - } - const focusOffset = getSelectionOffsetAndText( - this._editorRef, - selection.focusNode, - selection.focusOffset, - ).offset; - const anchorOffset = getSelectionOffsetAndText( - this._editorRef, - selection.anchorNode, - selection.anchorOffset, - ).offset; - const {model} = this.props; - const focusPosition = focusOffset.asPosition(model); - const anchorPosition = anchorOffset.asPosition(model); - const range = model.startRange(focusPosition, anchorPosition); - const firstPosition = focusPosition.compare(anchorPosition) < 0 ? focusPosition : anchorPosition; - - model.transform(() => { - const oldLen = range.length; - const newParts = callback(range); - const addedLen = range.replace(newParts); - const lastOffset = firstPosition.asOffset(model); - lastOffset.offset += oldLen + addedLen; - return lastOffset.asPosition(model); - }); - } - _wrapSelectionAsInline(prefix, suffix = prefix) { const range = getRangeForSelection( this._editorRef, @@ -479,30 +450,11 @@ export default class BasicMessageEditor extends React.Component { } _formatQuote = () => { - const {model} = this.props; - const {partCreator} = this.props.model; - this._replaceSelection(range => { - const parts = range.parts; - parts.splice(0, 0, partCreator.plain("> ")); - const startsWithPartial = range.start.offset !== 0; - const isFirstPart = range.start.index === 0; - const previousIsNewline = !isFirstPart && model.parts[range.start.index - 1].type === "newline"; - // prepend a newline if there is more text before the range on this line - if (startsWithPartial || (!isFirstPart && !previousIsNewline)) { - parts.splice(0, 0, partCreator.newline()); - } - // start at position 1 to make sure we skip the potentially inserted newline above, - // as we already inserted a quote sign for it above - for (let i = 1; i < parts.length; ++i) { - const part = parts[i]; - if (part.type === "newline") { - parts.splice(i + 1, 0, partCreator.plain("> ")); - } - } - parts.push(partCreator.newline()); - parts.push(partCreator.newline()); - return parts; - }); + const range = getRangeForSelection( + this._editorRef, + this.props.model, + document.getSelection()); + formatRangeAsQuote(range); } _formatCodeBlock = () => { diff --git a/src/editor/dom.js b/src/editor/dom.js index 03ee7f2cfc..e2a65be6ff 100644 --- a/src/editor/dom.js +++ b/src/editor/dom.js @@ -45,7 +45,7 @@ export function getCaretOffsetAndText(editor, sel) { return {caret: offset, text}; } -export function getSelectionOffsetAndText(editor, selectionNode, selectionOffset) { +function getSelectionOffsetAndText(editor, selectionNode, selectionOffset) { // sometimes selectionNode is an element, and then selectionOffset means // the index of a child element ... - 1 🤷 if (selectionNode.nodeType === Node.ELEMENT_NODE && selectionOffset !== 0) { diff --git a/src/editor/offset.js b/src/editor/offset.js index c638640f6f..785f16bc6d 100644 --- a/src/editor/offset.js +++ b/src/editor/offset.js @@ -23,4 +23,8 @@ export default class DocumentOffset { asPosition(model) { 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 index 4f0757948a..be979c275a 100644 --- a/src/editor/operations.js +++ b/src/editor/operations.js @@ -29,6 +29,44 @@ export function replaceRangeAndExpandSelection(model, range, newParts) { }); } +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 formatInline(range, prefix, suffix = prefix) { const {model, parts} = range; const {partCreator} = model; diff --git a/src/editor/range.js b/src/editor/range.js index 2163076515..0739cd7842 100644 --- a/src/editor/range.js +++ b/src/editor/range.js @@ -33,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) => { From 6e694c113ad65d5b458f39231b39daea12f4032b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 4 Sep 2019 12:40:21 +0200 Subject: [PATCH 15/27] add support for inline/block code formatting --- .../views/rooms/BasicMessageComposer.js | 9 ++++++-- src/editor/operations.js | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 38cfff9b65..cd8589ee29 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -24,6 +24,7 @@ import {setCaretPosition} from '../../../editor/caret'; import { replaceRangeAndExpandSelection, formatRangeAsQuote, + formatRangeAsCode, formatInline, } from '../../../editor/operations'; import {getCaretOffsetAndText, getRangeForSelection} from '../../../editor/dom'; @@ -457,8 +458,12 @@ export default class BasicMessageEditor extends React.Component { formatRangeAsQuote(range); } - _formatCodeBlock = () => { - + _formatCode = () => { + const range = getRangeForSelection( + this._editorRef, + this.props.model, + document.getSelection()); + formatRangeAsCode(range); } render() { diff --git a/src/editor/operations.js b/src/editor/operations.js index be979c275a..df3047618b 100644 --- a/src/editor/operations.js +++ b/src/editor/operations.js @@ -67,6 +67,29 @@ export function formatRangeAsQuote(range) { 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()); + } + replaceRangeAndExpandSelection(model, range, parts); + } 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; From 4c04bc19c9bf1716bcd490de2a4d1f4f71521ccb Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 4 Sep 2019 12:40:56 +0200 Subject: [PATCH 16/27] fixup: remove now unused import --- src/components/views/rooms/BasicMessageComposer.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index cd8589ee29..b1eb4f0746 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -22,7 +22,6 @@ import EditorModel from '../../../editor/model'; import HistoryManager from '../../../editor/history'; import {setCaretPosition} from '../../../editor/caret'; import { - replaceRangeAndExpandSelection, formatRangeAsQuote, formatRangeAsCode, formatInline, From 7a01d1407f7042a8be65967fe7f62a41f4f06516 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 4 Sep 2019 15:57:29 +0200 Subject: [PATCH 17/27] make _replaceRange internal only --- src/editor/model.js | 4 ++-- src/editor/range.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/editor/model.js b/src/editor/model.js index 34a796f733..a121c67b48 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -401,8 +401,8 @@ export default class EditorModel { 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); diff --git a/src/editor/range.js b/src/editor/range.js index 0739cd7842..822c3b13a7 100644 --- a/src/editor/range.js +++ b/src/editor/range.js @@ -58,7 +58,7 @@ 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; } From 4691108a1668de608385f14db30ae3a4a02bf155 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 4 Sep 2019 15:58:13 +0200 Subject: [PATCH 18/27] only increase offset if caret hasn't been found yet also rename caret away as this isn't used for the caret solely anymore --- src/editor/dom.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/editor/dom.js b/src/editor/dom.js index e2a65be6ff..3096710166 100644 --- a/src/editor/dom.js +++ b/src/editor/dom.js @@ -80,13 +80,13 @@ function getCaret(node, offsetToNode, offsetWithinNode) { // all ZWS from caret nodes are filtered out function getTextAndOffsetToNode(editor, selectionNode) { let offsetToNode = 0; - let foundCaret = false; + let foundNode = false; let text = ""; function enterNodeCallback(node) { - if (!foundCaret) { + if (!foundNode) { if (node === selectionNode) { - foundCaret = true; + foundNode = true; } } // usually newlines are entered as new DIV elements, @@ -94,12 +94,14 @@ function getTextAndOffsetToNode(editor, selectionNode) { // 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"; - offsetToNode += 1; } const nodeText = node.nodeType === Node.TEXT_NODE && getTextNodeValue(node); if (nodeText) { - if (!foundCaret) { + if (!foundNode) { offsetToNode += nodeText.length; } text += nodeText; @@ -114,7 +116,7 @@ function getTextAndOffsetToNode(editor, selectionNode) { // 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) { + if (!foundNode) { offsetToNode += 1; } } From e0668e85175b39a1be168adcef0de259b3ad5fa8 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 4 Sep 2019 15:58:59 +0200 Subject: [PATCH 19/27] improve algorithm to reduce selection to text node + charactar offset this follows the documentation of {focus|anchor}{Offset|Node} better and was causing problems for creating ranges from the document selection when doing ctrl+A in firefox as the anchorNode/Offset was expressed as childNodes from the editor root. --- src/editor/dom.js | 48 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/src/editor/dom.js b/src/editor/dom.js index 3096710166..9073eb37a3 100644 --- a/src/editor/dom.js +++ b/src/editor/dom.js @@ -45,15 +45,47 @@ export function getCaretOffsetAndText(editor, sel) { return {caret: offset, text}; } -function getSelectionOffsetAndText(editor, selectionNode, selectionOffset) { - // sometimes selectionNode is an element, and then selectionOffset means - // the index of a child element ... - 1 🤷 - if (selectionNode.nodeType === Node.ELEMENT_NODE && selectionOffset !== 0) { - selectionNode = selectionNode.childNodes[selectionOffset - 1]; - selectionOffset = selectionNode.textContent.length; +function tryReduceSelectionToTextNode(selectionNode, selectionOffset) { + // if selectionNode is an element, the selected location comes after the selectionOffset-th child node, + // which can point past any childNode, in which case, the end of selectionNode is selected. + // we try to simplify this to point at a text node with the offset being + // a character offset within the text node + // Also see https://developer.mozilla.org/en-US/docs/Web/API/Selection + while (selectionNode && selectionNode.nodeType === Node.ELEMENT_NODE) { + const childNodeCount = selectionNode.childNodes.length; + if (childNodeCount) { + if (selectionOffset >= 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, offsetToNode} = getTextAndOffsetToNode(editor, selectionNode); - const offset = getCaret(selectionNode, offsetToNode, selectionOffset); + 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}; } From 42c37d829310b7b5ffea96a484879dc87b68b685 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 4 Sep 2019 16:01:28 +0200 Subject: [PATCH 20/27] fixup: improve quote and code block newline handling --- src/editor/operations.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/editor/operations.js b/src/editor/operations.js index df3047618b..a8c9ac6d25 100644 --- a/src/editor/operations.js +++ b/src/editor/operations.js @@ -59,7 +59,7 @@ export function formatRangeAsQuote(range) { if (!rangeStartsAtBeginningOfLine(range)) { parts.unshift(partCreator.newline()); } - if (rangeEndsAtEndOfLine(range)) { + if (!rangeEndsAtEndOfLine(range)) { parts.push(partCreator.newline()); } @@ -79,15 +79,14 @@ export function formatRangeAsCode(range) { parts.push( partCreator.newline(), partCreator.plain("```")); - if (rangeEndsAtEndOfLine(range)) { + if (!rangeEndsAtEndOfLine(range)) { parts.push(partCreator.newline()); } - replaceRangeAndExpandSelection(model, range, parts); } else { parts.unshift(partCreator.plain("`")); parts.push(partCreator.plain("`")); - replaceRangeAndExpandSelection(model, range, parts); } + replaceRangeAndExpandSelection(model, range, parts); } export function formatInline(range, prefix, suffix = prefix) { From 037ac29c578257c7a86b0c766bda9f8c3cef7f4b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 4 Sep 2019 16:02:07 +0200 Subject: [PATCH 21/27] be more forgiving with offset that don't have atNodeEnd=true if index is not found, it means the last position should be returned if there is any. We still return -1 for empty documents, as index should always point to a valid part if positive. --- src/editor/model.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/editor/model.js b/src/editor/model.js index a121c67b48..3b4f1ce460 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -388,7 +388,11 @@ 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); + } } /** From 2ea556e0b40897fb92785a956f1b97baa204d41e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 4 Sep 2019 16:04:06 +0200 Subject: [PATCH 22/27] support update callback setting selection instead of caret --- .../views/rooms/BasicMessageComposer.js | 10 +++--- src/editor/caret.js | 33 ++++++++++++++++--- src/editor/model.js | 7 +++- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index b1eb4f0746..3f07cf567e 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -20,7 +20,7 @@ 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 {setSelection} from '../../../editor/caret'; import { formatRangeAsQuote, formatRangeAsCode, @@ -115,11 +115,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); } @@ -133,7 +133,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) { diff --git a/src/editor/caret.js b/src/editor/caret.js index 9b0fa14cfc..ed4f1b2a2e 100644 --- a/src/editor/caret.js +++ b/src/editor/caret.js @@ -16,12 +16,39 @@ limitations under the License. */ import {needsCaretNodeBefore, needsCaretNodeAfter} from "./render"; +import Range from "./range"; + +export function setSelection(editor, model, selection) { + if (selection instanceof Range) { + setDocumentRangeSelection(editor, model, selection); + } else { + setCaretPosition(editor, model, selection); + } +} + +function setDocumentRangeSelection(editor, model, range) { + const sel = document.getSelection(); + sel.removeAllRanges(); + const selectionRange = document.createRange(); + const start = getNodeAndOffsetForPosition(editor, model, range.start); + selectionRange.setStart(start.node, start.offset); + const end = getNodeAndOffsetForPosition(editor, model, range.end); + selectionRange.setEnd(end.node, end.offset); + sel.addRange(selectionRange); +} export function setCaretPosition(editor, model, caretPosition) { const sel = document.getSelection(); sel.removeAllRanges(); const range = document.createRange(); - const {offset, lineIndex, nodeIndex} = getLineAndNodePosition(model, caretPosition); + const {node, offset} = getNodeAndOffsetForPosition(editor, model, caretPosition); + range.setStart(node, offset); + range.collapse(true); + sel.addRange(range); +} + +function getNodeAndOffsetForPosition(editor, model, position) { + const {offset, lineIndex, nodeIndex} = getLineAndNodePosition(model, position); const lineNode = editor.childNodes[lineIndex]; let focusNode; @@ -35,9 +62,7 @@ export function setCaretPosition(editor, model, caretPosition) { focusNode = focusNode.firstChild; } } - range.setStart(focusNode, offset); - range.collapse(true); - sel.addRange(range); + return {node: focusNode, offset}; } export function getLineAndNodePosition(model, caretPosition) { diff --git a/src/editor/model.js b/src/editor/model.js index 3b4f1ce460..ea6b05570c 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -433,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; } From af535986d2f345e8bab914ad83fe6e8d620e4eac Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 4 Sep 2019 16:21:53 +0200 Subject: [PATCH 23/27] fix css lint --- res/css/views/rooms/_BasicMessageComposer.scss | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/res/css/views/rooms/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss index 72a10bf074..a90097846c 100644 --- a/res/css/views/rooms/_BasicMessageComposer.scss +++ b/res/css/views/rooms/_BasicMessageComposer.scss @@ -76,13 +76,12 @@ limitations under the License. .mx_BasicMessageComposer_formatBar { display: none; - background-color: red; width: calc(26px * 5); height: 24px; position: absolute; cursor: pointer; border-radius: 4px; - background: $message-action-bar-bg-color; + background-color: $message-action-bar-bg-color; &.mx_BasicMessageComposer_formatBar_shown { display: block; @@ -137,6 +136,5 @@ limitations under the License. .mx_BasicMessageComposer_formatCode::after { mask-image: url('$(res)/img/format/code.svg'); } - } } From e3d70f39997fff227bafa0bee08c194060e084c0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 5 Sep 2019 11:01:44 +0200 Subject: [PATCH 24/27] ensure selection is not lost upon clicking format bar in chrome --- res/css/views/rooms/_BasicMessageComposer.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/res/css/views/rooms/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss index a90097846c..e897352d7e 100644 --- a/res/css/views/rooms/_BasicMessageComposer.scss +++ b/res/css/views/rooms/_BasicMessageComposer.scss @@ -82,6 +82,7 @@ limitations under the License. cursor: pointer; border-radius: 4px; background-color: $message-action-bar-bg-color; + user-select: none; &.mx_BasicMessageComposer_formatBar_shown { display: block; From 4ef9fa53ac6f8cf395c432fd20d2102fd821643b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 5 Sep 2019 11:10:13 +0200 Subject: [PATCH 25/27] better i18n --- src/components/views/rooms/BasicMessageComposer.js | 11 ++++++----- src/i18n/strings/en_EN.json | 6 +++++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 3f07cf567e..06d8013550 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -35,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$'); @@ -487,11 +488,11 @@ export default class BasicMessageEditor extends React.Component { return (
{ autoComplete }
this._formatBarRef = ref}> - - - - - + + + + +
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", From d2949babcd9a60328ce595f6baf95ea84ec0a2a2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 6 Sep 2019 10:28:20 +0200 Subject: [PATCH 26/27] copyright is solely assigned to matrix foundation now, copy paste error --- src/editor/operations.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/editor/operations.js b/src/editor/operations.js index a8c9ac6d25..e6ed2ba0e5 100644 --- a/src/editor/operations.js +++ b/src/editor/operations.js @@ -1,5 +1,4 @@ /* -Copyright 2019 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); From 92c0c1a4e2c1a38bba1a92fe0d0ca1d85d76db36 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 6 Sep 2019 10:28:53 +0200 Subject: [PATCH 27/27] add comment about positioning the format bar --- src/components/views/rooms/BasicMessageComposer.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 06d8013550..e0468e9969 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -274,6 +274,7 @@ export default class BasicMessageEditor extends React.Component { } 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`; } }