Skip to content

Commit

Permalink
Merge pull request #3386 from matrix-org/bwindels/cider-formatbar
Browse files Browse the repository at this point in the history
New composer: show format bar on selection
  • Loading branch information
bwindels committed Sep 6, 2019
2 parents 46ee52a + 92c0c1a commit 02681d5
Show file tree
Hide file tree
Showing 14 changed files with 458 additions and 55 deletions.
67 changes: 67 additions & 0 deletions res/css/views/rooms/_BasicMessageComposer.scss
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ limitations under the License.
*/

.mx_BasicMessageComposer {
position: relative;

.mx_BasicMessageComposer_inputEmpty > :first-child::before {
content: var(--placeholder);
opacity: 0.333;
Expand Down Expand Up @@ -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');
}
}
}
3 changes: 3 additions & 0 deletions res/img/format/bold.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions res/img/format/code.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions res/img/format/italics.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions res/img/format/quote.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions res/img/format/strikethrough.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
94 changes: 88 additions & 6 deletions src/components/views/rooms/BasicMessageComposer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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$');

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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("<del>", "</del>");
}

_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) {
Expand All @@ -413,6 +488,13 @@ export default class BasicMessageEditor extends React.Component {
});
return (<div className={classes}>
{ autoComplete }
<div className="mx_BasicMessageComposer_formatBar" ref={ref => this._formatBarRef = ref}>
<span aria-label={_t("Bold")} role="button" onClick={this._formatBold} className="mx_BasicMessageComposer_formatButton mx_BasicMessageComposer_formatBold"></span>
<span aria-label={_t("Italics")} role="button" onClick={this._formatItalic} className="mx_BasicMessageComposer_formatButton mx_BasicMessageComposer_formatItalic"></span>
<span aria-label={_t("Strikethrough")} role="button" onClick={this._formatStrikethrough} className="mx_BasicMessageComposer_formatButton mx_BasicMessageComposer_formatStrikethrough"></span>
<span aria-label={_t("Code block")} role="button" onClick={this._formatCode} className="mx_BasicMessageComposer_formatButton mx_BasicMessageComposer_formatCode"></span>
<span aria-label={_t("Quote")} role="button" onClick={this._formatQuote} className="mx_BasicMessageComposer_formatButton mx_BasicMessageComposer_formatQuote"></span>
</div>
<div
className="mx_BasicMessageComposer_input"
contentEditable="true"
Expand Down
33 changes: 29 additions & 4 deletions src/editor/caret.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
Loading

0 comments on commit 02681d5

Please sign in to comment.