aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/element/textWysiwyg.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/element/textWysiwyg.tsx')
-rw-r--r--packages/excalidraw/element/textWysiwyg.tsx730
1 files changed, 730 insertions, 0 deletions
diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx
new file mode 100644
index 0000000..a757086
--- /dev/null
+++ b/packages/excalidraw/element/textWysiwyg.tsx
@@ -0,0 +1,730 @@
+import { CODES, KEYS } from "../keys";
+import {
+ isWritableElement,
+ getFontString,
+ getFontFamilyString,
+ isTestEnv,
+} from "../utils";
+import Scene from "../scene/Scene";
+import {
+ isArrowElement,
+ isBoundToContainer,
+ isTextElement,
+} from "./typeChecks";
+import { CLASSES, POINTER_BUTTON } from "../constants";
+import type {
+ ExcalidrawElement,
+ ExcalidrawLinearElement,
+ ExcalidrawTextElementWithContainer,
+ ExcalidrawTextElement,
+} from "./types";
+import type { AppState } from "../types";
+import { bumpVersion, mutateElement } from "./mutateElement";
+import {
+ getBoundTextElementId,
+ getContainerElement,
+ getTextElementAngle,
+ redrawTextBoundingBox,
+ getBoundTextMaxHeight,
+ getBoundTextMaxWidth,
+ computeContainerDimensionForBoundText,
+ computeBoundTextPosition,
+ getBoundTextElement,
+} from "./textElement";
+import { wrapText } from "./textWrapping";
+import {
+ actionDecreaseFontSize,
+ actionIncreaseFontSize,
+} from "../actions/actionProperties";
+import {
+ actionResetZoom,
+ actionZoomIn,
+ actionZoomOut,
+} from "../actions/actionCanvas";
+import type App from "../components/App";
+import { LinearElementEditor } from "./linearElementEditor";
+import { parseClipboard } from "../clipboard";
+import {
+ originalContainerCache,
+ updateOriginalContainerCache,
+} from "./containerCache";
+import { getTextWidth } from "./textMeasurements";
+import { normalizeText } from "./textMeasurements";
+
+const getTransform = (
+ width: number,
+ height: number,
+ angle: number,
+ appState: AppState,
+ maxWidth: number,
+ maxHeight: number,
+) => {
+ const { zoom } = appState;
+ const degree = (180 * angle) / Math.PI;
+ let translateX = (width * (zoom.value - 1)) / 2;
+ let translateY = (height * (zoom.value - 1)) / 2;
+ if (width > maxWidth && zoom.value !== 1) {
+ translateX = (maxWidth * (zoom.value - 1)) / 2;
+ }
+ if (height > maxHeight && zoom.value !== 1) {
+ translateY = (maxHeight * (zoom.value - 1)) / 2;
+ }
+ return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
+};
+
+export const textWysiwyg = ({
+ id,
+ onChange,
+ onSubmit,
+ getViewportCoords,
+ element,
+ canvas,
+ excalidrawContainer,
+ app,
+ autoSelect = true,
+}: {
+ id: ExcalidrawElement["id"];
+ /**
+ * textWysiwyg only deals with `originalText`
+ *
+ * Note: `text`, which can be wrapped and therefore different from `originalText`,
+ * is derived from `originalText`
+ */
+ onChange?: (nextOriginalText: string) => void;
+ onSubmit: (data: { viaKeyboard: boolean; nextOriginalText: string }) => void;
+ getViewportCoords: (x: number, y: number) => [number, number];
+ element: ExcalidrawTextElement;
+ canvas: HTMLCanvasElement;
+ excalidrawContainer: HTMLDivElement | null;
+ app: App;
+ autoSelect?: boolean;
+}) => {
+ const textPropertiesUpdated = (
+ updatedTextElement: ExcalidrawTextElement,
+ editable: HTMLTextAreaElement,
+ ) => {
+ if (!editable.style.fontFamily || !editable.style.fontSize) {
+ return false;
+ }
+ const currentFont = editable.style.fontFamily.replace(/"/g, "");
+ if (
+ getFontFamilyString({ fontFamily: updatedTextElement.fontFamily }) !==
+ currentFont
+ ) {
+ return true;
+ }
+ if (`${updatedTextElement.fontSize}px` !== editable.style.fontSize) {
+ return true;
+ }
+ return false;
+ };
+
+ const updateWysiwygStyle = () => {
+ const appState = app.state;
+ const updatedTextElement =
+ Scene.getScene(element)?.getElement<ExcalidrawTextElement>(id);
+
+ if (!updatedTextElement) {
+ return;
+ }
+ const { textAlign, verticalAlign } = updatedTextElement;
+ const elementsMap = app.scene.getNonDeletedElementsMap();
+ if (updatedTextElement && isTextElement(updatedTextElement)) {
+ let coordX = updatedTextElement.x;
+ let coordY = updatedTextElement.y;
+ const container = getContainerElement(
+ updatedTextElement,
+ app.scene.getNonDeletedElementsMap(),
+ );
+
+ let width = updatedTextElement.width;
+
+ // set to element height by default since that's
+ // what is going to be used for unbounded text
+ let height = updatedTextElement.height;
+
+ let maxWidth = updatedTextElement.width;
+ let maxHeight = updatedTextElement.height;
+
+ if (container && updatedTextElement.containerId) {
+ if (isArrowElement(container)) {
+ const boundTextCoords =
+ LinearElementEditor.getBoundTextElementPosition(
+ container,
+ updatedTextElement as ExcalidrawTextElementWithContainer,
+ elementsMap,
+ );
+ coordX = boundTextCoords.x;
+ coordY = boundTextCoords.y;
+ }
+ const propertiesUpdated = textPropertiesUpdated(
+ updatedTextElement,
+ editable,
+ );
+
+ let originalContainerData;
+ if (propertiesUpdated) {
+ originalContainerData = updateOriginalContainerCache(
+ container.id,
+ container.height,
+ );
+ } else {
+ originalContainerData = originalContainerCache[container.id];
+ if (!originalContainerData) {
+ originalContainerData = updateOriginalContainerCache(
+ container.id,
+ container.height,
+ );
+ }
+ }
+
+ maxWidth = getBoundTextMaxWidth(container, updatedTextElement);
+
+ maxHeight = getBoundTextMaxHeight(
+ container,
+ updatedTextElement as ExcalidrawTextElementWithContainer,
+ );
+
+ // autogrow container height if text exceeds
+ if (!isArrowElement(container) && height > maxHeight) {
+ const targetContainerHeight = computeContainerDimensionForBoundText(
+ height,
+ container.type,
+ );
+
+ mutateElement(container, { height: targetContainerHeight });
+ return;
+ } else if (
+ // autoshrink container height until original container height
+ // is reached when text is removed
+ !isArrowElement(container) &&
+ container.height > originalContainerData.height &&
+ height < maxHeight
+ ) {
+ const targetContainerHeight = computeContainerDimensionForBoundText(
+ height,
+ container.type,
+ );
+ mutateElement(container, { height: targetContainerHeight });
+ } else {
+ const { y } = computeBoundTextPosition(
+ container,
+ updatedTextElement as ExcalidrawTextElementWithContainer,
+ elementsMap,
+ );
+ coordY = y;
+ }
+ }
+ const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
+ const initialSelectionStart = editable.selectionStart;
+ const initialSelectionEnd = editable.selectionEnd;
+ const initialLength = editable.value.length;
+
+ // restore cursor position after value updated so it doesn't
+ // go to the end of text when container auto expanded
+ if (
+ initialSelectionStart === initialSelectionEnd &&
+ initialSelectionEnd !== initialLength
+ ) {
+ // get diff between length and selection end and shift
+ // the cursor by "diff" times to position correctly
+ const diff = initialLength - initialSelectionEnd;
+ editable.selectionStart = editable.value.length - diff;
+ editable.selectionEnd = editable.value.length - diff;
+ }
+
+ if (!container) {
+ maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
+ width = Math.min(width, maxWidth);
+ } else {
+ width += 0.5;
+ }
+
+ // add 5% buffer otherwise it causes wysiwyg to jump
+ height *= 1.05;
+
+ const font = getFontString(updatedTextElement);
+
+ // Make sure text editor height doesn't go beyond viewport
+ const editorMaxHeight =
+ (appState.height - viewportY) / appState.zoom.value;
+ Object.assign(editable.style, {
+ font,
+ // must be defined *after* font ¯\_(ツ)_/¯
+ lineHeight: updatedTextElement.lineHeight,
+ width: `${width}px`,
+ height: `${height}px`,
+ left: `${viewportX}px`,
+ top: `${viewportY}px`,
+ transform: getTransform(
+ width,
+ height,
+ getTextElementAngle(updatedTextElement, container),
+ appState,
+ maxWidth,
+ editorMaxHeight,
+ ),
+ textAlign,
+ verticalAlign,
+ color: updatedTextElement.strokeColor,
+ opacity: updatedTextElement.opacity / 100,
+ filter: "var(--theme-filter)",
+ maxHeight: `${editorMaxHeight}px`,
+ });
+ editable.scrollTop = 0;
+ // For some reason updating font attribute doesn't set font family
+ // hence updating font family explicitly for test environment
+ if (isTestEnv()) {
+ editable.style.fontFamily = getFontFamilyString(updatedTextElement);
+ }
+
+ mutateElement(updatedTextElement, { x: coordX, y: coordY });
+ }
+ };
+
+ const editable = document.createElement("textarea");
+
+ editable.dir = "auto";
+ editable.tabIndex = 0;
+ editable.dataset.type = "wysiwyg";
+ // prevent line wrapping on Safari
+ editable.wrap = "off";
+ editable.classList.add("excalidraw-wysiwyg");
+
+ let whiteSpace = "pre";
+ let wordBreak = "normal";
+
+ if (isBoundToContainer(element) || !element.autoResize) {
+ whiteSpace = "pre-wrap";
+ wordBreak = "break-word";
+ }
+ Object.assign(editable.style, {
+ position: "absolute",
+ display: "inline-block",
+ minHeight: "1em",
+ backfaceVisibility: "hidden",
+ margin: 0,
+ padding: 0,
+ border: 0,
+ outline: 0,
+ resize: "none",
+ background: "transparent",
+ overflow: "hidden",
+ // must be specified because in dark mode canvas creates a stacking context
+ zIndex: "var(--zIndex-wysiwyg)",
+ wordBreak,
+ // prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
+ whiteSpace,
+ overflowWrap: "break-word",
+ boxSizing: "content-box",
+ });
+ editable.value = element.originalText;
+ updateWysiwygStyle();
+
+ if (onChange) {
+ editable.onpaste = async (event) => {
+ const clipboardData = await parseClipboard(event, true);
+ if (!clipboardData.text) {
+ return;
+ }
+ const data = normalizeText(clipboardData.text);
+ if (!data) {
+ return;
+ }
+ const container = getContainerElement(
+ element,
+ app.scene.getNonDeletedElementsMap(),
+ );
+
+ const font = getFontString({
+ fontSize: app.state.currentItemFontSize,
+ fontFamily: app.state.currentItemFontFamily,
+ });
+ if (container) {
+ const boundTextElement = getBoundTextElement(
+ container,
+ app.scene.getNonDeletedElementsMap(),
+ );
+ const wrappedText = wrapText(
+ `${editable.value}${data}`,
+ font,
+ getBoundTextMaxWidth(container, boundTextElement),
+ );
+ const width = getTextWidth(wrappedText, font);
+ editable.style.width = `${width}px`;
+ }
+ };
+
+ editable.oninput = () => {
+ const normalized = normalizeText(editable.value);
+ if (editable.value !== normalized) {
+ const selectionStart = editable.selectionStart;
+ editable.value = normalized;
+ // put the cursor at some position close to where it was before
+ // normalization (otherwise it'll end up at the end of the text)
+ editable.selectionStart = selectionStart;
+ editable.selectionEnd = selectionStart;
+ }
+ onChange(editable.value);
+ };
+ }
+
+ editable.onkeydown = (event) => {
+ if (!event.shiftKey && actionZoomIn.keyTest(event)) {
+ event.preventDefault();
+ app.actionManager.executeAction(actionZoomIn);
+ updateWysiwygStyle();
+ } else if (!event.shiftKey && actionZoomOut.keyTest(event)) {
+ event.preventDefault();
+ app.actionManager.executeAction(actionZoomOut);
+ updateWysiwygStyle();
+ } else if (!event.shiftKey && actionResetZoom.keyTest(event)) {
+ event.preventDefault();
+ app.actionManager.executeAction(actionResetZoom);
+ updateWysiwygStyle();
+ } else if (actionDecreaseFontSize.keyTest(event)) {
+ app.actionManager.executeAction(actionDecreaseFontSize);
+ } else if (actionIncreaseFontSize.keyTest(event)) {
+ app.actionManager.executeAction(actionIncreaseFontSize);
+ } else if (event.key === KEYS.ESCAPE) {
+ event.preventDefault();
+ submittedViaKeyboard = true;
+ handleSubmit();
+ } else if (event.key === KEYS.ENTER && event[KEYS.CTRL_OR_CMD]) {
+ event.preventDefault();
+ if (event.isComposing || event.keyCode === 229) {
+ return;
+ }
+ submittedViaKeyboard = true;
+ handleSubmit();
+ } 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.isComposing) {
+ return;
+ } else 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: number) => {
+ 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 indices 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) => {
+ if (event.target instanceof HTMLCanvasElement) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ };
+
+ // using a state variable instead of passing it to the handleSubmit callback
+ // so that we don't need to create separate a callback for event handlers
+ let submittedViaKeyboard = false;
+ const handleSubmit = () => {
+ // prevent double submit
+ if (isDestroyed) {
+ return;
+ }
+ isDestroyed = true;
+ // cleanup must be run before onSubmit otherwise when app blurs the wysiwyg
+ // it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the
+ // wysiwyg on update
+ cleanup();
+ const updateElement = Scene.getScene(element)?.getElement(
+ element.id,
+ ) as ExcalidrawTextElement;
+ if (!updateElement) {
+ return;
+ }
+ const container = getContainerElement(
+ updateElement,
+ app.scene.getNonDeletedElementsMap(),
+ );
+
+ if (container) {
+ if (editable.value.trim()) {
+ const boundTextElementId = getBoundTextElementId(container);
+ if (!boundTextElementId || boundTextElementId !== element.id) {
+ mutateElement(container, {
+ boundElements: (container.boundElements || []).concat({
+ type: "text",
+ id: element.id,
+ }),
+ });
+ } else if (isArrowElement(container)) {
+ // updating an arrow label may change bounds, prevent stale cache:
+ bumpVersion(container);
+ }
+ } else {
+ mutateElement(container, {
+ boundElements: container.boundElements?.filter(
+ (ele) =>
+ !isTextElement(
+ ele as ExcalidrawTextElement | ExcalidrawLinearElement,
+ ),
+ ),
+ });
+ }
+ redrawTextBoundingBox(
+ updateElement,
+ container,
+ app.scene.getNonDeletedElementsMap(),
+ );
+ }
+
+ onSubmit({
+ viaKeyboard: submittedViaKeyboard,
+ nextOriginalText: editable.value,
+ });
+ };
+
+ const cleanup = () => {
+ // remove events to ensure they don't late-fire
+ editable.onblur = null;
+ editable.oninput = null;
+ editable.onkeydown = null;
+
+ if (observer) {
+ observer.disconnect();
+ }
+
+ window.removeEventListener("resize", updateWysiwygStyle);
+ window.removeEventListener("wheel", stopEvent, true);
+ window.removeEventListener("pointerdown", onPointerDown);
+ window.removeEventListener("pointerup", bindBlurEvent);
+ window.removeEventListener("blur", handleSubmit);
+ window.removeEventListener("beforeunload", handleSubmit);
+ unbindUpdate();
+ unbindOnScroll();
+
+ editable.remove();
+ };
+
+ const bindBlurEvent = (event?: MouseEvent) => {
+ window.removeEventListener("pointerup", bindBlurEvent);
+ // Deferred so that the pointerdown that initiates the wysiwyg doesn't
+ // trigger the blur on ensuing pointerup.
+ // Also to handle cases such as picking a color which would trigger a blur
+ // in that same tick.
+ const target = event?.target;
+
+ const isPropertiesTrigger =
+ target instanceof HTMLElement &&
+ target.classList.contains("properties-trigger");
+
+ setTimeout(() => {
+ editable.onblur = handleSubmit;
+
+ // case: clicking on the same property → no change → no update → no focus
+ if (!isPropertiesTrigger) {
+ editable.focus();
+ }
+ });
+ };
+
+ const temporarilyDisableSubmit = () => {
+ editable.onblur = null;
+ window.addEventListener("pointerup", bindBlurEvent);
+ // handle edge-case where pointerup doesn't fire e.g. due to user
+ // alt-tabbing away
+ window.addEventListener("blur", handleSubmit);
+ };
+
+ // prevent blur when changing properties from the menu
+ const onPointerDown = (event: MouseEvent) => {
+ const target = event?.target;
+
+ // panning canvas
+ if (event.button === POINTER_BUTTON.WHEEL) {
+ // trying to pan by clicking inside text area itself -> handle here
+ if (target instanceof HTMLTextAreaElement) {
+ event.preventDefault();
+ app.handleCanvasPanUsingWheelOrSpaceDrag(event);
+ }
+ temporarilyDisableSubmit();
+ return;
+ }
+
+ const isPropertiesTrigger =
+ target instanceof HTMLElement &&
+ target.classList.contains("properties-trigger");
+
+ if (
+ ((event.target instanceof HTMLElement ||
+ event.target instanceof SVGElement) &&
+ event.target.closest(
+ `.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`,
+ ) &&
+ !isWritableElement(event.target)) ||
+ isPropertiesTrigger
+ ) {
+ temporarilyDisableSubmit();
+ } else if (
+ event.target instanceof HTMLCanvasElement &&
+ // Vitest simply ignores stopPropagation, capture-mode, or rAF
+ // so without introducing crazier hacks, nothing we can do
+ !isTestEnv()
+ ) {
+ // On mobile, blur event doesn't seem to always fire correctly,
+ // so we want to also submit on pointerdown outside the wysiwyg.
+ // Done in the next frame to prevent pointerdown from creating a new text
+ // immediately (if tools locked) so that users on mobile have chance
+ // to submit first (to hide virtual keyboard).
+ // Note: revisit if we want to differ this behavior on Desktop
+ requestAnimationFrame(() => {
+ handleSubmit();
+ });
+ }
+ };
+
+ // handle updates of textElement properties of editing element
+ const unbindUpdate = app.scene.onUpdate(() => {
+ updateWysiwygStyle();
+ const isPopupOpened = !!document.activeElement?.closest(
+ ".properties-content",
+ );
+ if (!isPopupOpened) {
+ editable.focus();
+ }
+ });
+
+ const unbindOnScroll = app.onScrollChangeEmitter.on(() => {
+ updateWysiwygStyle();
+ });
+
+ // ---------------------------------------------------------------------------
+
+ let isDestroyed = false;
+
+ if (autoSelect) {
+ // select on init (focusing is done separately inside the bindBlurEvent()
+ // because we need it to happen *after* the blur event from `pointerdown`)
+ editable.select();
+ }
+ bindBlurEvent();
+
+ // reposition wysiwyg in case of canvas is resized. Using ResizeObserver
+ // is preferred so we catch changes from host, where window may not resize.
+ let observer: ResizeObserver | null = null;
+ if (canvas && "ResizeObserver" in window) {
+ observer = new window.ResizeObserver(() => {
+ updateWysiwygStyle();
+ });
+ observer.observe(canvas);
+ } else {
+ window.addEventListener("resize", updateWysiwygStyle);
+ }
+
+ editable.onpointerdown = (event) => event.stopPropagation();
+
+ // rAF (+ capture to by doubly sure) so we don't catch te pointerdown that
+ // triggered the wysiwyg
+ requestAnimationFrame(() => {
+ window.addEventListener("pointerdown", onPointerDown, { capture: true });
+ });
+ window.addEventListener("beforeunload", handleSubmit);
+ excalidrawContainer
+ ?.querySelector(".excalidraw-textEditorContainer")!
+ .appendChild(editable);
+};