Improve formatting features in the editor (#7104)
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
parent
cbf5fbf870
commit
26e6f8deca
8 changed files with 427 additions and 59 deletions
|
@ -16,11 +16,54 @@ limitations under the License.
|
|||
|
||||
import Range from "./range";
|
||||
import { Part, Type } from "./parts";
|
||||
import { Formatting } from "../components/views/rooms/MessageComposerFormatBar";
|
||||
|
||||
/**
|
||||
* Some common queries and transformations on the editor model
|
||||
*/
|
||||
|
||||
/**
|
||||
* Formats a given range with a given action
|
||||
* @param {Range} range the range that should be formatted
|
||||
* @param {Formatting} action the action that should be performed on the range
|
||||
*/
|
||||
export function formatRange(range: Range, action: Formatting): void {
|
||||
// If the selection was empty we select the current word instead
|
||||
if (range.wasInitializedEmpty()) {
|
||||
selectRangeOfWordAtCaret(range);
|
||||
} else {
|
||||
// Remove whitespace or new lines in our selection
|
||||
range.trim();
|
||||
}
|
||||
|
||||
// Edgecase when just selecting whitespace or new line.
|
||||
// There should be no reason to format whitespace, so we can just return.
|
||||
if (range.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case Formatting.Bold:
|
||||
toggleInlineFormat(range, "**");
|
||||
break;
|
||||
case Formatting.Italics:
|
||||
toggleInlineFormat(range, "_");
|
||||
break;
|
||||
case Formatting.Strikethrough:
|
||||
toggleInlineFormat(range, "<del>", "</del>");
|
||||
break;
|
||||
case Formatting.Code:
|
||||
formatRangeAsCode(range);
|
||||
break;
|
||||
case Formatting.Quote:
|
||||
formatRangeAsQuote(range);
|
||||
break;
|
||||
case Formatting.InsertLink:
|
||||
formatRangeAsLink(range);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]): void {
|
||||
const { model } = range;
|
||||
model.transform(() => {
|
||||
|
@ -32,17 +75,69 @@ export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]):
|
|||
});
|
||||
}
|
||||
|
||||
export function replaceRangeAndMoveCaret(range: Range, newParts: Part[], offset = 0): void {
|
||||
export function replaceRangeAndMoveCaret(range: Range, newParts: Part[], offset = 0, atNodeEnd = false): void {
|
||||
const { model } = range;
|
||||
model.transform(() => {
|
||||
const oldLen = range.length;
|
||||
const addedLen = range.replace(newParts);
|
||||
const firstOffset = range.start.asOffset(model);
|
||||
const lastOffset = firstOffset.add(oldLen + addedLen + offset);
|
||||
const lastOffset = firstOffset.add(oldLen + addedLen + offset, atNodeEnd);
|
||||
return lastOffset.asPosition(model);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a range with formatting or removes existing formatting and
|
||||
* positions the cursor with respect to the prefix and suffix length.
|
||||
* @param {Range} range the previous value
|
||||
* @param {Part[]} newParts the new value
|
||||
* @param {boolean} rangeHasFormatting the new value
|
||||
* @param {number} prefixLength the length of the formatting prefix
|
||||
* @param {number} suffixLength the length of the formatting suffix, defaults to prefix length
|
||||
*/
|
||||
export function replaceRangeAndAutoAdjustCaret(
|
||||
range: Range,
|
||||
newParts: Part[],
|
||||
rangeHasFormatting = false,
|
||||
prefixLength: number,
|
||||
suffixLength = prefixLength,
|
||||
): void {
|
||||
const { model } = range;
|
||||
const lastStartingPosition = range.getLastStartingPosition();
|
||||
const relativeOffset = lastStartingPosition.offset - range.start.offset;
|
||||
const distanceFromEnd = range.length - relativeOffset;
|
||||
// Handle edge case where the caret is located within the suffix or prefix
|
||||
if (rangeHasFormatting) {
|
||||
if (relativeOffset < prefixLength) { // Was the caret at the left format string?
|
||||
replaceRangeAndMoveCaret(range, newParts, -(range.length - 2 * suffixLength));
|
||||
return;
|
||||
}
|
||||
if (distanceFromEnd < suffixLength) { // Was the caret at the right format string?
|
||||
replaceRangeAndMoveCaret(range, newParts, 0, true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Calculate new position with respect to the previous position
|
||||
model.transform(() => {
|
||||
const offsetDirection = Math.sign(range.replace(newParts)); // Compensates for shrinkage or expansion
|
||||
const atEnd = distanceFromEnd === suffixLength;
|
||||
return lastStartingPosition.asOffset(model).add(offsetDirection * prefixLength, atEnd).asPosition(model);
|
||||
});
|
||||
}
|
||||
|
||||
const isFormattable = (_index: number, offset: number, part: Part) => {
|
||||
return part.text[offset] !== " " && part.type === Type.Plain;
|
||||
};
|
||||
|
||||
export function selectRangeOfWordAtCaret(range: Range): void {
|
||||
// Select right side of word
|
||||
range.expandForwardsWhile(isFormattable);
|
||||
// Select left side of word
|
||||
range.expandBackwardsWhile(isFormattable);
|
||||
// Trim possibly selected new lines
|
||||
range.trim();
|
||||
}
|
||||
|
||||
export function rangeStartsAtBeginningOfLine(range: Range): boolean {
|
||||
const { model } = range;
|
||||
const startsWithPartial = range.start.offset !== 0;
|
||||
|
@ -76,7 +171,6 @@ export function formatRangeAsQuote(range: Range): void {
|
|||
if (!rangeEndsAtEndOfLine(range)) {
|
||||
parts.push(partCreator.newline());
|
||||
}
|
||||
|
||||
parts.push(partCreator.newline());
|
||||
replaceRangeAndExpandSelection(range, parts);
|
||||
}
|
||||
|
@ -84,8 +178,22 @@ export function formatRangeAsQuote(range: Range): void {
|
|||
export function formatRangeAsCode(range: Range): void {
|
||||
const { model, parts } = range;
|
||||
const { partCreator } = model;
|
||||
const needsBlock = parts.some(p => p.type === Type.Newline);
|
||||
if (needsBlock) {
|
||||
|
||||
const hasBlockFormatting = (range.length > 0)
|
||||
&& range.text.startsWith("```")
|
||||
&& range.text.endsWith("```");
|
||||
|
||||
const needsBlockFormatting = parts.some(p => p.type === Type.Newline);
|
||||
|
||||
if (hasBlockFormatting) {
|
||||
// Remove previously pushed backticks and new lines
|
||||
parts.shift();
|
||||
parts.pop();
|
||||
if (parts[0]?.text === "\n" && parts[parts.length - 1]?.text === "\n") {
|
||||
parts.shift();
|
||||
parts.pop();
|
||||
}
|
||||
} else if (needsBlockFormatting) {
|
||||
parts.unshift(partCreator.plain("```"), partCreator.newline());
|
||||
if (!rangeStartsAtBeginningOfLine(range)) {
|
||||
parts.unshift(partCreator.newline());
|
||||
|
@ -97,19 +205,28 @@ export function formatRangeAsCode(range: Range): void {
|
|||
parts.push(partCreator.newline());
|
||||
}
|
||||
} else {
|
||||
parts.unshift(partCreator.plain("`"));
|
||||
parts.push(partCreator.plain("`"));
|
||||
toggleInlineFormat(range, "`");
|
||||
return;
|
||||
}
|
||||
|
||||
replaceRangeAndExpandSelection(range, parts);
|
||||
}
|
||||
|
||||
export function formatRangeAsLink(range: Range) {
|
||||
const { model, parts } = range;
|
||||
const { model } = range;
|
||||
const { partCreator } = model;
|
||||
parts.unshift(partCreator.plain("["));
|
||||
parts.push(partCreator.plain("]()"));
|
||||
// We set offset to -1 here so that the caret lands between the brackets
|
||||
replaceRangeAndMoveCaret(range, parts, -1);
|
||||
const linkRegex = /\[(.*?)\]\(.*?\)/g;
|
||||
const isFormattedAsLink = linkRegex.test(range.text);
|
||||
if (isFormattedAsLink) {
|
||||
const linkDescription = range.text.replace(linkRegex, "$1");
|
||||
const newParts = [partCreator.plain(linkDescription)];
|
||||
const prefixLength = 1;
|
||||
const suffixLength = range.length - (linkDescription.length + 2);
|
||||
replaceRangeAndAutoAdjustCaret(range, newParts, true, prefixLength, suffixLength);
|
||||
} else {
|
||||
// We set offset to -1 here so that the caret lands between the brackets
|
||||
replaceRangeAndMoveCaret(range, [partCreator.plain("[" + range.text + "]" + "()")], -1);
|
||||
}
|
||||
}
|
||||
|
||||
// parts helper methods
|
||||
|
@ -162,7 +279,7 @@ export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix
|
|||
parts[index - 1].text.endsWith(suffix);
|
||||
|
||||
if (isFormatted) {
|
||||
// remove prefix and suffix
|
||||
// remove prefix and suffix formatting string
|
||||
const partWithoutPrefix = parts[base].serialize();
|
||||
partWithoutPrefix.text = partWithoutPrefix.text.substr(prefix.length);
|
||||
parts[base] = partCreator.deserializePart(partWithoutPrefix);
|
||||
|
@ -178,5 +295,13 @@ export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix
|
|||
}
|
||||
});
|
||||
|
||||
replaceRangeAndExpandSelection(range, parts);
|
||||
// If the user didn't select something initially, we want to just restore
|
||||
// the caret position instead of making a new selection.
|
||||
if (range.wasInitializedEmpty() && prefix === suffix) {
|
||||
// Check if we need to add a offset for a toggle or untoggle
|
||||
const hasFormatting = range.text.startsWith(prefix) && range.text.endsWith(suffix);
|
||||
replaceRangeAndAutoAdjustCaret(range, parts, hasFormatting, prefix.length);
|
||||
} else {
|
||||
replaceRangeAndExpandSelection(range, parts);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue