mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-10-24 16:34:24 +02:00
Compare commits
7 Commits
zsviczian-
...
arnost/png
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
671ed94d74 | ||
|
|
d5ac76d4ea | ||
|
|
2b19d53549 | ||
|
|
b4abfad638 | ||
|
|
a39640ead1 | ||
|
|
84bd9bd4ff | ||
|
|
ae7ff76126 |
@@ -44,6 +44,7 @@
|
||||
"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",
|
||||
@@ -74,6 +75,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -825,6 +825,14 @@ 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
|
||||
@@ -835,8 +843,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// or programmatically from the host, so it will need to be
|
||||
// rewritten later
|
||||
contextMenu: null,
|
||||
editingElement:
|
||||
editingElement || actionResult.appState?.editingElement || null,
|
||||
editingElement,
|
||||
viewModeEnabled,
|
||||
zenModeEnabled,
|
||||
gridSize,
|
||||
@@ -1347,6 +1354,12 @@ 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]
|
||||
@@ -4142,12 +4155,6 @@ 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(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isTransparent, isWritableElement } from "../../utils";
|
||||
import { isInteractive, isTransparent, isWritableElement } from "../../utils";
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { AppState } from "../../types";
|
||||
import { TopPicks } from "./TopPicks";
|
||||
@@ -121,11 +121,14 @@ const ColorPickerPopupContent = ({
|
||||
}
|
||||
}}
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// prevents focusing the trigger
|
||||
e.preventDefault();
|
||||
|
||||
// return focus to excalidraw container
|
||||
if (container) {
|
||||
// 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)) {
|
||||
container.focus();
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ 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;
|
||||
@@ -210,9 +211,7 @@ export const loadLibraryFromBlob = async (
|
||||
return parseLibraryJSON(await parseFileContents(blob), defaultStatus);
|
||||
};
|
||||
|
||||
export const canvasToBlob = async (
|
||||
canvas: HTMLCanvasElement,
|
||||
): Promise<Blob> => {
|
||||
const _canvasToBlob = async (canvas: HTMLCanvasElement): Promise<Blob> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
canvas.toBlob((blob) => {
|
||||
@@ -232,6 +231,86 @@ export const canvasToBlob = async (
|
||||
});
|
||||
};
|
||||
|
||||
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> => {
|
||||
|
||||
@@ -75,7 +75,9 @@ 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 (
|
||||
|
||||
@@ -20,15 +20,13 @@ import {
|
||||
isTestEnv,
|
||||
} from "../utils";
|
||||
import { randomInteger, randomId } from "../random";
|
||||
import { bumpVersion, mutateElement, newElementWith } from "./mutateElement";
|
||||
import { bumpVersion, newElementWith } from "./mutateElement";
|
||||
import { getNewGroupIdsForDuplication } from "../groups";
|
||||
import { AppState } from "../types";
|
||||
import { getElementAbsoluteCoords } from ".";
|
||||
import { adjustXYWithRotation } from "../math";
|
||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||
import {
|
||||
getBoundTextElementOffset,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
measureText,
|
||||
normalizeText,
|
||||
@@ -44,7 +42,6 @@ import {
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { isArrowElement } from "./typeChecks";
|
||||
import { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||
|
||||
type ElementConstructorOpts = MarkOptional<
|
||||
@@ -211,8 +208,6 @@ const getAdjustedDimensions = (
|
||||
height: number;
|
||||
baseline: number;
|
||||
} => {
|
||||
const container = getContainerElement(element);
|
||||
|
||||
const {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
@@ -268,27 +263,6 @@ const getAdjustedDimensions = (
|
||||
);
|
||||
}
|
||||
|
||||
// make sure container dimensions are set properly when
|
||||
// text editor overflows beyond viewport dimensions
|
||||
if (container) {
|
||||
const boundTextElementPadding = getBoundTextElementOffset(element);
|
||||
|
||||
const containerDims = getContainerDims(container);
|
||||
let height = containerDims.height;
|
||||
let width = containerDims.width;
|
||||
if (nextHeight > height - boundTextElementPadding * 2) {
|
||||
height = nextHeight + boundTextElementPadding * 2;
|
||||
}
|
||||
if (nextWidth > width - boundTextElementPadding * 2) {
|
||||
width = nextWidth + boundTextElementPadding * 2;
|
||||
}
|
||||
if (
|
||||
!isArrowElement(container) &&
|
||||
(height !== containerDims.height || width !== containerDims.width)
|
||||
) {
|
||||
mutateElement(container, { height, width });
|
||||
}
|
||||
}
|
||||
return {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
|
||||
@@ -890,26 +890,34 @@ const rotateMultipleElements = (
|
||||
centerY,
|
||||
centerAngle + origAngle - element.angle,
|
||||
);
|
||||
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),
|
||||
|
||||
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),
|
||||
angle: normalizeAngle(centerAngle + origAngle),
|
||||
});
|
||||
}
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Scene.getScene(elements[0])?.informMutation();
|
||||
};
|
||||
|
||||
export const getResizeOffsetXY = (
|
||||
|
||||
@@ -26,6 +26,17 @@ 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;
|
||||
@@ -190,9 +201,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
mouse.clickAt(text.x + 50, text.y + 50);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingElement?.id).toBe(text.id);
|
||||
@@ -214,9 +223,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
mouse.doubleClickAt(text.x + 50, text.y + 50);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingElement?.id).toBe(text.id);
|
||||
@@ -243,9 +250,7 @@ describe("textWysiwyg", () => {
|
||||
textElement = UI.createElement("text");
|
||||
|
||||
mouse.clickOn(textElement);
|
||||
textarea = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
)!;
|
||||
textarea = getTextEditor();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -455,17 +460,11 @@ describe("textWysiwyg", () => {
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(750, 300);
|
||||
|
||||
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"));
|
||||
textarea = getTextEditor();
|
||||
updateTextEditor(
|
||||
textarea,
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
|
||||
);
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
textarea.blur();
|
||||
expect(textarea.style.width).toBe("792px");
|
||||
@@ -513,11 +512,9 @@ describe("textWysiwyg", () => {
|
||||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
@@ -543,11 +540,9 @@ describe("textWysiwyg", () => {
|
||||
]);
|
||||
expect(text.angle).toBe(rectangle.angle);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
@@ -572,9 +567,7 @@ describe("textWysiwyg", () => {
|
||||
API.setSelectedElements([diamond]);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const value = new Array(1000).fill("1").join("\n");
|
||||
@@ -587,9 +580,7 @@ describe("textWysiwyg", () => {
|
||||
expect(diamond.height).toBe(50020);
|
||||
|
||||
// Clearing text to simulate height decrease
|
||||
expect(() =>
|
||||
fireEvent.input(editor, { target: { value: "" } }),
|
||||
).not.toThrow();
|
||||
expect(() => updateTextEditor(editor, "")).not.toThrow();
|
||||
|
||||
expect(diamond.height).toBe(70);
|
||||
});
|
||||
@@ -611,9 +602,7 @@ describe("textWysiwyg", () => {
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(null);
|
||||
mouse.down();
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
||||
@@ -628,11 +617,9 @@ describe("textWysiwyg", () => {
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
|
||||
mouse.down();
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
||||
@@ -652,13 +639,11 @@ describe("textWysiwyg", () => {
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: text.id, type: "text" },
|
||||
@@ -689,11 +674,8 @@ describe("textWysiwyg", () => {
|
||||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
const editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
@@ -717,17 +699,9 @@ describe("textWysiwyg", () => {
|
||||
freedraw.y + freedraw.height / 2,
|
||||
);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Hello World!",
|
||||
},
|
||||
});
|
||||
const editor = getTextEditor();
|
||||
updateTextEditor(editor, "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");
|
||||
@@ -759,11 +733,9 @@ describe("textWysiwyg", () => {
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(null);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
@@ -776,17 +748,12 @@ describe("textWysiwyg", () => {
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(20, 30);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
},
|
||||
});
|
||||
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
updateTextEditor(
|
||||
editor,
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
);
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
expect(h.elements.length).toBe(2);
|
||||
expect(h.elements[1].type).toBe("text");
|
||||
@@ -826,12 +793,10 @@ describe("textWysiwyg", () => {
|
||||
mouse.down();
|
||||
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil);
|
||||
UI.clickTool("text");
|
||||
@@ -841,9 +806,7 @@ describe("textWysiwyg", () => {
|
||||
rectangle.y + rectangle.height / 2,
|
||||
);
|
||||
mouse.down();
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
editor.select();
|
||||
fireEvent.click(screen.getByTitle(/code/i));
|
||||
@@ -876,17 +839,9 @@ describe("textWysiwyg", () => {
|
||||
|
||||
Keyboard.keyDown(KEYS.ENTER);
|
||||
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Hello World!",
|
||||
},
|
||||
});
|
||||
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
editor.blur();
|
||||
@@ -905,17 +860,8 @@ describe("textWysiwyg", () => {
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Hello",
|
||||
},
|
||||
});
|
||||
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
@@ -943,13 +889,11 @@ describe("textWysiwyg", () => {
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: text.id, type: "text" },
|
||||
@@ -982,11 +926,9 @@ describe("textWysiwyg", () => {
|
||||
// Bind first text
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: text.id, type: "text" },
|
||||
@@ -1005,11 +947,9 @@ describe("textWysiwyg", () => {
|
||||
it("should respect text alignment when resizing", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
|
||||
// should center align horizontally and vertically by default
|
||||
@@ -1024,9 +964,7 @@ describe("textWysiwyg", () => {
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
editor.select();
|
||||
|
||||
@@ -1049,9 +987,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
editor.select();
|
||||
|
||||
@@ -1089,11 +1025,9 @@ describe("textWysiwyg", () => {
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
@@ -1106,11 +1040,9 @@ describe("textWysiwyg", () => {
|
||||
it("should scale font size correctly when resizing using shift", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
const textElement = h.elements[1] as ExcalidrawTextElement;
|
||||
expect(rectangle.width).toBe(90);
|
||||
@@ -1128,11 +1060,9 @@ describe("textWysiwyg", () => {
|
||||
it("should bind text correctly when container duplicated with alt-drag", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
expect(h.elements.length).toBe(2);
|
||||
|
||||
@@ -1162,11 +1092,9 @@ describe("textWysiwyg", () => {
|
||||
|
||||
it("undo should work", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: h.elements[1].id, type: "text" },
|
||||
@@ -1201,54 +1129,64 @@ describe("textWysiwyg", () => {
|
||||
|
||||
it("should not allow bound text with only whitespaces", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
fireEvent.change(editor, { target: { value: " " } });
|
||||
updateTextEditor(editor, " ");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([]);
|
||||
expect(h.elements[1].isDeleted).toBe(true);
|
||||
});
|
||||
|
||||
it("should restore original container height and clear cache once text is unbind", async () => {
|
||||
const originalRectHeight = rectangle.height;
|
||||
expect(rectangle.height).toBe(originalRectHeight);
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: { value: "Online whiteboard collaboration made easy" },
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
height: 75,
|
||||
width: 90,
|
||||
});
|
||||
editor.blur();
|
||||
expect(rectangle.height).toBe(185);
|
||||
mouse.select(rectangle);
|
||||
const originalRectHeight = container.height;
|
||||
expect(container.height).toBe(originalRectHeight);
|
||||
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "Online whiteboard collaboration made easy",
|
||||
});
|
||||
|
||||
h.elements = [container, text];
|
||||
API.setSelectedElements([container, text]);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 30,
|
||||
});
|
||||
const contextMenu = document.querySelector(".context-menu");
|
||||
let contextMenu = document.querySelector(".context-menu");
|
||||
|
||||
fireEvent.click(
|
||||
queryByText(contextMenu as HTMLElement, "Bind text to the container")!,
|
||||
);
|
||||
|
||||
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text).toBe(
|
||||
"Online \nwhitebo\nard \ncollabo\nration \nmade \neasy",
|
||||
);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 30,
|
||||
});
|
||||
contextMenu = document.querySelector(".context-menu");
|
||||
fireEvent.click(queryByText(contextMenu as HTMLElement, "Unbind text")!);
|
||||
expect(h.elements[0].boundElements).toEqual([]);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
|
||||
expect(getOriginalContainerHeightFromCache(container.id)).toBe(null);
|
||||
|
||||
expect(rectangle.height).toBe(originalRectHeight);
|
||||
expect(container.height).toBe(originalRectHeight);
|
||||
});
|
||||
|
||||
it("should reset the container height cache when resizing", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
@@ -1258,9 +1196,7 @@ describe("textWysiwyg", () => {
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
@@ -1273,12 +1209,8 @@ describe("textWysiwyg", () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
const editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
|
||||
mouse.select(rectangle);
|
||||
@@ -1302,12 +1234,8 @@ describe("textWysiwyg", () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
const editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
|
||||
@@ -1338,17 +1266,12 @@ describe("textWysiwyg", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
editor.select();
|
||||
});
|
||||
|
||||
@@ -1459,17 +1382,12 @@ 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 = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
},
|
||||
});
|
||||
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
updateTextEditor(
|
||||
editor,
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
);
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
|
||||
editor.select();
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
isBoundToContainer,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { CLASSES, isSafari, VERTICAL_ALIGN } from "../constants";
|
||||
import { CLASSES, isSafari } from "../constants";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
@@ -23,12 +23,10 @@ import { AppState } from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
getBoundTextElementId,
|
||||
getContainerCoords,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
getTextElementAngle,
|
||||
getTextWidth,
|
||||
measureText,
|
||||
normalizeText,
|
||||
redrawTextBoundingBox,
|
||||
wrapText,
|
||||
@@ -36,6 +34,7 @@ import {
|
||||
getBoundTextMaxWidth,
|
||||
computeContainerDimensionForBoundText,
|
||||
detectLineHeight,
|
||||
computeBoundTextPosition,
|
||||
} from "./textElement";
|
||||
import {
|
||||
actionDecreaseFontSize,
|
||||
@@ -162,7 +161,7 @@ export const textWysiwyg = ({
|
||||
let textElementWidth = updatedTextElement.width;
|
||||
// Set to element height by default since that's
|
||||
// what is going to be used for unbounded text
|
||||
let textElementHeight = updatedTextElement.height;
|
||||
const textElementHeight = updatedTextElement.height;
|
||||
|
||||
if (container && updatedTextElement.containerId) {
|
||||
if (isArrowElement(container)) {
|
||||
@@ -179,15 +178,6 @@ export const textWysiwyg = ({
|
||||
editable,
|
||||
);
|
||||
const containerDims = getContainerDims(container);
|
||||
// using editor.style.height to get the accurate height of text editor
|
||||
const editorHeight = Number(editable.style.height.slice(0, -2));
|
||||
if (editorHeight > 0) {
|
||||
textElementHeight = editorHeight;
|
||||
}
|
||||
if (propertiesUpdated) {
|
||||
// update height of the editor after properties updated
|
||||
textElementHeight = updatedTextElement.height;
|
||||
}
|
||||
|
||||
let originalContainerData;
|
||||
if (propertiesUpdated) {
|
||||
@@ -232,22 +222,12 @@ export const textWysiwyg = ({
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: targetContainerHeight });
|
||||
}
|
||||
// Start pushing text upward until a diff of 30px (padding)
|
||||
// is reached
|
||||
else {
|
||||
const containerCoords = getContainerCoords(container);
|
||||
|
||||
// vertically center align the text
|
||||
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
|
||||
if (!isArrowElement(container)) {
|
||||
coordY =
|
||||
containerCoords.y + maxHeight / 2 - textElementHeight / 2;
|
||||
}
|
||||
}
|
||||
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
coordY = containerCoords.y + (maxHeight - textElementHeight);
|
||||
}
|
||||
} else {
|
||||
const { y } = computeBoundTextPosition(
|
||||
container,
|
||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
coordY = y;
|
||||
}
|
||||
}
|
||||
const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
|
||||
@@ -388,25 +368,6 @@ export const textWysiwyg = ({
|
||||
};
|
||||
|
||||
editable.oninput = () => {
|
||||
const updatedTextElement = Scene.getScene(element)?.getElement(
|
||||
id,
|
||||
) as ExcalidrawTextElement;
|
||||
const font = getFontString(updatedTextElement);
|
||||
if (isBoundToContainer(element)) {
|
||||
const container = getContainerElement(element);
|
||||
const wrappedText = wrapText(
|
||||
normalizeText(editable.value),
|
||||
font,
|
||||
getBoundTextMaxWidth(container!),
|
||||
);
|
||||
const { width, height } = measureText(
|
||||
wrappedText,
|
||||
font,
|
||||
updatedTextElement.lineHeight,
|
||||
);
|
||||
editable.style.width = `${width}px`;
|
||||
editable.style.height = `${height}px`;
|
||||
}
|
||||
onChange(normalizeText(editable.value));
|
||||
};
|
||||
}
|
||||
|
||||
4
src/global.d.ts
vendored
4
src/global.d.ts
vendored
@@ -120,3 +120,7 @@ declare module "image-blob-reduce" {
|
||||
const reduce: ImageBlobReduce.ImageBlobReduceStatic;
|
||||
export = reduce;
|
||||
}
|
||||
|
||||
declare module "pngjs/browser" {
|
||||
export { PNG } from "pngjs";
|
||||
}
|
||||
|
||||
@@ -60,6 +60,13 @@ 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
|
||||
|
||||
12
yarn.lock
12
yarn.lock
@@ -2714,6 +2714,13 @@
|
||||
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"
|
||||
@@ -8227,6 +8234,11 @@ 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"
|
||||
|
||||
Reference in New Issue
Block a user