Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

New composer: show format bar on selection #3386

Merged
merged 27 commits into from
Sep 6, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
648ae37
make DocumentOffset compatible with what is returned from dom/getCaret
bwindels Sep 3, 2019
eb87301
allow getting the DocumentOffset for any node+offset, not just focusNode
bwindels Sep 3, 2019
0d02ab5
allow starting a range with both positions known already
bwindels Sep 3, 2019
65ddfc0
show format bar when text is selected
bwindels Sep 3, 2019
48f1bf1
sort positions in Range constructor, so start always comes before end
bwindels Sep 3, 2019
77b14be
add getter for intersecting parts of range, and total length
bwindels Sep 3, 2019
7dc39ba
implement bold support in format bar
bwindels Sep 3, 2019
c15dfc3
make Range start and end public
bwindels Sep 4, 2019
e7db660
fixup: css, we have 5 buttons
bwindels Sep 4, 2019
d4c7992
first impl of inline formatting
bwindels Sep 4, 2019
7f501b2
first impl of quote formatting
bwindels Sep 4, 2019
47d8d86
whitespace (in model)
bwindels Sep 4, 2019
b72d1a7
move inline formatting code out of react component
bwindels Sep 4, 2019
b35a353
move quote formatting out of react component
bwindels Sep 4, 2019
6e694c1
add support for inline/block code formatting
bwindels Sep 4, 2019
4c04bc1
fixup: remove now unused import
bwindels Sep 4, 2019
7a01d14
make _replaceRange internal only
bwindels Sep 4, 2019
4691108
only increase offset if caret hasn't been found yet
bwindels Sep 4, 2019
e0668e8
improve algorithm to reduce selection to text node + charactar offset
bwindels Sep 4, 2019
42c37d8
fixup: improve quote and code block newline handling
bwindels Sep 4, 2019
037ac29
be more forgiving with offset that don't have atNodeEnd=true
bwindels Sep 4, 2019
2ea556e
support update callback setting selection instead of caret
bwindels Sep 4, 2019
af53598
fix css lint
bwindels Sep 4, 2019
e3d70f3
ensure selection is not lost upon clicking format bar in chrome
bwindels Sep 5, 2019
4ef9fa5
better i18n
bwindels Sep 5, 2019
d2949ba
copyright is solely assigned to matrix foundation now, copy paste error
bwindels Sep 6, 2019
92c0c1a
add comment about positioning the format bar
bwindels Sep 6, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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`;
bwindels marked this conversation as resolved.
Show resolved Hide resolved
}
}
}

_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("*");
bwindels marked this conversation as resolved.
Show resolved Hide resolved
}

_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