diff options
Diffstat (limited to 'packages/excalidraw/element/textWysiwyg.tsx')
| -rw-r--r-- | packages/excalidraw/element/textWysiwyg.tsx | 730 |
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); +}; |
