Compare commits

..

9 Commits

15 changed files with 289 additions and 298 deletions

View File

@ -44,7 +44,6 @@
"png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0",
"png-chunks-extract": "1.0.0",
"pngjs": "7.0.0",
"points-on-curve": "0.2.0",
"pwacompat": "2.0.17",
"react": "18.2.0",
@ -75,7 +74,6 @@
"@types/lodash.throttle": "4.1.7",
"@types/pako": "1.0.3",
"@types/pica": "5.1.3",
"@types/pngjs": "6.0.1",
"@types/react": "18.0.15",
"@types/react-dom": "18.0.6",
"@types/resize-observer-browser": "0.1.7",

View File

@ -825,14 +825,6 @@ class App extends React.Component<AppProps, AppState> {
if (typeof this.props.name !== "undefined") {
name = this.props.name;
}
editingElement =
editingElement || actionResult.appState?.editingElement || null;
if (editingElement?.isDeleted) {
editingElement = null;
}
this.setState(
(state) => {
// using Object.assign instead of spread to fool TS 4.2.2+ into
@ -843,7 +835,8 @@ class App extends React.Component<AppProps, AppState> {
// or programmatically from the host, so it will need to be
// rewritten later
contextMenu: null,
editingElement,
editingElement:
editingElement || actionResult.appState?.editingElement || null,
viewModeEnabled,
zenModeEnabled,
gridSize,
@ -1354,12 +1347,6 @@ class App extends React.Component<AppProps, AppState> {
});
}
// failsafe in case the state is being updated in incorrect order resulting
// in the editingElement being now a deleted element
if (this.state.editingElement?.isDeleted) {
this.setState({ editingElement: null });
}
if (
this.state.selectedLinearElement &&
!this.state.selectedElementIds[this.state.selectedLinearElement.elementId]
@ -4155,6 +4142,12 @@ class App extends React.Component<AppProps, AppState> {
);
}
if (pointerDownState.resize.handleType) {
setCursor(
this.canvas,
getCursorForResizingElement({
transformHandleType: pointerDownState.resize.handleType,
}),
);
pointerDownState.resize.isResizing = true;
pointerDownState.resize.offset = tupleToCoors(
getResizeOffsetXY(

View File

@ -1,4 +1,4 @@
import { isInteractive, isTransparent, isWritableElement } from "../../utils";
import { isTransparent, isWritableElement } from "../../utils";
import { ExcalidrawElement } from "../../element/types";
import { AppState } from "../../types";
import { TopPicks } from "./TopPicks";
@ -121,14 +121,11 @@ const ColorPickerPopupContent = ({
}
}}
onCloseAutoFocus={(e) => {
e.stopPropagation();
// prevents focusing the trigger
e.preventDefault();
e.stopPropagation();
// return focus to excalidraw container unless
// user focuses an interactive element, such as a button, or
// enters the text editor by clicking on canvas with the text tool
if (container && !isInteractive(document.activeElement)) {
// return focus to excalidraw container
if (container) {
container.focus();
}

View File

@ -13,7 +13,6 @@ import { FileSystemHandle, nativeFileSystemSupported } from "./filesystem";
import { isValidExcalidrawData, isValidLibrary } from "./json";
import { restore, restoreLibraryItems } from "./restore";
import { ImportedLibraryData } from "./types";
import { PNG } from "pngjs/browser";
const parseFileContents = async (blob: Blob | File) => {
let contents: string;
@ -211,7 +210,9 @@ export const loadLibraryFromBlob = async (
return parseLibraryJSON(await parseFileContents(blob), defaultStatus);
};
const _canvasToBlob = async (canvas: HTMLCanvasElement): Promise<Blob> => {
export const canvasToBlob = async (
canvas: HTMLCanvasElement,
): Promise<Blob> => {
return new Promise((resolve, reject) => {
try {
canvas.toBlob((blob) => {
@ -231,86 +232,6 @@ const _canvasToBlob = async (canvas: HTMLCanvasElement): Promise<Blob> => {
});
};
export const canvasToBlob = async (
canvas: HTMLCanvasElement,
): Promise<Blob> => {
const tileWidth = 1000;
const tileHeight = 1000;
const tileDataArray: Uint8ClampedArray[][] = []; // Two-dimensional array to store tile data
const { width: canvasWidth, height: canvasHeight } = canvas;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("No canvas context");
}
// Function to process each tile
function processTile(tileX: number, tileY: number) {
// Calculate the starting and ending coordinates for the tile
const startX = tileX * tileWidth;
const startY = tileY * tileHeight;
const endX = Math.min(startX + tileWidth, canvasWidth);
const endY = Math.min(startY + tileHeight, canvasHeight);
// Get the image data for the tile directly from the main canvas
const imageData = ctx!.getImageData(
startX,
startY,
endX - startX,
endY - startY,
).data;
// Store the tile data in the two-dimensional array
tileDataArray[tileY] = tileDataArray[tileY] || [];
tileDataArray[tileY][tileX] = imageData;
}
console.time("tiling");
// Iterate over the tiles and process each one
for (let tileY = 0; tileY < canvasHeight / tileHeight; tileY++) {
for (let tileX = 0; tileX < canvasWidth / tileWidth; tileX++) {
processTile(tileX, tileY);
}
}
console.timeEnd("tiling");
console.time("create png");
// Create a new PNG image with the final dimensions
const finalImage = new PNG({ width: canvasWidth, height: canvasHeight });
console.timeEnd("create png");
console.time("concat tiles");
// Merge the tiles into the final image
for (let tileY = 0; tileY < canvasHeight / tileHeight; tileY++) {
for (let tileX = 0; tileX < canvasWidth / tileWidth; tileX++) {
const imageData = tileDataArray[tileY][tileX];
const destX = tileX * tileWidth;
const destY = tileY * tileHeight;
// Copy the pixels from the tile to the final image
for (let y = 0; y < tileHeight; y++) {
for (let x = 0; x < tileWidth; x++) {
const index = (y * tileWidth + x) * 4;
const destIndex = ((destY + y) * canvasWidth + destX + x) * 4;
finalImage.data[destIndex] = imageData[index];
finalImage.data[destIndex + 1] = imageData[index + 1];
finalImage.data[destIndex + 2] = imageData[index + 2];
finalImage.data[destIndex + 3] = imageData[index + 3];
}
}
}
}
console.timeEnd("concat tiles");
console.time("create buffer");
const buffer = PNG.sync.write(finalImage);
console.timeEnd("create buffer");
return new Blob([buffer], { type: "image/png" });
};
/** generates SHA-1 digest from supplied file (if not supported, falls back
to a 40-char base64 random id) */
export const generateIdFromFile = async (file: File): Promise<FileId> => {

View File

@ -75,9 +75,7 @@ export const exportCanvas = async (
document.body.appendChild(tempCanvas);
if (type === "png") {
console.time("export png");
let blob = await canvasToBlob(tempCanvas);
console.timeEnd("export png");
tempCanvas.remove();
if (appState.exportEmbedScene) {
blob = await (

View File

@ -890,34 +890,26 @@ const rotateMultipleElements = (
centerY,
centerAngle + origAngle - element.angle,
);
mutateElement(
element,
{
x: element.x + (rotatedCX - cx),
y: element.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle),
},
false,
);
updateBoundElements(element, { simultaneouslyUpdated: elements });
const boundText = getBoundTextElement(element);
if (boundText && !isArrowElement(element)) {
mutateElement(
boundText,
{
x: boundText.x + (rotatedCX - cx),
y: boundText.y + (rotatedCY - cy),
mutateElement(element, {
x: element.x + (rotatedCX - cx),
y: element.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle),
});
const boundTextElementId = getBoundTextElementId(element);
if (boundTextElementId) {
const textElement =
Scene.getScene(element)?.getElement<ExcalidrawTextElementWithContainer>(
boundTextElementId,
);
if (textElement && !isArrowElement(element)) {
mutateElement(textElement, {
x: textElement.x + (rotatedCX - cx),
y: textElement.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle),
},
false,
);
});
}
}
});
Scene.getScene(elements[0])?.informMutation();
};
export const getResizeOffsetXY = (

View File

@ -49,18 +49,7 @@ describe("Test wrapText", () => {
{
desc: "break all characters when width of each character is less than container width",
width: 25,
res: `H
e
l
l
o
w
h
a
t
s
u
p`,
res: `H\ne\nl\nl\no \nw\nh\na\nt\ns \nu\np`,
},
{
desc: "break words as per the width",
@ -90,8 +79,7 @@ up`,
});
describe("When text contain new lines", () => {
const text = `Hello
whats up`;
const text = "Hello\nwhats up";
[
{
desc: "break all words when width of each word is less than container width",
@ -101,18 +89,7 @@ whats up`;
{
desc: "break all characters when width of each character is less than container width",
width: 25,
res: `H
e
l
l
o
w
h
a
t
s
u
p`,
res: `H\ne\nl\nl\no\nw\nh\na\nt\ns \nu\np`,
},
{
desc: "break words as per the width",
@ -149,13 +126,7 @@ whats up`,
desc: "fit characters of long string as per container width and break words as per the width",
width: 130,
res: `hellolongte
xtthisiswha
tsupwithyou
Iamtypinggg
ggandtyping
gg break it
now`,
res: `hellolongte\nxtthisiswha\ntsupwithyou\nIamtypinggg\nggandtyping\ngg break it \nnow`,
},
{
desc: "fit the long text when container width is greater than text length and move the rest to next line",
@ -190,7 +161,7 @@ now`,
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
const res = wrapText(text, font, 110);
expect(res).toBe(
`Wikipedia \nis hosted \nby \nWikimedia-\nFoundation,\na non-\nprofit \norganizati\non that \nalso hosts\na range-of\nother \nprojects`,
`Wikipedia \nis hosted \nby \nWikimedia- \nFoundation,\na non-\nprofit \norganizati\non that \nalso hosts \na range-of \nother \nprojects`,
);
text = "Hello thereusing-now";

View File

@ -76,6 +76,7 @@ export const redrawTextBoundingBox = (
boundTextUpdates.text,
getFontString(textElement),
textElement.lineHeight,
maxWidth,
);
boundTextUpdates.width = metrics.width;
@ -195,6 +196,7 @@ export const handleBindTextResize = (
text,
getFontString(textElement),
textElement.lineHeight,
maxWidth,
);
nextHeight = metrics.height;
nextWidth = metrics.width;
@ -283,6 +285,7 @@ export const measureText = (
text: string,
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
maxWidth?: number | null,
) => {
text = text
.split("\n")
@ -292,7 +295,14 @@ export const measureText = (
.join("\n");
const fontSize = parseFloat(font);
const height = getTextHeight(text, fontSize, lineHeight);
const width = getTextWidth(text, font);
let width = getTextWidth(text, font);
// Since we now preserve trailing whitespaces so if the text has
// trailing whitespaces, it will be considered in the width and thus width
// computed might be much higher than the allowed max width
// by the container hence making sure the width never goes beyond the max width.
if (maxWidth) {
width = Math.min(width, maxWidth);
}
const baseline = measureBaseline(text, font, lineHeight);
return { width, height, baseline };
};
@ -380,7 +390,7 @@ export const getApproxMinLineHeight = (
let canvas: HTMLCanvasElement | undefined;
const getLineWidth = (text: string, font: FontString) => {
export const getLineWidth = (text: string, font: FontString) => {
if (!canvas) {
canvas = document.createElement("canvas");
}
@ -440,10 +450,8 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
if (!Number.isFinite(maxWidth) || maxWidth < 0) {
return text;
}
const lines: Array<string> = [];
const originalLines = text.split("\n");
const spaceWidth = getLineWidth(" ", font);
let currentLine = "";
let currentLineWidthTillNow = 0;
@ -459,7 +467,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
currentLineWidthTillNow = 0;
};
originalLines.forEach((originalLine) => {
const currentLineWidth = getTextWidth(originalLine, font);
const currentLineWidth = getLineWidth(originalLine, font);
// Push the line if its <= maxWidth
if (currentLineWidth <= maxWidth) {
@ -507,23 +515,25 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
}
}
// push current line if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
if (currentLineWidthTillNow >= maxWidth) {
push(currentLine);
resetParams();
// space needs to be appended before next word
// as currentLine contains chars which couldn't be appended
// to previous line unless the line ends with hyphen to sync
// with css word-wrap
} else if (!currentLine.endsWith("-")) {
} else if (!currentLine.endsWith("-") && index < words.length) {
currentLine += " ";
currentLineWidthTillNow += spaceWidth;
}
index++;
} else {
// Start appending words in a line till max width reached
while (currentLineWidthTillNow < maxWidth && index < words.length) {
const word = words[index];
currentLineWidthTillNow = getLineWidth(currentLine + word, font);
currentLineWidthTillNow = getLineWidth(
`${currentLine + word}`.trimEnd(),
font,
);
if (currentLineWidthTillNow > maxWidth) {
push(currentLine);
@ -531,24 +541,20 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
break;
}
index++;
// if word ends with "-" then we don't need to add space
// to sync with css word-wrap
const shouldAppendSpace = !word.endsWith("-");
currentLine += word;
if (shouldAppendSpace) {
if (shouldAppendSpace && index < words.length) {
currentLine += " ";
}
index++;
// Push the word if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
if (shouldAppendSpace) {
lines.push(currentLine.slice(0, -1));
} else {
lines.push(currentLine);
}
if (currentLineWidthTillNow >= maxWidth) {
lines.push(currentLine);
resetParams();
break;
}
@ -971,3 +977,22 @@ export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
}
return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY];
};
export const getSpacesOffsetForLine = (
element: ExcalidrawTextElement,
line: string,
font: FontString,
) => {
const container = getContainerElement(element);
const trailingSpacesWidth =
getLineWidth(line, font) - getLineWidth(line.trimEnd(), font);
const maxWidth = container ? getBoundTextMaxWidth(container) : element.width;
const availableWidth = maxWidth - getLineWidth(line.trimEnd(), font);
let spacesOffset = 0;
if (element.textAlign === TEXT_ALIGN.CENTER) {
spacesOffset = -Math.min(trailingSpacesWidth / 2, availableWidth / 2);
} else if (element.textAlign === TEXT_ALIGN.RIGHT) {
spacesOffset = -Math.min(availableWidth, trailingSpacesWidth);
}
return spacesOffset;
};

View File

@ -26,17 +26,6 @@ ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const tab = " ";
const mouse = new Pointer("mouse");
const getTextEditor = () => {
return document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
};
const updateTextEditor = (editor: HTMLTextAreaElement, value: string) => {
fireEvent.change(editor, { target: { value } });
editor.dispatchEvent(new Event("input"));
};
describe("textWysiwyg", () => {
describe("start text editing", () => {
const { h } = window;
@ -201,7 +190,9 @@ describe("textWysiwyg", () => {
mouse.clickAt(text.x + 50, text.y + 50);
const editor = getTextEditor();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).not.toBe(null);
expect(h.state.editingElement?.id).toBe(text.id);
@ -223,7 +214,9 @@ describe("textWysiwyg", () => {
mouse.doubleClickAt(text.x + 50, text.y + 50);
const editor = getTextEditor();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).not.toBe(null);
expect(h.state.editingElement?.id).toBe(text.id);
@ -250,7 +243,9 @@ describe("textWysiwyg", () => {
textElement = UI.createElement("text");
mouse.clickOn(textElement);
textarea = getTextEditor();
textarea = document.querySelector(
".excalidraw-textEditorContainer > textarea",
)!;
});
afterAll(() => {
@ -460,11 +455,17 @@ describe("textWysiwyg", () => {
UI.clickTool("text");
mouse.clickAt(750, 300);
textarea = getTextEditor();
updateTextEditor(
textarea,
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
);
textarea = document.querySelector(
".excalidraw-textEditorContainer > textarea",
)!;
fireEvent.change(textarea, {
target: {
value:
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
},
});
textarea.dispatchEvent(new Event("input"));
await new Promise((cb) => setTimeout(cb, 0));
textarea.blur();
expect(textarea.style.width).toBe("792px");
@ -512,9 +513,11 @@ describe("textWysiwyg", () => {
{ id: text.id, type: "text" },
]);
mouse.down();
const editor = getTextEditor();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
updateTextEditor(editor, "Hello World!");
fireEvent.change(editor, { target: { value: "Hello World!" } });
await new Promise((r) => setTimeout(r, 0));
editor.blur();
@ -540,9 +543,11 @@ describe("textWysiwyg", () => {
]);
expect(text.angle).toBe(rectangle.angle);
mouse.down();
const editor = getTextEditor();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
updateTextEditor(editor, "Hello World!");
fireEvent.change(editor, { target: { value: "Hello World!" } });
await new Promise((r) => setTimeout(r, 0));
editor.blur();
@ -567,7 +572,9 @@ describe("textWysiwyg", () => {
API.setSelectedElements([diamond]);
Keyboard.keyPress(KEYS.ENTER);
const editor = getTextEditor();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
const value = new Array(1000).fill("1").join("\n");
@ -580,7 +587,9 @@ describe("textWysiwyg", () => {
expect(diamond.height).toBe(50020);
// Clearing text to simulate height decrease
expect(() => updateTextEditor(editor, "")).not.toThrow();
expect(() =>
fireEvent.input(editor, { target: { value: "" } }),
).not.toThrow();
expect(diamond.height).toBe(70);
});
@ -602,7 +611,9 @@ describe("textWysiwyg", () => {
expect(text.type).toBe("text");
expect(text.containerId).toBe(null);
mouse.down();
let editor = getTextEditor();
let editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
editor.blur();
@ -617,9 +628,11 @@ describe("textWysiwyg", () => {
expect(text.containerId).toBe(rectangle.id);
mouse.down();
editor = getTextEditor();
editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
updateTextEditor(editor, "Hello World!");
fireEvent.change(editor, { target: { value: "Hello World!" } });
await new Promise((r) => setTimeout(r, 0));
editor.blur();
@ -639,11 +652,13 @@ describe("textWysiwyg", () => {
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
const editor = getTextEditor();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello World!");
fireEvent.change(editor, { target: { value: "Hello World!" } });
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
@ -674,8 +689,11 @@ describe("textWysiwyg", () => {
{ id: text.id, type: "text" },
]);
mouse.down();
const editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: "Hello World!" } });
await new Promise((r) => setTimeout(r, 0));
editor.blur();
@ -699,9 +717,17 @@ describe("textWysiwyg", () => {
freedraw.y + freedraw.height / 2,
);
const editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, {
target: {
value: "Hello World!",
},
});
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
editor.dispatchEvent(new Event("input"));
expect(freedraw.boundElements).toBe(null);
expect(h.elements[1].type).toBe("text");
@ -733,9 +759,11 @@ describe("textWysiwyg", () => {
expect(text.type).toBe("text");
expect(text.containerId).toBe(null);
mouse.down();
const editor = getTextEditor();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
updateTextEditor(editor, "Hello World!");
fireEvent.change(editor, { target: { value: "Hello World!" } });
await new Promise((r) => setTimeout(r, 0));
editor.blur();
@ -748,12 +776,17 @@ describe("textWysiwyg", () => {
UI.clickTool("text");
mouse.clickAt(20, 30);
const editor = getTextEditor();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
updateTextEditor(
editor,
"Excalidraw is an opensource virtual collaborative whiteboard",
);
fireEvent.change(editor, {
target: {
value: "Excalidraw is an opensource virtual collaborative whiteboard",
},
});
editor.dispatchEvent(new Event("input"));
await new Promise((cb) => setTimeout(cb, 0));
expect(h.elements.length).toBe(2);
expect(h.elements[1].type).toBe("text");
@ -793,10 +826,12 @@ describe("textWysiwyg", () => {
mouse.down();
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
let editor = getTextEditor();
let editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello World!");
fireEvent.change(editor, { target: { value: "Hello World!" } });
editor.blur();
expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil);
UI.clickTool("text");
@ -806,7 +841,9 @@ describe("textWysiwyg", () => {
rectangle.y + rectangle.height / 2,
);
mouse.down();
editor = getTextEditor();
editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
editor.select();
fireEvent.click(screen.getByTitle(/code/i));
@ -839,9 +876,17 @@ describe("textWysiwyg", () => {
Keyboard.keyDown(KEYS.ENTER);
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
let editor = getTextEditor();
let editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
updateTextEditor(editor, "Hello World!");
fireEvent.change(editor, {
target: {
value: "Hello World!",
},
});
editor.dispatchEvent(new Event("input"));
await new Promise((cb) => setTimeout(cb, 0));
editor.blur();
@ -860,8 +905,17 @@ describe("textWysiwyg", () => {
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = getTextEditor();
updateTextEditor(editor, "Hello");
editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, {
target: {
value: "Hello",
},
});
editor.dispatchEvent(new Event("input"));
await new Promise((r) => setTimeout(r, 0));
@ -889,11 +943,13 @@ describe("textWysiwyg", () => {
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id);
const editor = getTextEditor();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello World!");
fireEvent.change(editor, { target: { value: "Hello World!" } });
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
@ -926,9 +982,11 @@ describe("textWysiwyg", () => {
// Bind first text
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id);
const editor = getTextEditor();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello World!");
fireEvent.change(editor, { target: { value: "Hello World!" } });
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
@ -947,9 +1005,11 @@ describe("textWysiwyg", () => {
it("should respect text alignment when resizing", async () => {
Keyboard.keyPress(KEYS.ENTER);
let editor = getTextEditor();
let editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
fireEvent.change(editor, { target: { value: "Hello" } });
editor.blur();
// should center align horizontally and vertically by default
@ -964,7 +1024,9 @@ describe("textWysiwyg", () => {
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = getTextEditor();
editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
editor.select();
@ -987,7 +1049,9 @@ describe("textWysiwyg", () => {
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = getTextEditor();
editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
editor.select();
@ -1025,9 +1089,11 @@ describe("textWysiwyg", () => {
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
mouse.down();
const editor = getTextEditor();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
updateTextEditor(editor, "Hello World!");
fireEvent.change(editor, { target: { value: "Hello World!" } });
await new Promise((r) => setTimeout(r, 0));
editor.blur();
@ -1040,9 +1106,11 @@ describe("textWysiwyg", () => {
it("should scale font size correctly when resizing using shift", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = getTextEditor();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
fireEvent.change(editor, { target: { value: "Hello" } });
editor.blur();
const textElement = h.elements[1] as ExcalidrawTextElement;
expect(rectangle.width).toBe(90);
@ -1060,9 +1128,11 @@ describe("textWysiwyg", () => {
it("should bind text correctly when container duplicated with alt-drag", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = getTextEditor();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
fireEvent.change(editor, { target: { value: "Hello" } });
editor.blur();
expect(h.elements.length).toBe(2);
@ -1092,9 +1162,11 @@ describe("textWysiwyg", () => {
it("undo should work", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = getTextEditor();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
fireEvent.change(editor, { target: { value: "Hello" } });
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: h.elements[1].id, type: "text" },
@ -1129,10 +1201,12 @@ describe("textWysiwyg", () => {
it("should not allow bound text with only whitespaces", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = getTextEditor();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, " ");
fireEvent.change(editor, { target: { value: " " } });
editor.blur();
expect(rectangle.boundElements).toStrictEqual([]);
expect(h.elements[1].isDeleted).toBe(true);
@ -1151,9 +1225,9 @@ describe("textWysiwyg", () => {
type: "text",
text: "Online whiteboard collaboration made easy",
});
h.elements = [container, text];
API.setSelectedElements([container, text]);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 20,
@ -1184,9 +1258,11 @@ describe("textWysiwyg", () => {
it("should reset the container height cache when resizing", async () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
let editor = getTextEditor();
let editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
fireEvent.change(editor, { target: { value: "Hello" } });
editor.blur();
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
@ -1196,7 +1272,9 @@ describe("textWysiwyg", () => {
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = getTextEditor();
editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
editor.blur();
@ -1209,8 +1287,12 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
const editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello World!" } });
editor.blur();
mouse.select(rectangle);
@ -1234,8 +1316,12 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
const editor = getTextEditor();
updateTextEditor(editor, "Hello World!");
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello World!" } });
editor.blur();
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
@ -1266,12 +1352,17 @@ describe("textWysiwyg", () => {
beforeEach(async () => {
Keyboard.keyPress(KEYS.ENTER);
editor = getTextEditor();
updateTextEditor(editor, "Hello");
editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello" } });
editor.blur();
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = getTextEditor();
editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
editor.select();
});
@ -1382,12 +1473,17 @@ describe("textWysiwyg", () => {
it("should wrap text in a container when wrap text in container triggered from context menu", async () => {
UI.clickTool("text");
mouse.clickAt(20, 30);
const editor = getTextEditor();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
updateTextEditor(
editor,
"Excalidraw is an opensource virtual collaborative whiteboard",
);
fireEvent.change(editor, {
target: {
value: "Excalidraw is an opensource virtual collaborative whiteboard",
},
});
editor.dispatchEvent(new Event("input"));
await new Promise((cb) => setTimeout(cb, 0));
editor.select();

View File

@ -11,7 +11,7 @@ import {
isBoundToContainer,
isTextElement,
} from "./typeChecks";
import { CLASSES, isSafari } from "../constants";
import { CLASSES, TEXT_ALIGN, isSafari } from "../constants";
import {
ExcalidrawElement,
ExcalidrawLinearElement,
@ -26,7 +26,7 @@ import {
getContainerDims,
getContainerElement,
getTextElementAngle,
getTextWidth,
measureText,
normalizeText,
redrawTextBoundingBox,
wrapText,
@ -196,6 +196,8 @@ export const textWysiwyg = ({
}
maxWidth = getBoundTextMaxWidth(container);
textElementWidth = Math.min(textElementWidth, maxWidth);
maxHeight = getBoundTextMaxHeight(
container,
updatedTextElement as ExcalidrawTextElementWithContainer,
@ -230,7 +232,16 @@ export const textWysiwyg = ({
coordY = y;
}
}
const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
let spacesOffset = 0;
if (updatedTextElement.textAlign === TEXT_ALIGN.CENTER) {
spacesOffset = Math.max(0, updatedTextElement.width / 2 - maxWidth / 2);
} else if (updatedTextElement.textAlign === TEXT_ALIGN.RIGHT) {
spacesOffset = Math.max(0, updatedTextElement.width - maxWidth);
}
const [viewportX, viewportY] = getViewportCoords(
coordX + spacesOffset,
coordY,
);
const initialSelectionStart = editable.selectionStart;
const initialSelectionEnd = editable.selectionEnd;
const initialLength = editable.value.length;
@ -362,7 +373,12 @@ export const textWysiwyg = ({
font,
getBoundTextMaxWidth(container),
);
const width = getTextWidth(wrappedText, font);
const { width } = measureText(
wrappedText,
font,
element.lineHeight,
getBoundTextMaxWidth(container),
);
editable.style.width = `${width}px`;
}
};

4
src/global.d.ts vendored
View File

@ -120,7 +120,3 @@ declare module "image-blob-reduce" {
const reduce: ImageBlobReduce.ImageBlobReduceStatic;
export = reduce;
}
declare module "pngjs/browser" {
export { PNG } from "pngjs";
}

View File

@ -46,6 +46,7 @@ import {
getLineHeightInPx,
getBoundTextMaxHeight,
getBoundTextMaxWidth,
getSpacesOffsetForLine,
} from "../element/textElement";
import { LinearElementEditor } from "../element/linearElementEditor";
@ -319,13 +320,13 @@ const drawElementOnCanvas = (
}
context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
context.save();
context.font = getFontString(element);
const font = getFontString(element);
context.font = font;
context.fillStyle = element.strokeColor;
context.textAlign = element.textAlign as CanvasTextAlign;
// Canvas does not support multiline text by default
const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
const horizontalOffset =
element.textAlign === "center"
? element.width / 2
@ -336,11 +337,17 @@ const drawElementOnCanvas = (
element.fontSize,
element.lineHeight,
);
const verticalOffset = element.height - element.baseline;
for (let index = 0; index < lines.length; index++) {
context.fillText(
const spacesOffset = getSpacesOffsetForLine(
element,
lines[index],
horizontalOffset,
font,
);
context.fillText(
lines[index].trimEnd(),
horizontalOffset + spacesOffset,
(index + 1) * lineHeightPx - verticalOffset,
);
}

View File

@ -1151,7 +1151,7 @@ describe("Test Linear Elements", () => {
expect(
wrapText(textElement.originalText, font, getBoundTextMaxWidth(arrow)),
).toMatchInlineSnapshot(`
"Online whiteboard collaboration
"Online whiteboard collaboration
made easy"
`);
const handleBindTextResizeSpy = jest.spyOn(

View File

@ -60,13 +60,6 @@ export const isInputLike = (
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement;
export const isInteractive = (target: Element | EventTarget | null) => {
return (
isInputLike(target) ||
(target instanceof Element && !!target.closest("label, button"))
);
};
export const isWritableElement = (
target: Element | EventTarget | null,
): target is

View File

@ -2714,13 +2714,6 @@
resolved "https://registry.yarnpkg.com/@types/pica/-/pica-5.1.3.tgz#5ef64529a1f83f7d6586a8bf75a8a00be32aca02"
integrity sha512-13SEyETRE5psd9bE0AmN+0M1tannde2fwHfLVaVIljkbL9V0OfFvKwCicyeDvVYLkmjQWEydbAlsDsmjrdyTOg==
"@types/pngjs@6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/@types/pngjs/-/pngjs-6.0.1.tgz#c711ec3fbbf077fed274ecccaf85dd4673130072"
integrity sha512-J39njbdW1U/6YyVXvC9+1iflZghP8jgRf2ndYghdJb5xL49LYDB+1EuAxfbuJ2IBbWIL3AjHPQhgaTxT3YaYeg==
dependencies:
"@types/node" "*"
"@types/prettier@^2.1.5":
version "2.7.2"
resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.2.tgz#6c2324641cc4ba050a8c710b2b251b377581fbf0"
@ -8234,11 +8227,6 @@ png-chunks-extract@1.0.0:
dependencies:
crc-32 "^0.3.0"
pngjs@7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-7.0.0.tgz#a8b7446020ebbc6ac739db6c5415a65d17090e26"
integrity sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==
points-on-curve@0.2.0, points-on-curve@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/points-on-curve/-/points-on-curve-0.2.0.tgz#7dbb98c43791859434284761330fa893cb81b4d1"