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,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) {
|
||||
|
|
|
@ -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,62 @@ export function walkDOMDepthFirst(rootNode, enterNodeCallback, leaveNodeCallback
|
|||
}
|
||||
|
||||
export function getCaretOffsetAndText(editor, sel) {
|
||||
let {focusNode, focusOffset} = sel;
|
||||
// sometimes focusNode is an element, and then focusOffset 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;
|
||||
const {offset, text} = getSelectionOffsetAndText(editor, sel.focusNode, sel.focusOffset);
|
||||
return {caret: offset, text};
|
||||
}
|
||||
|
||||
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, focusNodeOffset} = getTextAndFocusNodeOffset(editor, focusNode, focusOffset);
|
||||
const caret = getCaret(focusNode, focusNodeOffset, focusOffset);
|
||||
return {caret, text};
|
||||
return {
|
||||
node: selectionNode,
|
||||
characterOffset: selectionOffset,
|
||||
};
|
||||
}
|
||||
|
||||
function getSelectionOffsetAndText(editor, selectionNode, selectionOffset) {
|
||||
const {node, characterOffset} = tryReduceSelectionToTextNode(selectionNode, selectionOffset);
|
||||
const {text, offsetToNode} = getTextAndOffsetToNode(editor, node);
|
||||
const offset = getCaret(node, offsetToNode, characterOffset);
|
||||
return {offset, text};
|
||||
}
|
||||
|
||||
// gets the caret position details, ignoring and adjusting to
|
||||
// the ZWS if you're typing in a caret node
|
||||
function getCaret(focusNode, focusNodeOffset, focusOffset) {
|
||||
let atNodeEnd = focusOffset === focusNode.textContent.length;
|
||||
if (focusNode.nodeType === Node.TEXT_NODE && isCaretNode(focusNode.parentElement)) {
|
||||
const zwsIdx = focusNode.nodeValue.indexOf(CARET_NODE_CHAR);
|
||||
if (zwsIdx !== -1 && zwsIdx < focusOffset) {
|
||||
focusOffset -= 1;
|
||||
function getCaret(node, offsetToNode, offsetWithinNode) {
|
||||
let atNodeEnd = offsetWithinNode === node.textContent.length;
|
||||
if (node.nodeType === Node.TEXT_NODE && isCaretNode(node.parentElement)) {
|
||||
const zwsIdx = node.nodeValue.indexOf(CARET_NODE_CHAR);
|
||||
if (zwsIdx !== -1 && zwsIdx < offsetWithinNode) {
|
||||
offsetWithinNode -= 1;
|
||||
}
|
||||
// if typing in a caret node, you're either typing before or after the ZWS.
|
||||
// In both cases, you should be considered at node end because the ZWS is
|
||||
|
@ -67,21 +104,21 @@ function getCaret(focusNode, focusNodeOffset, focusOffset) {
|
|||
// that caret node will be removed.
|
||||
atNodeEnd = true;
|
||||
}
|
||||
return {offset: focusNodeOffset + focusOffset, atNodeEnd};
|
||||
return new DocumentOffset(offsetToNode + offsetWithinNode, atNodeEnd);
|
||||
}
|
||||
|
||||
// gets the text of the editor as a string,
|
||||
// and the offset in characters where the focusNode starts in that string
|
||||
// and the offset in characters where the selectionNode starts in that string
|
||||
// all ZWS from caret nodes are filtered out
|
||||
function getTextAndFocusNodeOffset(editor, focusNode, focusOffset) {
|
||||
let focusNodeOffset = 0;
|
||||
let foundCaret = false;
|
||||
function getTextAndOffsetToNode(editor, selectionNode) {
|
||||
let offsetToNode = 0;
|
||||
let foundNode = false;
|
||||
let text = "";
|
||||
|
||||
function enterNodeCallback(node) {
|
||||
if (!foundCaret) {
|
||||
if (node === focusNode) {
|
||||
foundCaret = true;
|
||||
if (!foundNode) {
|
||||
if (node === selectionNode) {
|
||||
foundNode = true;
|
||||
}
|
||||
}
|
||||
// usually newlines are entered as new DIV elements,
|
||||
|
@ -89,13 +126,15 @@ function getTextAndFocusNodeOffset(editor, focusNode, focusOffset) {
|
|||
// converted to BRs, so also take these into account when they
|
||||
// are not the last element in the DIV.
|
||||
if (node.tagName === "BR" && node.nextSibling) {
|
||||
if (!foundNode) {
|
||||
offsetToNode += 1;
|
||||
}
|
||||
text += "\n";
|
||||
focusNodeOffset += 1;
|
||||
}
|
||||
const nodeText = node.nodeType === Node.TEXT_NODE && getTextNodeValue(node);
|
||||
if (nodeText) {
|
||||
if (!foundCaret) {
|
||||
focusNodeOffset += nodeText.length;
|
||||
if (!foundNode) {
|
||||
offsetToNode += nodeText.length;
|
||||
}
|
||||
text += nodeText;
|
||||
}
|
||||
|
@ -109,15 +148,15 @@ function getTextAndFocusNodeOffset(editor, focusNode, focusOffset) {
|
|||
// whereas you just want it to be appended to the current line
|
||||
if (node.tagName === "DIV" && node.nextSibling && node.nextSibling.tagName === "DIV") {
|
||||
text += "\n";
|
||||
if (!foundCaret) {
|
||||
focusNodeOffset += 1;
|
||||
if (!foundNode) {
|
||||
offsetToNode += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walkDOMDepthFirst(editor, enterNodeCallback, leaveNodeCallback);
|
||||
|
||||
return {text, focusNodeOffset};
|
||||
return {text, offsetToNode};
|
||||
}
|
||||
|
||||
// get text value of text node, ignoring ZWS if it's a caret node
|
||||
|
@ -137,3 +176,19 @@ function getTextNodeValue(node) {
|
|||
return nodeText;
|
||||
}
|
||||
}
|
||||
|
||||
export function getRangeForSelection(editor, model, selection) {
|
||||
const focusOffset = getSelectionOffsetAndText(
|
||||
editor,
|
||||
selection.focusNode,
|
||||
selection.focusOffset,
|
||||
).offset;
|
||||
const anchorOffset = getSelectionOffsetAndText(
|
||||
editor,
|
||||
selection.anchorNode,
|
||||
selection.anchorOffset,
|
||||
).offset;
|
||||
const focusPosition = focusOffset.asPosition(model);
|
||||
const anchorPosition = anchorOffset.asPosition(model);
|
||||
return model.startRange(focusPosition, anchorPosition);
|
||||
}
|
||||
|
|
|
@ -388,21 +388,25 @@ export default class EditorModel {
|
|||
currentOffset += partLen;
|
||||
return false;
|
||||
});
|
||||
|
||||
return new DocumentPosition(index, totalOffset - currentOffset);
|
||||
if (index === -1) {
|
||||
return this.getPositionAtEnd();
|
||||
} else {
|
||||
return new DocumentPosition(index, totalOffset - currentOffset);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a range, which can span across multiple parts, to find and replace text.
|
||||
* @param {DocumentPosition} position where to start the range
|
||||
* @param {DocumentPosition} positionA a boundary of the range
|
||||
* @param {DocumentPosition?} positionB the other boundary of the range, optional
|
||||
* @return {Range}
|
||||
*/
|
||||
startRange(position) {
|
||||
return new Range(this, position);
|
||||
startRange(positionA, positionB = positionA) {
|
||||
return new Range(this, positionA, positionB);
|
||||
}
|
||||
|
||||
//mostly internal, called from Range.replace
|
||||
replaceRange(startPosition, endPosition, parts) {
|
||||
// called from Range.replace
|
||||
_replaceRange(startPosition, endPosition, parts) {
|
||||
// convert end position to offset, so it is independent of how the document is split into parts
|
||||
// which we'll change when splitting up at the start position
|
||||
const endOffset = endPosition.asOffset(this);
|
||||
|
@ -429,7 +433,12 @@ export default class EditorModel {
|
|||
*/
|
||||
transform(callback) {
|
||||
const pos = callback();
|
||||
const acPromise = this._setActivePart(pos, true);
|
||||
let acPromise = null;
|
||||
if (!(pos instanceof Range)) {
|
||||
acPromise = this._setActivePart(pos, true);
|
||||
} else {
|
||||
acPromise = Promise.resolve();
|
||||
}
|
||||
this._updateCallback(pos);
|
||||
return acPromise;
|
||||
}
|
||||
|
|
|
@ -15,12 +15,16 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
export default class DocumentOffset {
|
||||
constructor(offset, atEnd) {
|
||||
constructor(offset, atNodeEnd) {
|
||||
this.offset = offset;
|
||||
this.atEnd = atEnd;
|
||||
this.atNodeEnd = atNodeEnd;
|
||||
}
|
||||
|
||||
asPosition(model) {
|
||||
return model.positionForOffset(this.offset, this.atEnd);
|
||||
return model.positionForOffset(this.offset, this.atNodeEnd);
|
||||
}
|
||||
|
||||
add(delta, atNodeEnd = false) {
|
||||
return new DocumentOffset(this.offset + delta, atNodeEnd);
|
||||
}
|
||||
}
|
||||
|
|
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 {
|
||||
constructor(model, startPosition, endPosition = startPosition) {
|
||||
constructor(model, positionA, positionB = positionA) {
|
||||
this._model = model;
|
||||
this._start = startPosition;
|
||||
this._end = endPosition;
|
||||
const bIsLarger = positionA.compare(positionB) < 0;
|
||||
this._start = bIsLarger ? positionA : positionB;
|
||||
this._end = bIsLarger ? positionB : positionA;
|
||||
}
|
||||
|
||||
moveStart(delta) {
|
||||
|
@ -32,6 +33,10 @@ export default class Range {
|
|||
this._start = this._start.backwardsWhile(this._model, predicate);
|
||||
}
|
||||
|
||||
get model() {
|
||||
return this._model;
|
||||
}
|
||||
|
||||
get text() {
|
||||
let text = "";
|
||||
this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => {
|
||||
|
@ -53,7 +58,38 @@ export default class Range {
|
|||
this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => {
|
||||
oldLength += endIdx - startIdx;
|
||||
});
|
||||
this._model.replaceRange(this._start, this._end, parts);
|
||||
this._model._replaceRange(this._start, this._end, parts);
|
||||
return newLength - oldLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of the (partial) parts within the range.
|
||||
* For partial parts, only the text is adjusted to the part that intersects with the range.
|
||||
*/
|
||||
get parts() {
|
||||
const parts = [];
|
||||
this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => {
|
||||
const serializedPart = part.serialize();
|
||||
serializedPart.text = part.text.substring(startIdx, endIdx);
|
||||
const newPart = this._model.partCreator.deserializePart(serializedPart);
|
||||
parts.push(newPart);
|
||||
});
|
||||
return parts;
|
||||
}
|
||||
|
||||
get length() {
|
||||
let len = 0;
|
||||
this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => {
|
||||
len += endIdx - startIdx;
|
||||
});
|
||||
return len;
|
||||
}
|
||||
|
||||
get start() {
|
||||
return this._start;
|
||||
}
|
||||
|
||||
get end() {
|
||||
return this._end;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue