From e0a449aa401c2a53b9f34e56f6812de3db48b569 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Lafont?= Date: Tue, 13 Apr 2021 16:23:46 +0200 Subject: [PATCH] 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 --- src/element/textWysiwyg.test.tsx | 169 +++++++++++++++++++++++++++++++ src/element/textWysiwyg.tsx | 114 ++++++++++++++++++++- 2 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 src/element/textWysiwyg.test.tsx diff --git a/src/element/textWysiwyg.test.tsx b/src/element/textWysiwyg.test.tsx new file mode 100644 index 00000000..f98cb8a5 --- /dev/null +++ b/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(); + + 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`); + }); +}); diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index 1329e13b..40d1c1fc 100644 --- a/src/element/textWysiwyg.tsx +++ b/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();