You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
709 lines
22 KiB
TypeScript
709 lines
22 KiB
TypeScript
4 years ago
|
import { CODES, KEYS } from "../keys";
|
||
3 years ago
|
import {
|
||
|
isWritableElement,
|
||
|
getFontString,
|
||
|
getFontFamilyString,
|
||
3 years ago
|
isTestEnv,
|
||
3 years ago
|
} from "../utils";
|
||
5 years ago
|
import Scene from "../scene/Scene";
|
||
2 years ago
|
import {
|
||
|
isArrowElement,
|
||
|
isBoundToContainer,
|
||
|
isTextElement,
|
||
|
} from "./typeChecks";
|
||
2 years ago
|
import { CLASSES, isSafari } from "../constants";
|
||
3 years ago
|
import {
|
||
|
ExcalidrawElement,
|
||
|
ExcalidrawLinearElement,
|
||
2 years ago
|
ExcalidrawTextElementWithContainer,
|
||
2 years ago
|
ExcalidrawTextElement,
|
||
2 years ago
|
ExcalidrawTextContainer,
|
||
3 years ago
|
} from "./types";
|
||
5 years ago
|
import { AppState } from "../types";
|
||
1 year ago
|
import { bumpVersion, mutateElement } from "./mutateElement";
|
||
3 years ago
|
import {
|
||
|
getBoundTextElementId,
|
||
3 years ago
|
getContainerElement,
|
||
2 years ago
|
getTextElementAngle,
|
||
2 years ago
|
getTextWidth,
|
||
2 years ago
|
normalizeText,
|
||
2 years ago
|
redrawTextBoundingBox,
|
||
3 years ago
|
wrapText,
|
||
2 years ago
|
getBoundTextMaxHeight,
|
||
|
getBoundTextMaxWidth,
|
||
2 years ago
|
computeContainerDimensionForBoundText,
|
||
2 years ago
|
detectLineHeight,
|
||
2 years ago
|
computeBoundTextPosition,
|
||
3 years ago
|
} from "./textElement";
|
||
3 years ago
|
import {
|
||
|
actionDecreaseFontSize,
|
||
|
actionIncreaseFontSize,
|
||
|
} from "../actions/actionProperties";
|
||
3 years ago
|
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
|
||
3 years ago
|
import App from "../components/App";
|
||
2 years ago
|
import { LinearElementEditor } from "./linearElementEditor";
|
||
2 years ago
|
import { parseClipboard } from "../clipboard";
|
||
5 years ago
|
|
||
|
const getTransform = (
|
||
|
width: number,
|
||
|
height: number,
|
||
|
angle: number,
|
||
5 years ago
|
appState: AppState,
|
||
4 years ago
|
maxWidth: number,
|
||
3 years ago
|
maxHeight: number,
|
||
5 years ago
|
) => {
|
||
3 years ago
|
const { zoom } = appState;
|
||
5 years ago
|
const degree = (180 * angle) / Math.PI;
|
||
3 years ago
|
let translateX = (width * (zoom.value - 1)) / 2;
|
||
|
let translateY = (height * (zoom.value - 1)) / 2;
|
||
4 years ago
|
if (width > maxWidth && zoom.value !== 1) {
|
||
3 years ago
|
translateX = (maxWidth * (zoom.value - 1)) / 2;
|
||
4 years ago
|
}
|
||
3 years ago
|
if (height > maxHeight && zoom.value !== 1) {
|
||
3 years ago
|
translateY = (maxHeight * (zoom.value - 1)) / 2;
|
||
3 years ago
|
}
|
||
4 years ago
|
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
|
||
5 years ago
|
};
|
||
|
|
||
2 years ago
|
const originalContainerCache: {
|
||
|
[id: ExcalidrawTextContainer["id"]]:
|
||
|
| {
|
||
|
height: ExcalidrawTextContainer["height"];
|
||
|
}
|
||
|
| undefined;
|
||
|
} = {};
|
||
|
|
||
|
export const updateOriginalContainerCache = (
|
||
|
id: ExcalidrawTextContainer["id"],
|
||
|
height: ExcalidrawTextContainer["height"],
|
||
|
) => {
|
||
|
const data =
|
||
|
originalContainerCache[id] || (originalContainerCache[id] = { height });
|
||
|
data.height = height;
|
||
|
return data;
|
||
|
};
|
||
|
|
||
|
export const resetOriginalContainerCache = (
|
||
|
id: ExcalidrawTextContainer["id"],
|
||
|
) => {
|
||
|
if (originalContainerCache[id]) {
|
||
|
delete originalContainerCache[id];
|
||
|
}
|
||
|
};
|
||
|
|
||
|
export const getOriginalContainerHeightFromCache = (
|
||
|
id: ExcalidrawTextContainer["id"],
|
||
|
) => {
|
||
|
return originalContainerCache[id]?.height ?? null;
|
||
|
};
|
||
|
|
||
5 years ago
|
export const textWysiwyg = ({
|
||
5 years ago
|
id,
|
||
5 years ago
|
onChange,
|
||
5 years ago
|
onSubmit,
|
||
5 years ago
|
getViewportCoords,
|
||
5 years ago
|
element,
|
||
4 years ago
|
canvas,
|
||
4 years ago
|
excalidrawContainer,
|
||
3 years ago
|
app,
|
||
5 years ago
|
}: {
|
||
|
id: ExcalidrawElement["id"];
|
||
|
onChange?: (text: string) => void;
|
||
3 years ago
|
onSubmit: (data: {
|
||
|
text: string;
|
||
|
viaKeyboard: boolean;
|
||
|
originalText: string;
|
||
|
}) => void;
|
||
5 years ago
|
getViewportCoords: (x: number, y: number) => [number, number];
|
||
3 years ago
|
element: ExcalidrawTextElement;
|
||
1 year ago
|
canvas: HTMLCanvasElement;
|
||
4 years ago
|
excalidrawContainer: HTMLDivElement | null;
|
||
3 years ago
|
app: App;
|
||
5 years ago
|
}) => {
|
||
3 years ago
|
const textPropertiesUpdated = (
|
||
2 years ago
|
updatedTextElement: ExcalidrawTextElement,
|
||
3 years ago
|
editable: HTMLTextAreaElement,
|
||
|
) => {
|
||
2 years ago
|
if (!editable.style.fontFamily || !editable.style.fontSize) {
|
||
|
return false;
|
||
|
}
|
||
3 years ago
|
const currentFont = editable.style.fontFamily.replace(/"/g, "");
|
||
3 years ago
|
if (
|
||
2 years ago
|
getFontFamilyString({ fontFamily: updatedTextElement.fontFamily }) !==
|
||
3 years ago
|
currentFont
|
||
|
) {
|
||
|
return true;
|
||
|
}
|
||
2 years ago
|
if (`${updatedTextElement.fontSize}px` !== editable.style.fontSize) {
|
||
3 years ago
|
return true;
|
||
|
}
|
||
|
return false;
|
||
|
};
|
||
|
|
||
4 years ago
|
const updateWysiwygStyle = () => {
|
||
3 years ago
|
const appState = app.state;
|
||
2 years ago
|
const updatedTextElement =
|
||
3 years ago
|
Scene.getScene(element)?.getElement<ExcalidrawTextElement>(id);
|
||
2 years ago
|
|
||
2 years ago
|
if (!updatedTextElement) {
|
||
3 years ago
|
return;
|
||
|
}
|
||
2 years ago
|
const { textAlign, verticalAlign } = updatedTextElement;
|
||
2 years ago
|
|
||
2 years ago
|
if (updatedTextElement && isTextElement(updatedTextElement)) {
|
||
2 years ago
|
let coordX = updatedTextElement.x;
|
||
2 years ago
|
let coordY = updatedTextElement.y;
|
||
|
const container = getContainerElement(updatedTextElement);
|
||
|
let maxWidth = updatedTextElement.width;
|
||
3 years ago
|
|
||
2 years ago
|
let maxHeight = updatedTextElement.height;
|
||
2 years ago
|
let textElementWidth = updatedTextElement.width;
|
||
3 years ago
|
// Set to element height by default since that's
|
||
3 years ago
|
// what is going to be used for unbounded text
|
||
2 years ago
|
const textElementHeight = updatedTextElement.height;
|
||
2 years ago
|
|
||
2 years ago
|
if (container && updatedTextElement.containerId) {
|
||
2 years ago
|
if (isArrowElement(container)) {
|
||
|
const boundTextCoords =
|
||
|
LinearElementEditor.getBoundTextElementPosition(
|
||
|
container,
|
||
|
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||
|
);
|
||
|
coordX = boundTextCoords.x;
|
||
|
coordY = boundTextCoords.y;
|
||
|
}
|
||
3 years ago
|
const propertiesUpdated = textPropertiesUpdated(
|
||
2 years ago
|
updatedTextElement,
|
||
3 years ago
|
editable,
|
||
4 years ago
|
);
|
||
2 years ago
|
|
||
|
let originalContainerData;
|
||
|
if (propertiesUpdated) {
|
||
|
originalContainerData = updateOriginalContainerCache(
|
||
|
container.id,
|
||
2 years ago
|
container.height,
|
||
2 years ago
|
);
|
||
|
} else {
|
||
|
originalContainerData = originalContainerCache[container.id];
|
||
|
if (!originalContainerData) {
|
||
|
originalContainerData = updateOriginalContainerCache(
|
||
|
container.id,
|
||
2 years ago
|
container.height,
|
||
2 years ago
|
);
|
||
|
}
|
||
3 years ago
|
}
|
||
2 years ago
|
|
||
2 years ago
|
maxWidth = getBoundTextMaxWidth(container);
|
||
|
maxHeight = getBoundTextMaxHeight(
|
||
|
container,
|
||
|
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||
|
);
|
||
2 years ago
|
|
||
3 years ago
|
// autogrow container height if text exceeds
|
||
2 years ago
|
if (!isArrowElement(container) && textElementHeight > maxHeight) {
|
||
2 years ago
|
const targetContainerHeight = computeContainerDimensionForBoundText(
|
||
|
textElementHeight,
|
||
|
container.type,
|
||
2 years ago
|
);
|
||
2 years ago
|
|
||
|
mutateElement(container, { height: targetContainerHeight });
|
||
3 years ago
|
return;
|
||
|
} else if (
|
||
|
// autoshrink container height until original container height
|
||
|
// is reached when text is removed
|
||
2 years ago
|
!isArrowElement(container) &&
|
||
2 years ago
|
container.height > originalContainerData.height &&
|
||
2 years ago
|
textElementHeight < maxHeight
|
||
3 years ago
|
) {
|
||
2 years ago
|
const targetContainerHeight = computeContainerDimensionForBoundText(
|
||
|
textElementHeight,
|
||
|
container.type,
|
||
2 years ago
|
);
|
||
2 years ago
|
mutateElement(container, { height: targetContainerHeight });
|
||
2 years ago
|
} else {
|
||
|
const { y } = computeBoundTextPosition(
|
||
|
container,
|
||
|
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||
|
);
|
||
|
coordY = y;
|
||
3 years ago
|
}
|
||
|
}
|
||
|
const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
|
||
3 years ago
|
const initialSelectionStart = editable.selectionStart;
|
||
|
const initialSelectionEnd = editable.selectionEnd;
|
||
|
const initialLength = editable.value.length;
|
||
|
|
||
3 years ago
|
// restore cursor position after value updated so it doesn't
|
||
3 years ago
|
// 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;
|
||
|
}
|
||
|
|
||
3 years ago
|
if (!container) {
|
||
3 years ago
|
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
|
||
2 years ago
|
textElementWidth = Math.min(textElementWidth, maxWidth);
|
||
2 years ago
|
} else {
|
||
2 years ago
|
textElementWidth += 0.5;
|
||
|
}
|
||
2 years ago
|
|
||
|
let lineHeight = updatedTextElement.lineHeight;
|
||
|
|
||
|
// In Safari the font size gets rounded off when rendering hence calculating the line height by rounding off font size
|
||
|
if (isSafari) {
|
||
|
lineHeight = detectLineHeight({
|
||
|
...updatedTextElement,
|
||
|
fontSize: Math.round(updatedTextElement.fontSize),
|
||
|
});
|
||
|
}
|
||
|
|
||
3 years ago
|
// Make sure text editor height doesn't go beyond viewport
|
||
|
const editorMaxHeight =
|
||
3 years ago
|
(appState.height - viewportY) / appState.zoom.value;
|
||
5 years ago
|
Object.assign(editable.style, {
|
||
2 years ago
|
font: getFontString(updatedTextElement),
|
||
5 years ago
|
// must be defined *after* font ¯\_(ツ)_/¯
|
||
2 years ago
|
lineHeight,
|
||
2 years ago
|
width: `${textElementWidth}px`,
|
||
2 years ago
|
height: `${textElementHeight}px`,
|
||
5 years ago
|
left: `${viewportX}px`,
|
||
|
top: `${viewportY}px`,
|
||
3 years ago
|
transform: getTransform(
|
||
2 years ago
|
textElementWidth,
|
||
2 years ago
|
textElementHeight,
|
||
2 years ago
|
getTextElementAngle(updatedTextElement),
|
||
3 years ago
|
appState,
|
||
|
maxWidth,
|
||
|
editorMaxHeight,
|
||
|
),
|
||
4 years ago
|
textAlign,
|
||
3 years ago
|
verticalAlign,
|
||
2 years ago
|
color: updatedTextElement.strokeColor,
|
||
|
opacity: updatedTextElement.opacity / 100,
|
||
4 years ago
|
filter: "var(--theme-filter)",
|
||
3 years ago
|
maxHeight: `${editorMaxHeight}px`,
|
||
5 years ago
|
});
|
||
2 years ago
|
editable.scrollTop = 0;
|
||
3 years ago
|
// For some reason updating font attribute doesn't set font family
|
||
|
// hence updating font family explicitly for test environment
|
||
|
if (isTestEnv()) {
|
||
2 years ago
|
editable.style.fontFamily = getFontFamilyString(updatedTextElement);
|
||
3 years ago
|
}
|
||
2 years ago
|
mutateElement(updatedTextElement, { x: coordX, y: coordY });
|
||
5 years ago
|
}
|
||
4 years ago
|
};
|
||
5 years ago
|
|
||
|
const editable = document.createElement("textarea");
|
||
|
|
||
5 years ago
|
editable.dir = "auto";
|
||
5 years ago
|
editable.tabIndex = 0;
|
||
|
editable.dataset.type = "wysiwyg";
|
||
5 years ago
|
// prevent line wrapping on Safari
|
||
|
editable.wrap = "off";
|
||
3 years ago
|
editable.classList.add("excalidraw-wysiwyg");
|
||
5 years ago
|
|
||
3 years ago
|
let whiteSpace = "pre";
|
||
3 years ago
|
let wordBreak = "normal";
|
||
|
|
||
|
if (isBoundToContainer(element)) {
|
||
|
whiteSpace = "pre-wrap";
|
||
|
wordBreak = "break-word";
|
||
3 years ago
|
}
|
||
5 years ago
|
Object.assign(editable.style, {
|
||
4 years ago
|
position: "absolute",
|
||
5 years ago
|
display: "inline-block",
|
||
5 years ago
|
minHeight: "1em",
|
||
5 years ago
|
backfaceVisibility: "hidden",
|
||
5 years ago
|
margin: 0,
|
||
|
padding: 0,
|
||
|
border: 0,
|
||
|
outline: 0,
|
||
|
resize: "none",
|
||
2 years ago
|
background: "transparent",
|
||
5 years ago
|
overflow: "hidden",
|
||
4 years ago
|
// must be specified because in dark mode canvas creates a stacking context
|
||
|
zIndex: "var(--zIndex-wysiwyg)",
|
||
3 years ago
|
wordBreak,
|
||
3 years ago
|
// prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
|
||
|
whiteSpace,
|
||
|
overflowWrap: "break-word",
|
||
2 years ago
|
boxSizing: "content-box",
|
||
5 years ago
|
});
|
||
2 years ago
|
editable.value = element.originalText;
|
||
5 years ago
|
updateWysiwygStyle();
|
||
5 years ago
|
|
||
5 years ago
|
if (onChange) {
|
||
2 years ago
|
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);
|
||
|
|
||
|
const font = getFontString({
|
||
|
fontSize: app.state.currentItemFontSize,
|
||
|
fontFamily: app.state.currentItemFontFamily,
|
||
|
});
|
||
|
if (container) {
|
||
|
const wrappedText = wrapText(
|
||
|
`${editable.value}${data}`,
|
||
|
font,
|
||
2 years ago
|
getBoundTextMaxWidth(container),
|
||
2 years ago
|
);
|
||
|
const width = getTextWidth(wrappedText, font);
|
||
|
editable.style.width = `${width}px`;
|
||
|
}
|
||
|
};
|
||
|
|
||
5 years ago
|
editable.oninput = () => {
|
||
5 years ago
|
onChange(normalizeText(editable.value));
|
||
5 years ago
|
};
|
||
|
}
|
||
|
|
||
5 years ago
|
editable.onkeydown = (event) => {
|
||
3 years ago
|
if (!event.shiftKey && actionZoomIn.keyTest(event)) {
|
||
3 years ago
|
event.preventDefault();
|
||
|
app.actionManager.executeAction(actionZoomIn);
|
||
|
updateWysiwygStyle();
|
||
3 years ago
|
} else if (!event.shiftKey && actionZoomOut.keyTest(event)) {
|
||
3 years ago
|
event.preventDefault();
|
||
|
app.actionManager.executeAction(actionZoomOut);
|
||
|
updateWysiwygStyle();
|
||
|
} else if (actionDecreaseFontSize.keyTest(event)) {
|
||
3 years ago
|
app.actionManager.executeAction(actionDecreaseFontSize);
|
||
|
} else if (actionIncreaseFontSize.keyTest(event)) {
|
||
|
app.actionManager.executeAction(actionIncreaseFontSize);
|
||
|
} else if (event.key === KEYS.ESCAPE) {
|
||
5 years ago
|
event.preventDefault();
|
||
4 years ago
|
submittedViaKeyboard = true;
|
||
5 years ago
|
handleSubmit();
|
||
5 years ago
|
} else if (event.key === KEYS.ENTER && event[KEYS.CTRL_OR_CMD]) {
|
||
5 years ago
|
event.preventDefault();
|
||
|
if (event.isComposing || event.keyCode === 229) {
|
||
5 years ago
|
return;
|
||
|
}
|
||
4 years ago
|
submittedViaKeyboard = true;
|
||
5 years ago
|
handleSubmit();
|
||
4 years ago
|
} else if (
|
||
|
event.key === KEYS.TAB ||
|
||
|
(event[KEYS.CTRL_OR_CMD] &&
|
||
|
(event.code === CODES.BRACKET_LEFT ||
|
||
|
event.code === CODES.BRACKET_RIGHT))
|
||
|
) {
|
||
|
event.preventDefault();
|
||
2 years ago
|
if (event.isComposing) {
|
||
|
return;
|
||
|
} else if (event.shiftKey || event.code === CODES.BRACKET_LEFT) {
|
||
4 years ago
|
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;
|
||
3 years ago
|
linesStartIndices.forEach((startIndex: number) => {
|
||
4 years ago
|
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,
|
||
|
);
|
||
5 years ago
|
}
|
||
|
};
|
||
|
|
||
4 years ago
|
/**
|
||
3 years ago
|
* @returns indices of start positions of selected lines, in reverse order
|
||
4 years ago
|
*/
|
||
|
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();
|
||
|
};
|
||
|
|
||
5 years ago
|
const stopEvent = (event: Event) => {
|
||
5 years ago
|
event.preventDefault();
|
||
5 years ago
|
event.stopPropagation();
|
||
5 years ago
|
};
|
||
5 years ago
|
|
||
4 years ago
|
// 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;
|
||
5 years ago
|
const handleSubmit = () => {
|
||
4 years ago
|
// 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();
|
||
3 years ago
|
const updateElement = Scene.getScene(element)?.getElement(
|
||
|
element.id,
|
||
|
) as ExcalidrawTextElement;
|
||
3 years ago
|
if (!updateElement) {
|
||
|
return;
|
||
|
}
|
||
3 years ago
|
let text = editable.value;
|
||
|
const container = getContainerElement(updateElement);
|
||
|
|
||
|
if (container) {
|
||
|
text = updateElement.text;
|
||
2 years ago
|
if (editable.value.trim()) {
|
||
3 years ago
|
const boundTextElementId = getBoundTextElementId(container);
|
||
|
if (!boundTextElementId || boundTextElementId !== element.id) {
|
||
|
mutateElement(container, {
|
||
|
boundElements: (container.boundElements || []).concat({
|
||
|
type: "text",
|
||
|
id: element.id,
|
||
|
}),
|
||
|
});
|
||
1 year ago
|
} else if (isArrowElement(container)) {
|
||
|
// updating an arrow label may change bounds, prevent stale cache:
|
||
|
bumpVersion(container);
|
||
3 years ago
|
}
|
||
3 years ago
|
} else {
|
||
|
mutateElement(container, {
|
||
|
boundElements: container.boundElements?.filter(
|
||
|
(ele) =>
|
||
|
!isTextElement(
|
||
|
ele as ExcalidrawTextElement | ExcalidrawLinearElement,
|
||
|
),
|
||
|
),
|
||
|
});
|
||
3 years ago
|
}
|
||
2 years ago
|
redrawTextBoundingBox(updateElement, container);
|
||
3 years ago
|
}
|
||
|
|
||
4 years ago
|
onSubmit({
|
||
3 years ago
|
text,
|
||
4 years ago
|
viaKeyboard: submittedViaKeyboard,
|
||
3 years ago
|
originalText: editable.value,
|
||
4 years ago
|
});
|
||
5 years ago
|
};
|
||
5 years ago
|
|
||
5 years ago
|
const cleanup = () => {
|
||
5 years ago
|
if (isDestroyed) {
|
||
|
return;
|
||
|
}
|
||
|
isDestroyed = true;
|
||
5 years ago
|
// remove events to ensure they don't late-fire
|
||
5 years ago
|
editable.onblur = null;
|
||
5 years ago
|
editable.oninput = null;
|
||
|
editable.onkeydown = null;
|
||
|
|
||
4 years ago
|
if (observer) {
|
||
|
observer.disconnect();
|
||
|
}
|
||
|
|
||
5 years ago
|
window.removeEventListener("resize", updateWysiwygStyle);
|
||
5 years ago
|
window.removeEventListener("wheel", stopEvent, true);
|
||
5 years ago
|
window.removeEventListener("pointerdown", onPointerDown);
|
||
4 years ago
|
window.removeEventListener("pointerup", bindBlurEvent);
|
||
5 years ago
|
window.removeEventListener("blur", handleSubmit);
|
||
1 year ago
|
window.removeEventListener("beforeunload", handleSubmit);
|
||
5 years ago
|
unbindUpdate();
|
||
|
|
||
4 years ago
|
editable.remove();
|
||
5 years ago
|
};
|
||
5 years ago
|
|
||
3 years ago
|
const bindBlurEvent = (event?: MouseEvent) => {
|
||
4 years ago
|
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.
|
||
3 years ago
|
const target = event?.target;
|
||
|
|
||
2 years ago
|
const isTargetPickerTrigger =
|
||
|
target instanceof HTMLElement &&
|
||
|
target.classList.contains("active-color");
|
||
3 years ago
|
|
||
5 years ago
|
setTimeout(() => {
|
||
|
editable.onblur = handleSubmit;
|
||
2 years ago
|
|
||
|
if (isTargetPickerTrigger) {
|
||
|
const callback = (
|
||
|
mutationList: MutationRecord[],
|
||
|
observer: MutationObserver,
|
||
|
) => {
|
||
|
const radixIsRemoved = mutationList.find(
|
||
|
(mutation) =>
|
||
|
mutation.removedNodes.length > 0 &&
|
||
|
(mutation.removedNodes[0] as HTMLElement).dataset
|
||
|
?.radixPopperContentWrapper !== undefined,
|
||
|
);
|
||
|
|
||
|
if (radixIsRemoved) {
|
||
|
// should work without this in theory
|
||
|
// and i think it does actually but radix probably somewhere,
|
||
|
// somehow sets the focus elsewhere
|
||
|
setTimeout(() => {
|
||
|
editable.focus();
|
||
|
});
|
||
|
|
||
|
observer.disconnect();
|
||
|
}
|
||
3 years ago
|
};
|
||
2 years ago
|
|
||
|
const observer = new MutationObserver(callback);
|
||
|
|
||
|
observer.observe(document.querySelector(".excalidraw-container")!, {
|
||
|
childList: true,
|
||
|
});
|
||
3 years ago
|
}
|
||
2 years ago
|
|
||
5 years ago
|
// case: clicking on the same property → no change → no update → no focus
|
||
2 years ago
|
if (!isTargetPickerTrigger) {
|
||
3 years ago
|
editable.focus();
|
||
|
}
|
||
5 years ago
|
});
|
||
|
};
|
||
|
|
||
|
// prevent blur when changing properties from the menu
|
||
|
const onPointerDown = (event: MouseEvent) => {
|
||
2 years ago
|
const isTargetPickerTrigger =
|
||
|
event.target instanceof HTMLElement &&
|
||
|
event.target.classList.contains("active-color");
|
||
|
|
||
5 years ago
|
if (
|
||
3 years ago
|
((event.target instanceof HTMLElement ||
|
||
4 years ago
|
event.target instanceof SVGElement) &&
|
||
3 years ago
|
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
|
||
|
!isWritableElement(event.target)) ||
|
||
2 years ago
|
isTargetPickerTrigger
|
||
5 years ago
|
) {
|
||
|
editable.onblur = null;
|
||
4 years ago
|
window.addEventListener("pointerup", bindBlurEvent);
|
||
5 years ago
|
// handle edge-case where pointerup doesn't fire e.g. due to user
|
||
4 years ago
|
// alt-tabbing away
|
||
5 years ago
|
window.addEventListener("blur", handleSubmit);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
// handle updates of textElement properties of editing element
|
||
5 years ago
|
const unbindUpdate = Scene.getScene(element)!.addCallback(() => {
|
||
5 years ago
|
updateWysiwygStyle();
|
||
3 years ago
|
const isColorPickerActive = !!document.activeElement?.closest(
|
||
2 years ago
|
".color-picker-content",
|
||
3 years ago
|
);
|
||
|
if (!isColorPickerActive) {
|
||
|
editable.focus();
|
||
|
}
|
||
5 years ago
|
});
|
||
|
|
||
4 years ago
|
// ---------------------------------------------------------------------------
|
||
|
|
||
5 years ago
|
let isDestroyed = false;
|
||
|
|
||
4 years ago
|
// 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();
|
||
4 years ago
|
|
||
|
// 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);
|
||
|
}
|
||
|
|
||
5 years ago
|
window.addEventListener("pointerdown", onPointerDown);
|
||
5 years ago
|
window.addEventListener("wheel", stopEvent, {
|
||
|
passive: false,
|
||
|
capture: true,
|
||
|
});
|
||
1 year ago
|
window.addEventListener("beforeunload", handleSubmit);
|
||
4 years ago
|
excalidrawContainer
|
||
|
?.querySelector(".excalidraw-textEditorContainer")!
|
||
4 years ago
|
.appendChild(editable);
|
||
5 years ago
|
};
|