2020-01-09 02:00:59 +04:00
|
|
|
import { KEYS } from "../keys";
|
2020-06-25 21:21:27 +02:00
|
|
|
import { isWritableElement, getFontString } from "../utils";
|
2020-07-30 14:50:59 +05:30
|
|
|
import Scene from "../scene/Scene";
|
2020-04-12 15:57:57 +02:00
|
|
|
import { isTextElement } from "./typeChecks";
|
|
|
|
import { CLASSES } from "../constants";
|
2020-06-25 21:21:27 +02:00
|
|
|
import { ExcalidrawElement } from "./types";
|
2020-07-27 17:18:49 +05:30
|
|
|
import { AppState } from "../types";
|
2020-06-25 21:21:27 +02:00
|
|
|
|
|
|
|
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")
|
|
|
|
);
|
2020-05-20 16:21:37 +03:00
|
|
|
};
|
2020-03-18 13:01:33 +01:00
|
|
|
|
2020-06-25 21:21:27 +02:00
|
|
|
const getTransform = (
|
|
|
|
width: number,
|
|
|
|
height: number,
|
|
|
|
angle: number,
|
2020-07-27 17:18:49 +05:30
|
|
|
appState: AppState,
|
2020-06-25 21:21:27 +02:00
|
|
|
) => {
|
2020-07-27 17:18:49 +05:30
|
|
|
const { zoom, offsetTop, offsetLeft } = appState;
|
2020-06-25 21:21:27 +02:00
|
|
|
const degree = (180 * angle) / Math.PI;
|
2020-07-27 17:18:49 +05:30
|
|
|
// offsets must be multiplied by 2 to account for the division by 2 of
|
|
|
|
// the whole expression afterwards
|
|
|
|
return `translate(${((width - offsetLeft * 2) * (zoom - 1)) / 2}px, ${
|
|
|
|
((height - offsetTop * 2) * (zoom - 1)) / 2
|
2020-06-25 21:21:27 +02:00
|
|
|
}px) scale(${zoom}) rotate(${degree}deg)`;
|
2020-01-07 22:21:05 +05:00
|
|
|
};
|
|
|
|
|
2020-05-20 16:21:37 +03:00
|
|
|
export const textWysiwyg = ({
|
2020-04-12 15:57:57 +02:00
|
|
|
id,
|
2020-07-27 17:18:49 +05:30
|
|
|
appState,
|
2020-04-03 14:16:14 +02:00
|
|
|
onChange,
|
2020-01-24 12:04:54 +02:00
|
|
|
onSubmit,
|
2020-06-25 21:21:27 +02:00
|
|
|
getViewportCoords,
|
2020-07-30 14:50:59 +05:30
|
|
|
element,
|
2020-06-25 21:21:27 +02:00
|
|
|
}: {
|
|
|
|
id: ExcalidrawElement["id"];
|
2020-07-27 17:18:49 +05:30
|
|
|
appState: AppState;
|
2020-06-25 21:21:27 +02:00
|
|
|
onChange?: (text: string) => void;
|
|
|
|
onSubmit: (text: string) => void;
|
|
|
|
getViewportCoords: (x: number, y: number) => [number, number];
|
2020-07-30 14:50:59 +05:30
|
|
|
element: ExcalidrawElement;
|
2020-06-25 21:21:27 +02:00
|
|
|
}) => {
|
|
|
|
function updateWysiwygStyle() {
|
2020-07-30 14:50:59 +05:30
|
|
|
const updatedElement = Scene.getScene(element)?.getElement(id);
|
|
|
|
if (updatedElement && isTextElement(updatedElement)) {
|
2020-06-25 21:21:27 +02:00
|
|
|
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;
|
|
|
|
|
|
|
|
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,
|
2020-07-27 17:18:49 +05:30
|
|
|
appState,
|
2020-06-25 21:21:27 +02:00
|
|
|
),
|
|
|
|
textAlign: textAlign,
|
|
|
|
color: updatedElement.strokeColor,
|
|
|
|
opacity: updatedElement.opacity / 100,
|
|
|
|
});
|
|
|
|
}
|
2020-02-21 22:51:34 -05:00
|
|
|
}
|
2020-06-25 21:21:27 +02:00
|
|
|
|
|
|
|
const editable = document.createElement("textarea");
|
|
|
|
|
2020-04-02 12:21:19 -04:00
|
|
|
editable.dir = "auto";
|
2020-01-09 02:04:53 +05:00
|
|
|
editable.tabIndex = 0;
|
|
|
|
editable.dataset.type = "wysiwyg";
|
2020-06-25 21:21:27 +02:00
|
|
|
// prevent line wrapping on Safari
|
|
|
|
editable.wrap = "off";
|
2020-04-02 17:40:26 +09:00
|
|
|
|
2020-01-09 02:04:53 +05:00
|
|
|
Object.assign(editable.style, {
|
2020-02-01 06:07:08 +00:00
|
|
|
position: "fixed",
|
2020-01-09 02:04:53 +05:00
|
|
|
display: "inline-block",
|
2020-01-24 12:04:54 +02:00
|
|
|
minHeight: "1em",
|
2020-02-07 18:42:24 +01:00
|
|
|
backfaceVisibility: "hidden",
|
2020-06-25 21:21:27 +02:00
|
|
|
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",
|
2020-01-07 22:21:05 +05:00
|
|
|
});
|
|
|
|
|
2020-06-25 21:21:27 +02:00
|
|
|
updateWysiwygStyle();
|
2020-02-07 18:42:24 +01:00
|
|
|
|
2020-04-03 14:16:14 +02:00
|
|
|
if (onChange) {
|
|
|
|
editable.oninput = () => {
|
2020-06-25 21:21:27 +02:00
|
|
|
onChange(normalizeText(editable.value));
|
2020-04-03 14:16:14 +02:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2020-04-04 11:41:54 +03:00
|
|
|
editable.onkeydown = (event) => {
|
|
|
|
if (event.key === KEYS.ESCAPE) {
|
|
|
|
event.preventDefault();
|
2020-03-12 18:04:56 +01:00
|
|
|
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) {
|
2020-04-03 13:41:32 +01:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
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();
|
2020-01-07 22:21:05 +05:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2020-05-20 16:21:37 +03:00
|
|
|
const stopEvent = (event: Event) => {
|
2020-04-04 11:41:54 +03:00
|
|
|
event.stopPropagation();
|
2020-05-20 16:21:37 +03:00
|
|
|
};
|
2020-01-07 22:21:05 +05:00
|
|
|
|
2020-05-20 16:21:37 +03:00
|
|
|
const handleSubmit = () => {
|
2020-07-14 14:56:45 +03:00
|
|
|
onSubmit(normalizeText(editable.value));
|
2020-01-07 22:21:05 +05:00
|
|
|
cleanup();
|
2020-05-20 16:21:37 +03:00
|
|
|
};
|
2020-01-07 22:21:05 +05:00
|
|
|
|
2020-05-20 16:21:37 +03:00
|
|
|
const cleanup = () => {
|
2020-04-12 15:57:57 +02:00
|
|
|
if (isDestroyed) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
isDestroyed = true;
|
2020-04-05 22:31:59 +02:00
|
|
|
// remove events to ensure they don't late-fire
|
2020-04-12 15:57:57 +02:00
|
|
|
editable.onblur = null;
|
2020-04-05 22:31:59 +02:00
|
|
|
editable.oninput = null;
|
|
|
|
editable.onkeydown = null;
|
|
|
|
|
2020-06-25 21:21:27 +02:00
|
|
|
window.removeEventListener("resize", updateWysiwygStyle);
|
2020-01-07 22:21:05 +05:00
|
|
|
window.removeEventListener("wheel", stopEvent, true);
|
2020-04-12 15:57:57 +02:00
|
|
|
window.removeEventListener("pointerdown", onPointerDown);
|
|
|
|
window.removeEventListener("pointerup", rebindBlur);
|
|
|
|
window.removeEventListener("blur", handleSubmit);
|
|
|
|
|
|
|
|
unbindUpdate();
|
|
|
|
|
2020-01-09 02:04:53 +05:00
|
|
|
document.body.removeChild(editable);
|
2020-05-20 16:21:37 +03:00
|
|
|
};
|
2020-01-07 22:21:05 +05:00
|
|
|
|
2020-04-12 15:57:57 +02:00
|
|
|
const rebindBlur = () => {
|
|
|
|
window.removeEventListener("pointerup", rebindBlur);
|
|
|
|
// deferred to guard against focus traps on various UIs that steal focus
|
|
|
|
// upon pointerUp
|
|
|
|
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 &&
|
2020-04-13 17:29:11 +02:00
|
|
|
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
|
2020-04-12 15:57:57 +02:00
|
|
|
!isWritableElement(event.target)
|
|
|
|
) {
|
|
|
|
editable.onblur = null;
|
|
|
|
window.addEventListener("pointerup", rebindBlur);
|
|
|
|
// 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
|
2020-07-30 14:50:59 +05:30
|
|
|
const unbindUpdate = Scene.getScene(element)!.addCallback(() => {
|
2020-06-25 21:21:27 +02:00
|
|
|
updateWysiwygStyle();
|
2020-04-12 15:57:57 +02:00
|
|
|
editable.focus();
|
|
|
|
});
|
|
|
|
|
|
|
|
let isDestroyed = false;
|
|
|
|
|
|
|
|
editable.onblur = handleSubmit;
|
2020-06-25 21:21:27 +02:00
|
|
|
// reposition wysiwyg in case of window resize. Happens on mobile when
|
|
|
|
// device keyboard is opened.
|
|
|
|
window.addEventListener("resize", updateWysiwygStyle);
|
2020-04-12 15:57:57 +02:00
|
|
|
window.addEventListener("pointerdown", onPointerDown);
|
2020-01-07 22:21:05 +05:00
|
|
|
window.addEventListener("wheel", stopEvent, true);
|
2020-01-09 02:04:53 +05:00
|
|
|
document.body.appendChild(editable);
|
|
|
|
editable.focus();
|
2020-06-25 21:21:27 +02:00
|
|
|
editable.select();
|
2020-05-20 16:21:37 +03:00
|
|
|
};
|