Skip to content

Commit

Permalink
feat: support tab in text Wyswig (#3411)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
johnrazeur and dwelle committed Apr 13, 2021
1 parent d5a270f commit e0a449a
Show file tree
Hide file tree
Showing 2 changed files with 280 additions and 3 deletions.
169 changes: 169 additions & 0 deletions src/element/textWysiwyg.test.tsx
@@ -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`);
});
});
114 changes: 111 additions & 3 deletions src/element/textWysiwyg.tsx
@@ -1,4 +1,4 @@
import { KEYS } from "../keys";
import { CODES, KEYS } from "../keys";
import { isWritableElement, getFontString } from "../utils";
import Scene from "../scene/Scene";
import { isTextElement } from "./typeChecks";
Expand Down Expand Up @@ -134,6 +134,7 @@ export const textWysiwyg = ({
}

editable.onkeydown = (event) => {
event.stopPropagation();
if (event.key === KEYS.ESCAPE) {
event.preventDefault();
submittedViaKeyboard = true;
Expand All @@ -145,11 +146,118 @@ export const textWysiwyg = ({
}
submittedViaKeyboard = true;
handleSubmit();
} else if (event.key === KEYS.ENTER && !event.altKey) {
event.stopPropagation();
} else if (
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) => {
event.preventDefault();
event.stopPropagation();
Expand Down

1 comment on commit e0a449a

@vercel
Copy link

@vercel vercel bot commented on e0a449a Apr 13, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.