Browse Source

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>
Clément Lafont 4 years ago
parent
commit
e0a449aa40
2 changed files with 280 additions and 3 deletions
  1. 169 0
      src/element/textWysiwyg.test.tsx
  2. 111 3
      src/element/textWysiwyg.tsx

+ 169 - 0
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`);
+  });
+});

+ 111 - 3
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";
@@ -134,6 +134,7 @@ export const textWysiwyg = ({
   }
 
   editable.onkeydown = (event) => {
+    event.stopPropagation();
     if (event.key === KEYS.ESCAPE) {
       event.preventDefault();
       submittedViaKeyboard = true;
@@ -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();