Merge pull request #3386 from matrix-org/bwindels/cider-formatbar
New composer: show format bar on selection
This commit is contained in:
commit
02681d50b9
14 changed files with 458 additions and 55 deletions
|
@ -16,6 +16,8 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.mx_BasicMessageComposer {
|
.mx_BasicMessageComposer {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
.mx_BasicMessageComposer_inputEmpty > :first-child::before {
|
.mx_BasicMessageComposer_inputEmpty > :first-child::before {
|
||||||
content: var(--placeholder);
|
content: var(--placeholder);
|
||||||
opacity: 0.333;
|
opacity: 0.333;
|
||||||
|
@ -71,4 +73,69 @@ limitations under the License.
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 0;
|
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
res/img/format/bold.svg
Normal file
3
res/img/format/bold.svg
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="13" viewBox="0 0 10 13">
|
||||||
|
<path fill="#212121" fill-rule="nonzero" d="M7.47 6.156c.732.204 1.299.57 1.701 1.098.402.528.603 1.188.603 1.98 0 1.092-.375 1.941-1.125 2.547-.75.606-1.797.909-3.141.909H.918c-.288 0-.513-.081-.675-.243C.081 12.285 0 12.066 0 11.79V.9C0 .624.081.405.243.243.405.081.63 0 .918 0H5.31c1.308 0 2.331.291 3.069.873.738.582 1.107 1.395 1.107 2.439 0 .672-.177 1.254-.531 1.746-.354.492-.849.858-1.485 1.098zM1.818 5.49h3.204c1.776 0 2.664-.672 2.664-2.016 0-.672-.219-1.17-.657-1.494-.438-.324-1.107-.486-2.007-.486H1.818V5.49zm3.492 5.706c.924 0 1.602-.168 2.034-.504.432-.336.648-.858.648-1.566 0-.72-.219-1.257-.657-1.611-.438-.354-1.113-.531-2.025-.531H1.818v4.212H5.31z"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 770 B |
7
res/img/format/code.svg
Normal file
7
res/img/format/code.svg
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="19" height="12" viewBox="0 0 19 12">
|
||||||
|
<g fill="none" fill-rule="evenodd" stroke="#212121" stroke-linecap="round">
|
||||||
|
<path stroke-linejoin="round" d="M14.1 9.8L18 5.9 14.1 2"/>
|
||||||
|
<path d="M7.5 11.5l4-11"/>
|
||||||
|
<path stroke-linejoin="round" d="M4.9 2L1 5.9l3.9 3.9"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 348 B |
3
res/img/format/italics.svg
Normal file
3
res/img/format/italics.svg
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="3" height="13" viewBox="0 0 3 13">
|
||||||
|
<path fill="#212121" fill-rule="nonzero" d="M.542 12.87a.539.539 0 0 1-.396-.162.506.506 0 0 1-.144-.414L1.92.522A.551.551 0 0 1 2.478 0c.168 0 .303.051.405.153.102.102.147.243.135.423L1.1 12.348a.544.544 0 0 1-.171.387.563.563 0 0 1-.387.135z"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 340 B |
5
res/img/format/quote.svg
Normal file
5
res/img/format/quote.svg
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="11" viewBox="0 0 13 11">
|
||||||
|
<g fill="#212121" fill-rule="nonzero">
|
||||||
|
<path d="M1.05 2.375c.25.017.458.112.625.288.167.175.25.404.25.687 0 .3-.087.542-.262.725A.877.877 0 0 1 1 4.35c-.667 0-1-.492-1-1.475C0 1.858.317.975.95.225 1.1.075 1.25 0 1.4 0a.44.44 0 0 1 .325.125.44.44 0 0 1 .125.325c0 .15-.05.275-.15.375a2.26 2.26 0 0 0-.462.7 3.215 3.215 0 0 0-.188.85zm3.575 0c.25.017.458.112.625.288.167.175.25.404.25.687 0 .3-.087.542-.263.725a.877.877 0 0 1-.662.275c-.667 0-1-.492-1-1.475 0-1.017.317-1.9.95-2.65.15-.15.3-.225.45-.225A.44.44 0 0 1 5.3.125a.44.44 0 0 1 .125.325c0 .15-.05.275-.15.375a2.26 2.26 0 0 0-.463.7 3.215 3.215 0 0 0-.187.85zM11.95 7.975a.916.916 0 0 1-.625-.287c-.167-.176-.25-.405-.25-.688 0-.3.087-.542.262-.725A.877.877 0 0 1 12 6c.667 0 1 .492 1 1.475 0 1.017-.317 1.9-.95 2.65-.15.15-.3.225-.45.225a.44.44 0 0 1-.325-.125.44.44 0 0 1-.125-.325c0-.15.05-.275.15-.375a2.26 2.26 0 0 0 .462-.7c.092-.233.155-.517.188-.85zm-3.575 0a.916.916 0 0 1-.625-.287C7.583 7.512 7.5 7.283 7.5 7c0-.3.087-.542.262-.725A.877.877 0 0 1 8.425 6c.667 0 1 .492 1 1.475 0 1.017-.317 1.9-.95 2.65-.15.15-.3.225-.45.225a.44.44 0 0 1-.325-.125.44.44 0 0 1-.125-.325c0-.15.05-.275.15-.375a2.26 2.26 0 0 0 .462-.7c.092-.233.155-.517.188-.85z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.3 KiB |
6
res/img/format/strikethrough.svg
Normal file
6
res/img/format/strikethrough.svg
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="13" viewBox="0 0 12 13">
|
||||||
|
<g fill="none" fill-rule="evenodd">
|
||||||
|
<path fill="#212121" fill-rule="nonzero" d="M9.566 7.834l-.071-.085c.39.426.585.999.585 1.719 0 .684-.189 1.293-.567 1.827-.378.534-.912.948-1.602 1.242-.69.294-1.479.441-2.367.441a7.85 7.85 0 0 1-2.565-.423c-.822-.282-1.467-.663-1.935-1.143a.65.65 0 0 1-.234-.522c0-.132.039-.246.117-.342.078-.096.165-.144.261-.144.12 0 .258.06.414.18 1.128.936 2.442 1.404 3.942 1.404 1.092 0 1.935-.219 2.529-.657.594-.438.891-1.059.891-1.863 0-.468-.144-.846-.432-1.134a2.753 2.753 0 0 0-.696-.5h1.73zM5.616 0c.828 0 1.608.135 2.34.405.732.27 1.338.657 1.818 1.161.156.156.234.33.234.522a.526.526 0 0 1-.117.342c-.078.096-.165.144-.261.144-.12 0-.258-.06-.414-.18-.648-.528-1.233-.894-1.755-1.098C6.939 1.092 6.324.99 5.616.99c-1.068 0-1.902.228-2.502.684-.6.456-.9 1.092-.9 1.908 0 .492.132.891.396 1.197l.052.055H1.326c-.152-.354-.228-.765-.228-1.234 0-.708.189-1.335.567-1.881.378-.546.909-.969 1.593-1.269C3.942.15 4.728 0 5.616 0z"/>
|
||||||
|
<rect width="12" height="1" y="5.834" fill="#000" rx=".5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
|
@ -20,8 +20,13 @@ import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import EditorModel from '../../../editor/model';
|
import EditorModel from '../../../editor/model';
|
||||||
import HistoryManager from '../../../editor/history';
|
import HistoryManager from '../../../editor/history';
|
||||||
import {setCaretPosition} from '../../../editor/caret';
|
import {setSelection} from '../../../editor/caret';
|
||||||
import {getCaretOffsetAndText} from '../../../editor/dom';
|
import {
|
||||||
|
formatRangeAsQuote,
|
||||||
|
formatRangeAsCode,
|
||||||
|
formatInline,
|
||||||
|
} from '../../../editor/operations';
|
||||||
|
import {getCaretOffsetAndText, getRangeForSelection} from '../../../editor/dom';
|
||||||
import Autocomplete from '../rooms/Autocomplete';
|
import Autocomplete from '../rooms/Autocomplete';
|
||||||
import {autoCompleteCreator} from '../../../editor/parts';
|
import {autoCompleteCreator} from '../../../editor/parts';
|
||||||
import {renderModel} from '../../../editor/render';
|
import {renderModel} from '../../../editor/render';
|
||||||
|
@ -30,6 +35,7 @@ import TypingStore from "../../../stores/TypingStore";
|
||||||
import EMOJIBASE from 'emojibase-data/en/compact.json';
|
import EMOJIBASE from 'emojibase-data/en/compact.json';
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import EMOTICON_REGEX from 'emojibase-regex/emoticon';
|
import EMOTICON_REGEX from 'emojibase-regex/emoticon';
|
||||||
|
import { _t } from '../../../languageHandler';
|
||||||
|
|
||||||
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$');
|
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._editorRef = null;
|
||||||
this._autocompleteRef = null;
|
this._autocompleteRef = null;
|
||||||
|
this._formatBarRef = null;
|
||||||
this._modifiedFlag = false;
|
this._modifiedFlag = false;
|
||||||
this._isIMEComposing = false;
|
this._isIMEComposing = false;
|
||||||
|
this._hasTextSelected = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_replaceEmoticon = (caretPosition, inputType, diff) => {
|
_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);
|
renderModel(this._editorRef, this.props.model);
|
||||||
if (caret) {
|
if (selection) { // set the caret/selection
|
||||||
try {
|
try {
|
||||||
setCaretPosition(this._editorRef, this.props.model, caret);
|
setSelection(this._editorRef, this.props.model, selection);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
|
@ -126,7 +134,7 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.setState({autoComplete: this.props.model.autoComplete});
|
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);
|
TypingStore.sharedInstance().setSelfTyping(this.props.room.roomId, !this.props.model.isEmpty);
|
||||||
|
|
||||||
if (this.props.onChange) {
|
if (this.props.onChange) {
|
||||||
|
@ -239,6 +247,37 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
|
|
||||||
_onSelectionChange = () => {
|
_onSelectionChange = () => {
|
||||||
this._refreshLastCaretIfNeeded();
|
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) => {
|
_onKeyDown = (event) => {
|
||||||
|
@ -392,6 +431,42 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
return caretPosition;
|
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() {
|
render() {
|
||||||
let autoComplete;
|
let autoComplete;
|
||||||
if (this.state.autoComplete) {
|
if (this.state.autoComplete) {
|
||||||
|
@ -413,6 +488,13 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
});
|
});
|
||||||
return (<div className={classes}>
|
return (<div className={classes}>
|
||||||
{ autoComplete }
|
{ 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
|
<div
|
||||||
className="mx_BasicMessageComposer_input"
|
className="mx_BasicMessageComposer_input"
|
||||||
contentEditable="true"
|
contentEditable="true"
|
||||||
|
|
|
@ -16,12 +16,39 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {needsCaretNodeBefore, needsCaretNodeAfter} from "./render";
|
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) {
|
export function setCaretPosition(editor, model, caretPosition) {
|
||||||
const sel = document.getSelection();
|
const sel = document.getSelection();
|
||||||
sel.removeAllRanges();
|
sel.removeAllRanges();
|
||||||
const range = document.createRange();
|
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];
|
const lineNode = editor.childNodes[lineIndex];
|
||||||
|
|
||||||
let focusNode;
|
let focusNode;
|
||||||
|
@ -35,9 +62,7 @@ export function setCaretPosition(editor, model, caretPosition) {
|
||||||
focusNode = focusNode.firstChild;
|
focusNode = focusNode.firstChild;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
range.setStart(focusNode, offset);
|
return {node: focusNode, offset};
|
||||||
range.collapse(true);
|
|
||||||
sel.addRange(range);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLineAndNodePosition(model, caretPosition) {
|
export function getLineAndNodePosition(model, caretPosition) {
|
||||||
|
|
|
@ -16,6 +16,7 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {CARET_NODE_CHAR, isCaretNode} from "./render";
|
import {CARET_NODE_CHAR, isCaretNode} from "./render";
|
||||||
|
import DocumentOffset from "./offset";
|
||||||
|
|
||||||
export function walkDOMDepthFirst(rootNode, enterNodeCallback, leaveNodeCallback) {
|
export function walkDOMDepthFirst(rootNode, enterNodeCallback, leaveNodeCallback) {
|
||||||
let node = rootNode.firstChild;
|
let node = rootNode.firstChild;
|
||||||
|
@ -40,26 +41,62 @@ export function walkDOMDepthFirst(rootNode, enterNodeCallback, leaveNodeCallback
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCaretOffsetAndText(editor, sel) {
|
export function getCaretOffsetAndText(editor, sel) {
|
||||||
let {focusNode, focusOffset} = sel;
|
const {offset, text} = getSelectionOffsetAndText(editor, sel.focusNode, sel.focusOffset);
|
||||||
// sometimes focusNode is an element, and then focusOffset means
|
return {caret: offset, text};
|
||||||
// the index of a child element ... - 1 🤷
|
}
|
||||||
if (focusNode.nodeType === Node.ELEMENT_NODE && focusOffset !== 0) {
|
|
||||||
focusNode = focusNode.childNodes[focusOffset - 1];
|
function tryReduceSelectionToTextNode(selectionNode, selectionOffset) {
|
||||||
focusOffset = focusNode.textContent.length;
|
// 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, focusNodeOffset} = getTextAndFocusNodeOffset(editor, focusNode, focusOffset);
|
return {
|
||||||
const caret = getCaret(focusNode, focusNodeOffset, focusOffset);
|
node: selectionNode,
|
||||||
return {caret, text};
|
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
|
// gets the caret position details, ignoring and adjusting to
|
||||||
// the ZWS if you're typing in a caret node
|
// the ZWS if you're typing in a caret node
|
||||||
function getCaret(focusNode, focusNodeOffset, focusOffset) {
|
function getCaret(node, offsetToNode, offsetWithinNode) {
|
||||||
let atNodeEnd = focusOffset === focusNode.textContent.length;
|
let atNodeEnd = offsetWithinNode === node.textContent.length;
|
||||||
if (focusNode.nodeType === Node.TEXT_NODE && isCaretNode(focusNode.parentElement)) {
|
if (node.nodeType === Node.TEXT_NODE && isCaretNode(node.parentElement)) {
|
||||||
const zwsIdx = focusNode.nodeValue.indexOf(CARET_NODE_CHAR);
|
const zwsIdx = node.nodeValue.indexOf(CARET_NODE_CHAR);
|
||||||
if (zwsIdx !== -1 && zwsIdx < focusOffset) {
|
if (zwsIdx !== -1 && zwsIdx < offsetWithinNode) {
|
||||||
focusOffset -= 1;
|
offsetWithinNode -= 1;
|
||||||
}
|
}
|
||||||
// if typing in a caret node, you're either typing before or after the ZWS.
|
// 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
|
// 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.
|
// that caret node will be removed.
|
||||||
atNodeEnd = true;
|
atNodeEnd = true;
|
||||||
}
|
}
|
||||||
return {offset: focusNodeOffset + focusOffset, atNodeEnd};
|
return new DocumentOffset(offsetToNode + offsetWithinNode, atNodeEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
// gets the text of the editor as a string,
|
// 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
|
// all ZWS from caret nodes are filtered out
|
||||||
function getTextAndFocusNodeOffset(editor, focusNode, focusOffset) {
|
function getTextAndOffsetToNode(editor, selectionNode) {
|
||||||
let focusNodeOffset = 0;
|
let offsetToNode = 0;
|
||||||
let foundCaret = false;
|
let foundNode = false;
|
||||||
let text = "";
|
let text = "";
|
||||||
|
|
||||||
function enterNodeCallback(node) {
|
function enterNodeCallback(node) {
|
||||||
if (!foundCaret) {
|
if (!foundNode) {
|
||||||
if (node === focusNode) {
|
if (node === selectionNode) {
|
||||||
foundCaret = true;
|
foundNode = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// usually newlines are entered as new DIV elements,
|
// 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
|
// converted to BRs, so also take these into account when they
|
||||||
// are not the last element in the DIV.
|
// are not the last element in the DIV.
|
||||||
if (node.tagName === "BR" && node.nextSibling) {
|
if (node.tagName === "BR" && node.nextSibling) {
|
||||||
|
if (!foundNode) {
|
||||||
|
offsetToNode += 1;
|
||||||
|
}
|
||||||
text += "\n";
|
text += "\n";
|
||||||
focusNodeOffset += 1;
|
|
||||||
}
|
}
|
||||||
const nodeText = node.nodeType === Node.TEXT_NODE && getTextNodeValue(node);
|
const nodeText = node.nodeType === Node.TEXT_NODE && getTextNodeValue(node);
|
||||||
if (nodeText) {
|
if (nodeText) {
|
||||||
if (!foundCaret) {
|
if (!foundNode) {
|
||||||
focusNodeOffset += nodeText.length;
|
offsetToNode += nodeText.length;
|
||||||
}
|
}
|
||||||
text += nodeText;
|
text += nodeText;
|
||||||
}
|
}
|
||||||
|
@ -109,15 +148,15 @@ function getTextAndFocusNodeOffset(editor, focusNode, focusOffset) {
|
||||||
// whereas you just want it to be appended to the current line
|
// whereas you just want it to be appended to the current line
|
||||||
if (node.tagName === "DIV" && node.nextSibling && node.nextSibling.tagName === "DIV") {
|
if (node.tagName === "DIV" && node.nextSibling && node.nextSibling.tagName === "DIV") {
|
||||||
text += "\n";
|
text += "\n";
|
||||||
if (!foundCaret) {
|
if (!foundNode) {
|
||||||
focusNodeOffset += 1;
|
offsetToNode += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
walkDOMDepthFirst(editor, enterNodeCallback, leaveNodeCallback);
|
walkDOMDepthFirst(editor, enterNodeCallback, leaveNodeCallback);
|
||||||
|
|
||||||
return {text, focusNodeOffset};
|
return {text, offsetToNode};
|
||||||
}
|
}
|
||||||
|
|
||||||
// get text value of text node, ignoring ZWS if it's a caret node
|
// get text value of text node, ignoring ZWS if it's a caret node
|
||||||
|
@ -137,3 +176,19 @@ function getTextNodeValue(node) {
|
||||||
return nodeText;
|
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);
|
||||||
|
}
|
||||||
|
|
|
@ -388,21 +388,25 @@ export default class EditorModel {
|
||||||
currentOffset += partLen;
|
currentOffset += partLen;
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
if (index === -1) {
|
||||||
return new DocumentPosition(index, totalOffset - currentOffset);
|
return this.getPositionAtEnd();
|
||||||
|
} else {
|
||||||
|
return new DocumentPosition(index, totalOffset - currentOffset);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts a range, which can span across multiple parts, to find and replace text.
|
* 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}
|
* @return {Range}
|
||||||
*/
|
*/
|
||||||
startRange(position) {
|
startRange(positionA, positionB = positionA) {
|
||||||
return new Range(this, position);
|
return new Range(this, positionA, positionB);
|
||||||
}
|
}
|
||||||
|
|
||||||
//mostly internal, called from Range.replace
|
// called from Range.replace
|
||||||
replaceRange(startPosition, endPosition, parts) {
|
_replaceRange(startPosition, endPosition, parts) {
|
||||||
// convert end position to offset, so it is independent of how the document is split into 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
|
// which we'll change when splitting up at the start position
|
||||||
const endOffset = endPosition.asOffset(this);
|
const endOffset = endPosition.asOffset(this);
|
||||||
|
@ -429,7 +433,12 @@ export default class EditorModel {
|
||||||
*/
|
*/
|
||||||
transform(callback) {
|
transform(callback) {
|
||||||
const pos = 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);
|
this._updateCallback(pos);
|
||||||
return acPromise;
|
return acPromise;
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,12 +15,16 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export default class DocumentOffset {
|
export default class DocumentOffset {
|
||||||
constructor(offset, atEnd) {
|
constructor(offset, atNodeEnd) {
|
||||||
this.offset = offset;
|
this.offset = offset;
|
||||||
this.atEnd = atEnd;
|
this.atNodeEnd = atNodeEnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
asPosition(model) {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
97
src/editor/operations.js
Normal file
97
src/editor/operations.js
Normal file
|
@ -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);
|
||||||
|
}
|
|
@ -15,10 +15,11 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export default class Range {
|
export default class Range {
|
||||||
constructor(model, startPosition, endPosition = startPosition) {
|
constructor(model, positionA, positionB = positionA) {
|
||||||
this._model = model;
|
this._model = model;
|
||||||
this._start = startPosition;
|
const bIsLarger = positionA.compare(positionB) < 0;
|
||||||
this._end = endPosition;
|
this._start = bIsLarger ? positionA : positionB;
|
||||||
|
this._end = bIsLarger ? positionB : positionA;
|
||||||
}
|
}
|
||||||
|
|
||||||
moveStart(delta) {
|
moveStart(delta) {
|
||||||
|
@ -32,6 +33,10 @@ export default class Range {
|
||||||
this._start = this._start.backwardsWhile(this._model, predicate);
|
this._start = this._start.backwardsWhile(this._model, predicate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get model() {
|
||||||
|
return this._model;
|
||||||
|
}
|
||||||
|
|
||||||
get text() {
|
get text() {
|
||||||
let text = "";
|
let text = "";
|
||||||
this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => {
|
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) => {
|
this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => {
|
||||||
oldLength += endIdx - startIdx;
|
oldLength += endIdx - startIdx;
|
||||||
});
|
});
|
||||||
this._model.replaceRange(this._start, this._end, parts);
|
this._model._replaceRange(this._start, this._end, parts);
|
||||||
return newLength - oldLength;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -760,6 +760,11 @@
|
||||||
" (unsupported)": " (unsupported)",
|
" (unsupported)": " (unsupported)",
|
||||||
"Join as <voiceText>voice</voiceText> or <videoText>video</videoText>.": "Join as <voiceText>voice</voiceText> or <videoText>video</videoText>.",
|
"Join as <voiceText>voice</voiceText> or <videoText>video</videoText>.": "Join as <voiceText>voice</voiceText> or <videoText>video</videoText>.",
|
||||||
"Ongoing conference call%(supportedText)s.": "Ongoing conference call%(supportedText)s.",
|
"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 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",
|
"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",
|
"All devices for this user are trusted": "All devices for this user are trusted",
|
||||||
|
@ -1404,7 +1409,6 @@
|
||||||
"Unhide Preview": "Unhide Preview",
|
"Unhide Preview": "Unhide Preview",
|
||||||
"Share Permalink": "Share Permalink",
|
"Share Permalink": "Share Permalink",
|
||||||
"Share Message": "Share Message",
|
"Share Message": "Share Message",
|
||||||
"Quote": "Quote",
|
|
||||||
"Source URL": "Source URL",
|
"Source URL": "Source URL",
|
||||||
"Collapse Reply Thread": "Collapse Reply Thread",
|
"Collapse Reply Thread": "Collapse Reply Thread",
|
||||||
"End-to-end encryption information": "End-to-end encryption information",
|
"End-to-end encryption information": "End-to-end encryption information",
|
||||||
|
|
Loading…
Reference in a new issue