e424ca53c6
On dark mode, while text element is being edited, it was changing color to the light mode variant (white turns back to black, etc...). This is caused by the --appearance-filter not being applied to the floating textarea. The css var --appearance-filter is only applied to the .excalidraw class while the floating textarea is being appended to the body which put it outside of .excalidraw. This change adds excalidraw class to the floating textarea and also adds Appearance_dark to it while dark mode is on.
213 lines
6.0 KiB
TypeScript
213 lines
6.0 KiB
TypeScript
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,
|
|
) => {
|
|
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
|
|
return `translate(${((width - offsetLeft * 2) * (zoom - 1)) / 2}px, ${
|
|
((height - offsetTop * 2) * (zoom - 1)) / 2
|
|
}px) scale(${zoom}) rotate(${degree}deg)`;
|
|
};
|
|
|
|
export const textWysiwyg = ({
|
|
id,
|
|
appState,
|
|
onChange,
|
|
onSubmit,
|
|
getViewportCoords,
|
|
element,
|
|
}: {
|
|
id: ExcalidrawElement["id"];
|
|
appState: AppState;
|
|
onChange?: (text: string) => void;
|
|
onSubmit: (text: string) => void;
|
|
getViewportCoords: (x: number, y: number) => [number, number];
|
|
element: ExcalidrawElement;
|
|
}) => {
|
|
function 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;
|
|
|
|
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,
|
|
),
|
|
textAlign: textAlign,
|
|
color: updatedElement.strokeColor,
|
|
opacity: updatedElement.opacity / 100,
|
|
filter: "var(--appearance-filter)",
|
|
});
|
|
}
|
|
}
|
|
|
|
const editable = document.createElement("textarea");
|
|
|
|
editable.dir = "auto";
|
|
editable.tabIndex = 0;
|
|
editable.dataset.type = "wysiwyg";
|
|
// prevent line wrapping on Safari
|
|
editable.wrap = "off";
|
|
editable.className = `excalidraw ${
|
|
appState.appearance === "dark" ? "Appearance_dark" : ""
|
|
}`;
|
|
|
|
Object.assign(editable.style, {
|
|
position: "fixed",
|
|
display: "inline-block",
|
|
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",
|
|
});
|
|
|
|
updateWysiwygStyle();
|
|
|
|
if (onChange) {
|
|
editable.oninput = () => {
|
|
onChange(normalizeText(editable.value));
|
|
};
|
|
}
|
|
|
|
editable.onkeydown = (event) => {
|
|
if (event.key === KEYS.ESCAPE) {
|
|
event.preventDefault();
|
|
handleSubmit();
|
|
} else if (event.key === KEYS.ENTER && event[KEYS.CTRL_OR_CMD]) {
|
|
event.preventDefault();
|
|
if (event.isComposing || event.keyCode === 229) {
|
|
return;
|
|
}
|
|
handleSubmit();
|
|
} else if (event.key === KEYS.ENTER && !event.altKey) {
|
|
event.stopPropagation();
|
|
}
|
|
};
|
|
|
|
const stopEvent = (event: Event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
};
|
|
|
|
const handleSubmit = () => {
|
|
onSubmit(normalizeText(editable.value));
|
|
cleanup();
|
|
};
|
|
|
|
const cleanup = () => {
|
|
if (isDestroyed) {
|
|
return;
|
|
}
|
|
isDestroyed = true;
|
|
// remove events to ensure they don't late-fire
|
|
editable.onblur = null;
|
|
editable.oninput = null;
|
|
editable.onkeydown = null;
|
|
|
|
window.removeEventListener("resize", updateWysiwygStyle);
|
|
window.removeEventListener("wheel", stopEvent, true);
|
|
window.removeEventListener("pointerdown", onPointerDown);
|
|
window.removeEventListener("pointerup", rebindBlur);
|
|
window.removeEventListener("blur", handleSubmit);
|
|
|
|
unbindUpdate();
|
|
|
|
document.body.removeChild(editable);
|
|
};
|
|
|
|
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 &&
|
|
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
|
|
!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
|
|
const unbindUpdate = Scene.getScene(element)!.addCallback(() => {
|
|
updateWysiwygStyle();
|
|
editable.focus();
|
|
});
|
|
|
|
let isDestroyed = false;
|
|
|
|
editable.onblur = handleSubmit;
|
|
// reposition wysiwyg in case of window resize. Happens on mobile when
|
|
// device keyboard is opened.
|
|
window.addEventListener("resize", updateWysiwygStyle);
|
|
window.addEventListener("pointerdown", onPointerDown);
|
|
window.addEventListener("wheel", stopEvent, {
|
|
passive: false,
|
|
capture: true,
|
|
});
|
|
document.body.appendChild(editable);
|
|
editable.focus();
|
|
editable.select();
|
|
};
|