feat: support tab in text Wyswig (#3411)
* fix: support tab in text Wyswig * Refactor tab handling Tab now indent the whole line, instead of inserting at the cursor position. Shift+Tab now deindent the whole line. * Add multi-line tabulation support * rename * simplify algo for selected lines start indices & naming tweaks * add cmd-bracket shortcuts as alias to indent/outdent * support outdenting partial tabs Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
parent
d5a270f643
commit
e0a449aa40
169
src/element/textWysiwyg.test.tsx
Normal file
169
src/element/textWysiwyg.test.tsx
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import ReactDOM from "react-dom";
|
||||||
|
import ExcalidrawApp from "../excalidraw-app";
|
||||||
|
import { render } from "../tests/test-utils";
|
||||||
|
import { Pointer, UI } from "../tests/helpers/ui";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
|
|
||||||
|
// Unmount ReactDOM from root
|
||||||
|
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||||
|
|
||||||
|
const tab = " ";
|
||||||
|
|
||||||
|
describe("textWysiwyg", () => {
|
||||||
|
let textarea: HTMLTextAreaElement;
|
||||||
|
beforeEach(async () => {
|
||||||
|
await render(<ExcalidrawApp />);
|
||||||
|
|
||||||
|
const element = UI.createElement("text");
|
||||||
|
|
||||||
|
new Pointer("mouse").clickOn(element);
|
||||||
|
textarea = document.querySelector(
|
||||||
|
".excalidraw-textEditorContainer > textarea",
|
||||||
|
)!;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add a tab at the start of the first line", () => {
|
||||||
|
const event = new KeyboardEvent("keydown", { key: KEYS.TAB });
|
||||||
|
textarea.value = "Line#1\nLine#2";
|
||||||
|
// cursor: "|Line#1\nLine#2"
|
||||||
|
textarea.selectionStart = 0;
|
||||||
|
textarea.selectionEnd = 0;
|
||||||
|
textarea.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(textarea.value).toEqual(`${tab}Line#1\nLine#2`);
|
||||||
|
// cursor: " |Line#1\nLine#2"
|
||||||
|
expect(textarea.selectionStart).toEqual(4);
|
||||||
|
expect(textarea.selectionEnd).toEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add a tab at the start of the second line", () => {
|
||||||
|
const event = new KeyboardEvent("keydown", { key: KEYS.TAB });
|
||||||
|
textarea.value = "Line#1\nLine#2";
|
||||||
|
// cursor: "Line#1\nLin|e#2"
|
||||||
|
textarea.selectionStart = 10;
|
||||||
|
textarea.selectionEnd = 10;
|
||||||
|
|
||||||
|
textarea.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(textarea.value).toEqual(`Line#1\n${tab}Line#2`);
|
||||||
|
|
||||||
|
// cursor: "Line#1\n Lin|e#2"
|
||||||
|
expect(textarea.selectionStart).toEqual(14);
|
||||||
|
expect(textarea.selectionEnd).toEqual(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add a tab at the start of the first and second line", () => {
|
||||||
|
const event = new KeyboardEvent("keydown", { key: KEYS.TAB });
|
||||||
|
textarea.value = "Line#1\nLine#2\nLine#3";
|
||||||
|
// cursor: "Li|ne#1\nLi|ne#2\nLine#3"
|
||||||
|
textarea.selectionStart = 2;
|
||||||
|
textarea.selectionEnd = 9;
|
||||||
|
|
||||||
|
textarea.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(textarea.value).toEqual(`${tab}Line#1\n${tab}Line#2\nLine#3`);
|
||||||
|
|
||||||
|
// cursor: " Li|ne#1\n Li|ne#2\nLine#3"
|
||||||
|
expect(textarea.selectionStart).toEqual(6);
|
||||||
|
expect(textarea.selectionEnd).toEqual(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove a tab at the start of the first line", () => {
|
||||||
|
const event = new KeyboardEvent("keydown", {
|
||||||
|
key: KEYS.TAB,
|
||||||
|
shiftKey: true,
|
||||||
|
});
|
||||||
|
textarea.value = `${tab}Line#1\nLine#2`;
|
||||||
|
// cursor: "| Line#1\nLine#2"
|
||||||
|
textarea.selectionStart = 0;
|
||||||
|
textarea.selectionEnd = 0;
|
||||||
|
|
||||||
|
textarea.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(textarea.value).toEqual(`Line#1\nLine#2`);
|
||||||
|
|
||||||
|
// cursor: "|Line#1\nLine#2"
|
||||||
|
expect(textarea.selectionStart).toEqual(0);
|
||||||
|
expect(textarea.selectionEnd).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove a tab at the start of the second line", () => {
|
||||||
|
const event = new KeyboardEvent("keydown", {
|
||||||
|
key: KEYS.TAB,
|
||||||
|
shiftKey: true,
|
||||||
|
});
|
||||||
|
// cursor: "Line#1\n Lin|e#2"
|
||||||
|
textarea.value = `Line#1\n${tab}Line#2`;
|
||||||
|
textarea.selectionStart = 15;
|
||||||
|
textarea.selectionEnd = 15;
|
||||||
|
|
||||||
|
textarea.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(textarea.value).toEqual(`Line#1\nLine#2`);
|
||||||
|
// cursor: "Line#1\nLin|e#2"
|
||||||
|
expect(textarea.selectionStart).toEqual(11);
|
||||||
|
expect(textarea.selectionEnd).toEqual(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove a tab at the start of the first and second line", () => {
|
||||||
|
const event = new KeyboardEvent("keydown", {
|
||||||
|
key: KEYS.TAB,
|
||||||
|
shiftKey: true,
|
||||||
|
});
|
||||||
|
// cursor: " Li|ne#1\n Li|ne#2\nLine#3"
|
||||||
|
textarea.value = `${tab}Line#1\n${tab}Line#2\nLine#3`;
|
||||||
|
textarea.selectionStart = 6;
|
||||||
|
textarea.selectionEnd = 17;
|
||||||
|
|
||||||
|
textarea.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(textarea.value).toEqual(`Line#1\nLine#2\nLine#3`);
|
||||||
|
// cursor: "Li|ne#1\nLi|ne#2\nLine#3"
|
||||||
|
expect(textarea.selectionStart).toEqual(2);
|
||||||
|
expect(textarea.selectionEnd).toEqual(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove a tab at the start of the second line and cursor stay on this line", () => {
|
||||||
|
const event = new KeyboardEvent("keydown", {
|
||||||
|
key: KEYS.TAB,
|
||||||
|
shiftKey: true,
|
||||||
|
});
|
||||||
|
// cursor: "Line#1\n | Line#2"
|
||||||
|
textarea.value = `Line#1\n${tab}Line#2`;
|
||||||
|
textarea.selectionStart = 9;
|
||||||
|
textarea.selectionEnd = 9;
|
||||||
|
textarea.dispatchEvent(event);
|
||||||
|
|
||||||
|
// cursor: "Line#1\n|Line#2"
|
||||||
|
expect(textarea.selectionStart).toEqual(7);
|
||||||
|
// expect(textarea.selectionEnd).toEqual(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove partial tabs", () => {
|
||||||
|
const event = new KeyboardEvent("keydown", {
|
||||||
|
key: KEYS.TAB,
|
||||||
|
shiftKey: true,
|
||||||
|
});
|
||||||
|
// cursor: "Line#1\n Line#|2"
|
||||||
|
textarea.value = `Line#1\n Line#2`;
|
||||||
|
textarea.selectionStart = 15;
|
||||||
|
textarea.selectionEnd = 15;
|
||||||
|
textarea.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(textarea.value).toEqual(`Line#1\nLine#2`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove nothing", () => {
|
||||||
|
const event = new KeyboardEvent("keydown", {
|
||||||
|
key: KEYS.TAB,
|
||||||
|
shiftKey: true,
|
||||||
|
});
|
||||||
|
// cursor: "Line#1\n Li|ne#2"
|
||||||
|
textarea.value = `Line#1\nLine#2`;
|
||||||
|
textarea.selectionStart = 9;
|
||||||
|
textarea.selectionEnd = 9;
|
||||||
|
textarea.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(textarea.value).toEqual(`Line#1\nLine#2`);
|
||||||
|
});
|
||||||
|
});
|
@ -1,4 +1,4 @@
|
|||||||
import { KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { isWritableElement, getFontString } from "../utils";
|
import { isWritableElement, getFontString } from "../utils";
|
||||||
import Scene from "../scene/Scene";
|
import Scene from "../scene/Scene";
|
||||||
import { isTextElement } from "./typeChecks";
|
import { isTextElement } from "./typeChecks";
|
||||||
@ -134,6 +134,7 @@ export const textWysiwyg = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
editable.onkeydown = (event) => {
|
editable.onkeydown = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
if (event.key === KEYS.ESCAPE) {
|
if (event.key === KEYS.ESCAPE) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
submittedViaKeyboard = true;
|
submittedViaKeyboard = true;
|
||||||
@ -145,11 +146,118 @@ export const textWysiwyg = ({
|
|||||||
}
|
}
|
||||||
submittedViaKeyboard = true;
|
submittedViaKeyboard = true;
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
} else if (event.key === KEYS.ENTER && !event.altKey) {
|
} else if (
|
||||||
event.stopPropagation();
|
event.key === KEYS.TAB ||
|
||||||
|
(event[KEYS.CTRL_OR_CMD] &&
|
||||||
|
(event.code === CODES.BRACKET_LEFT ||
|
||||||
|
event.code === CODES.BRACKET_RIGHT))
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.shiftKey || event.code === CODES.BRACKET_LEFT) {
|
||||||
|
outdent();
|
||||||
|
} else {
|
||||||
|
indent();
|
||||||
|
}
|
||||||
|
// We must send an input event to resize the element
|
||||||
|
editable.dispatchEvent(new Event("input"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TAB_SIZE = 4;
|
||||||
|
const TAB = " ".repeat(TAB_SIZE);
|
||||||
|
const RE_LEADING_TAB = new RegExp(`^ {1,${TAB_SIZE}}`);
|
||||||
|
const indent = () => {
|
||||||
|
const { selectionStart, selectionEnd } = editable;
|
||||||
|
const linesStartIndices = getSelectedLinesStartIndices();
|
||||||
|
|
||||||
|
let value = editable.value;
|
||||||
|
linesStartIndices.forEach((startIndex) => {
|
||||||
|
const startValue = value.slice(0, startIndex);
|
||||||
|
const endValue = value.slice(startIndex);
|
||||||
|
|
||||||
|
value = `${startValue}${TAB}${endValue}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
editable.value = value;
|
||||||
|
|
||||||
|
editable.selectionStart = selectionStart + TAB_SIZE;
|
||||||
|
editable.selectionEnd = selectionEnd + TAB_SIZE * linesStartIndices.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const outdent = () => {
|
||||||
|
const { selectionStart, selectionEnd } = editable;
|
||||||
|
const linesStartIndices = getSelectedLinesStartIndices();
|
||||||
|
const removedTabs: number[] = [];
|
||||||
|
|
||||||
|
let value = editable.value;
|
||||||
|
linesStartIndices.forEach((startIndex) => {
|
||||||
|
const tabMatch = value
|
||||||
|
.slice(startIndex, startIndex + TAB_SIZE)
|
||||||
|
.match(RE_LEADING_TAB);
|
||||||
|
|
||||||
|
if (tabMatch) {
|
||||||
|
const startValue = value.slice(0, startIndex);
|
||||||
|
const endValue = value.slice(startIndex + tabMatch[0].length);
|
||||||
|
|
||||||
|
// Delete a tab from the line
|
||||||
|
value = `${startValue}${endValue}`;
|
||||||
|
removedTabs.push(startIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
editable.value = value;
|
||||||
|
|
||||||
|
if (removedTabs.length) {
|
||||||
|
if (selectionStart > removedTabs[removedTabs.length - 1]) {
|
||||||
|
editable.selectionStart = Math.max(
|
||||||
|
selectionStart - TAB_SIZE,
|
||||||
|
removedTabs[removedTabs.length - 1],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// If the cursor is before the first tab removed, ex:
|
||||||
|
// Line| #1
|
||||||
|
// Line #2
|
||||||
|
// Lin|e #3
|
||||||
|
// we should reset the selectionStart to his initial value.
|
||||||
|
editable.selectionStart = selectionStart;
|
||||||
|
}
|
||||||
|
editable.selectionEnd = Math.max(
|
||||||
|
editable.selectionStart,
|
||||||
|
selectionEnd - TAB_SIZE * removedTabs.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns indeces of start positions of selected lines, in reverse order
|
||||||
|
*/
|
||||||
|
const getSelectedLinesStartIndices = () => {
|
||||||
|
let { selectionStart, selectionEnd, value } = editable;
|
||||||
|
|
||||||
|
// chars before selectionStart on the same line
|
||||||
|
const startOffset = value.slice(0, selectionStart).match(/[^\n]*$/)![0]
|
||||||
|
.length;
|
||||||
|
// put caret at the start of the line
|
||||||
|
selectionStart = selectionStart - startOffset;
|
||||||
|
|
||||||
|
const selected = value.slice(selectionStart, selectionEnd);
|
||||||
|
|
||||||
|
return selected
|
||||||
|
.split("\n")
|
||||||
|
.reduce(
|
||||||
|
(startIndices, line, idx, lines) =>
|
||||||
|
startIndices.concat(
|
||||||
|
idx
|
||||||
|
? // curr line index is prev line's start + prev line's length + \n
|
||||||
|
startIndices[idx - 1] + lines[idx - 1].length + 1
|
||||||
|
: // first selected line
|
||||||
|
selectionStart,
|
||||||
|
),
|
||||||
|
[] as number[],
|
||||||
|
)
|
||||||
|
.reverse();
|
||||||
|
};
|
||||||
|
|
||||||
const stopEvent = (event: Event) => {
|
const stopEvent = (event: Event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
Loading…
x
Reference in New Issue
Block a user