excalidraw/src/element/textWysiwyg.tsx

259 lines
7.7 KiB
TypeScript
Raw Normal View History

import { KEYS } from "../keys";
import { isWritableElement, getFontString } from "../utils";
import Scene from "../scene/Scene";
import { isTextElement } from "./typeChecks";
import { CLASSES } from "../constants";
import { ExcalidrawElement } from "./types";
import { AppState } from "../types";
const normalizeText = (text: string) => {
return (
text
// replace tabs with spaces so they render and measure correctly
.replace(/\t/g, " ")
// normalize newlines
.replace(/\r?\n|\r/g, "\n")
);
};
const getTransform = (
width: number,
height: number,
angle: number,
appState: AppState,
maxWidth: number,
) => {
const { zoom, offsetTop, offsetLeft } = appState;
const degree = (180 * angle) / Math.PI;
// offsets must be multiplied by 2 to account for the division by 2 of
// the whole expression afterwards
let translateX = ((width - offsetLeft * 2) * (zoom.value - 1)) / 2;
const translateY = ((height - offsetTop * 2) * (zoom.value - 1)) / 2;
if (width > maxWidth && zoom.value !== 1) {
translateX = (maxWidth / 2) * (zoom.value - 1);
}
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
};
export const textWysiwyg = ({
id,
appState,
onChange,
2020-01-24 12:04:54 +02:00
onSubmit,
getViewportCoords,
element,
canvas,
}: {
id: ExcalidrawElement["id"];
appState: AppState;
onChange?: (text: string) => void;
onSubmit: (data: { text: string; viaKeyboard: boolean }) => void;
getViewportCoords: (x: number, y: number) => [number, number];
element: ExcalidrawElement;
canvas: HTMLCanvasElement | null;
}) => {
2020-11-06 22:06:39 +02:00
const updateWysiwygStyle = () => {
const updatedElement = Scene.getScene(element)?.getElement(id);
if (updatedElement && isTextElement(updatedElement)) {
const [viewportX, viewportY] = getViewportCoords(
updatedElement.x,
updatedElement.y,
);
const { textAlign, angle } = updatedElement;
editable.value = updatedElement.text;
const lines = updatedElement.text.replace(/\r\n?/g, "\n").split("\n");
const lineHeight = updatedElement.height / lines.length;
const maxWidth =
(appState.offsetLeft + appState.width - viewportX - 8) /
appState.zoom.value -
// margin-right of parent if any
Number(
getComputedStyle(
document.querySelector(".excalidraw")!.parentNode as Element,
).marginRight.slice(0, -2),
);
Object.assign(editable.style, {
font: getFontString(updatedElement),
// must be defined *after* font ¯\_(ツ)_/¯
lineHeight: `${lineHeight}px`,
width: `${updatedElement.width}px`,
height: `${updatedElement.height}px`,
left: `${viewportX}px`,
top: `${viewportY}px`,
transform: getTransform(
updatedElement.width,
updatedElement.height,
angle,
appState,
maxWidth,
),
textAlign,
color: updatedElement.strokeColor,
opacity: updatedElement.opacity / 100,
filter: "var(--theme-filter)",
maxWidth: `${maxWidth}px`,
});
}
2020-11-06 22:06:39 +02:00
};
const editable = document.createElement("textarea");
editable.dir = "auto";
editable.tabIndex = 0;
editable.dataset.type = "wysiwyg";
// prevent line wrapping on Safari
editable.wrap = "off";
Rotation support (#1099) * rotate rectanble with fixed angle * rotate dashed rectangle with fixed angle * fix rotate handler rect * fix canvas size with rotation * angle in element base * fix bug in calculating canvas size * trial only for rectangle * hitTest for rectangle rotation * properly resize rotated rectangle * fix canvas size calculation * giving up... workaround for now * **experimental** handler to rotate rectangle * remove rotation on copy for debugging * update snapshots * better rotation handler with atan2 * rotate when drawImage * add rotation handler * hitTest for any shapes * fix hitTest for curved lines * rotate text element * rotation locking * hint messaage for rotating * show proper handlers on mobile (a workaround, there should be a better way) * refactor hitTest * support exporting png * support exporting svg * fix rotating curved line * refactor drawElementFromCanvas with getElementAbsoluteCoords * fix export png and svg * adjust resize positions for lines (N, E, S, W) * do not make handlers big on mobile * Update src/locales/en.json Alright! Co-Authored-By: Lipis <lipiridis@gmail.com> * do not show rotation/resizing hints on mobile * proper calculation for N and W positions * simplify calculation * use "rotation" as property name for clarification (may increase bundle size) * update snapshots excluding rotation handle * refactor with adjustPositionWithRotation * refactor with adjustXYWithRotation * forgot to rename rotation * rename internal function * initialize element angle on restore * rotate wysiwyg editor * fix shift-rotate around 270deg * improve rotation locking * refactor adjustXYWithRotation * avoid rotation degree becomes >=360 * refactor with generateHandler Co-authored-by: Lipis <lipiridis@gmail.com> Co-authored-by: dwelle <luzar.david@gmail.com>
2020-04-02 17:40:26 +09:00
Object.assign(editable.style, {
position: "absolute",
display: "inline-block",
2020-01-24 12:04:54 +02:00
minHeight: "1em",
backfaceVisibility: "hidden",
margin: 0,
padding: 0,
border: 0,
outline: 0,
resize: "none",
background: "transparent",
overflow: "hidden",
// prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
whiteSpace: "pre",
// must be specified because in dark mode canvas creates a stacking context
zIndex: "var(--zIndex-wysiwyg)",
});
updateWysiwygStyle();
if (onChange) {
editable.oninput = () => {
onChange(normalizeText(editable.value));
};
}
2020-04-04 11:41:54 +03:00
editable.onkeydown = (event) => {
if (event.key === KEYS.ESCAPE) {
event.preventDefault();
submittedViaKeyboard = true;
handleSubmit();
2020-04-08 20:56:27 +02:00
} else if (event.key === KEYS.ENTER && event[KEYS.CTRL_OR_CMD]) {
2020-04-04 11:41:54 +03:00
event.preventDefault();
if (event.isComposing || event.keyCode === 229) {
return;
}
submittedViaKeyboard = true;
handleSubmit();
2020-04-08 20:56:27 +02:00
} else if (event.key === KEYS.ENTER && !event.altKey) {
2020-04-04 11:41:54 +03:00
event.stopPropagation();
}
};
const stopEvent = (event: Event) => {
event.preventDefault();
2020-04-04 11:41:54 +03:00
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 = () => {
onSubmit({
text: normalizeText(editable.value),
viaKeyboard: submittedViaKeyboard,
});
cleanup();
};
const cleanup = () => {
if (isDestroyed) {
return;
}
isDestroyed = true;
2020-04-05 22:31:59 +02:00
// remove events to ensure they don't late-fire
editable.onblur = null;
2020-04-05 22:31:59 +02:00
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);
unbindUpdate();
editable.remove();
};
const bindBlurEvent = () => {
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.
setTimeout(() => {
editable.onblur = handleSubmit;
// case: clicking on the same property → no change → no update → no focus
editable.focus();
});
};
// prevent blur when changing properties from the menu
const onPointerDown = (event: MouseEvent) => {
if (
(event.target instanceof HTMLElement ||
event.target instanceof SVGElement) &&
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
!isWritableElement(event.target)
) {
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);
}
};
// handle updates of textElement properties of editing element
const unbindUpdate = Scene.getScene(element)!.addCallback(() => {
updateWysiwygStyle();
editable.focus();
});
// ---------------------------------------------------------------------------
let isDestroyed = false;
// 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);
}
window.addEventListener("pointerdown", onPointerDown);
window.addEventListener("wheel", stopEvent, {
passive: false,
capture: true,
});
document
.querySelector(".excalidraw-textEditorContainer")!
.appendChild(editable);
};