Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e75f215838 | ||
|
|
2264a66241 | ||
|
|
57a700f82e | ||
|
|
45a57d70de | ||
|
|
66ccc254f0 | ||
|
|
da8dd389a9 | ||
|
|
dae81c0a2c | ||
|
|
1e9943323a | ||
|
|
1815cf3213 | ||
|
|
d35386755f | ||
|
|
9d5cfbbfb7 | ||
|
|
fee760d38c |
@@ -12,14 +12,24 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
excalidraw/excalidraw
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: excalidraw/excalidraw:latest
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
computeBoundTextPosition,
|
computeBoundTextPosition,
|
||||||
computeContainerDimensionForBoundText,
|
computeContainerDimensionForBoundText,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
measureTextElement,
|
measureText,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
|
import { getFontString } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionUnbindText = register({
|
export const actionUnbindText = register({
|
||||||
@@ -50,11 +51,10 @@ export const actionUnbindText = register({
|
|||||||
selectedElements.forEach((element) => {
|
selectedElements.forEach((element) => {
|
||||||
const boundTextElement = getBoundTextElement(element);
|
const boundTextElement = getBoundTextElement(element);
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
const { width, height, baseline } = measureTextElement(
|
const { width, height, baseline } = measureText(
|
||||||
boundTextElement,
|
boundTextElement.originalText,
|
||||||
{
|
getFontString(boundTextElement),
|
||||||
text: boundTextElement.originalText,
|
boundTextElement.lineHeight,
|
||||||
},
|
|
||||||
);
|
);
|
||||||
const originalContainerHeight = getOriginalContainerHeightFromCache(
|
const originalContainerHeight = getOriginalContainerHeightFromCache(
|
||||||
element.id,
|
element.id,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const actionCopy = register({
|
|||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = getSelectedElements(elements, appState, true);
|
const selectedElements = getSelectedElements(elements, appState, true);
|
||||||
|
|
||||||
copyToClipboard(selectedElements, appState, app.files);
|
copyToClipboard(selectedElements, app.files);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
|
|||||||
+27
-11
@@ -2,12 +2,12 @@ import {
|
|||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "./element/types";
|
} from "./element/types";
|
||||||
import { AppState, BinaryFiles } from "./types";
|
import { BinaryFiles } from "./types";
|
||||||
import { SVG_EXPORT_TAG } from "./scene/export";
|
import { SVG_EXPORT_TAG } from "./scene/export";
|
||||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
||||||
import { isInitializedImageElement } from "./element/typeChecks";
|
import { isInitializedImageElement } from "./element/typeChecks";
|
||||||
import { isPromiseLike } from "./utils";
|
import { isPromiseLike, isTestEnv } from "./utils";
|
||||||
|
|
||||||
type ElementsClipboard = {
|
type ElementsClipboard = {
|
||||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||||
@@ -55,24 +55,40 @@ const clipboardContainsElements = (
|
|||||||
|
|
||||||
export const copyToClipboard = async (
|
export const copyToClipboard = async (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
|
||||||
files: BinaryFiles | null,
|
files: BinaryFiles | null,
|
||||||
) => {
|
) => {
|
||||||
|
let foundFile = false;
|
||||||
|
|
||||||
|
const _files = elements.reduce((acc, element) => {
|
||||||
|
if (isInitializedImageElement(element)) {
|
||||||
|
foundFile = true;
|
||||||
|
if (files && files[element.fileId]) {
|
||||||
|
acc[element.fileId] = files[element.fileId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as BinaryFiles);
|
||||||
|
|
||||||
|
if (foundFile && !files) {
|
||||||
|
console.warn(
|
||||||
|
"copyToClipboard: attempting to file element(s) without providing associated `files` object.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// select binded text elements when copying
|
// select binded text elements when copying
|
||||||
const contents: ElementsClipboard = {
|
const contents: ElementsClipboard = {
|
||||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||||
elements,
|
elements,
|
||||||
files: files
|
files: files ? _files : undefined,
|
||||||
? elements.reduce((acc, element) => {
|
|
||||||
if (isInitializedImageElement(element) && files[element.fileId]) {
|
|
||||||
acc[element.fileId] = files[element.fileId];
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {} as BinaryFiles)
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
const json = JSON.stringify(contents);
|
const json = JSON.stringify(contents);
|
||||||
|
|
||||||
|
if (isTestEnv()) {
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
CLIPBOARD = json;
|
CLIPBOARD = json;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
PREFER_APP_CLIPBOARD = false;
|
PREFER_APP_CLIPBOARD = false;
|
||||||
await copyTextToSystemClipboard(json);
|
await copyTextToSystemClipboard(json);
|
||||||
|
|||||||
+15
-2
@@ -60,6 +60,7 @@ import {
|
|||||||
ENV,
|
ENV,
|
||||||
EVENT,
|
EVENT,
|
||||||
GRID_SIZE,
|
GRID_SIZE,
|
||||||
|
IMAGE_MIME_TYPES,
|
||||||
IMAGE_RENDER_TIMEOUT,
|
IMAGE_RENDER_TIMEOUT,
|
||||||
isAndroid,
|
isAndroid,
|
||||||
isBrave,
|
isBrave,
|
||||||
@@ -1589,6 +1590,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
elements: data.elements,
|
elements: data.elements,
|
||||||
files: data.files || null,
|
files: data.files || null,
|
||||||
position: "cursor",
|
position: "cursor",
|
||||||
|
retainSeed: isPlainPaste,
|
||||||
});
|
});
|
||||||
} else if (data.text) {
|
} else if (data.text) {
|
||||||
this.addTextFromPaste(data.text, isPlainPaste);
|
this.addTextFromPaste(data.text, isPlainPaste);
|
||||||
@@ -1602,6 +1604,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
files: BinaryFiles | null;
|
files: BinaryFiles | null;
|
||||||
position: { clientX: number; clientY: number } | "cursor" | "center";
|
position: { clientX: number; clientY: number } | "cursor" | "center";
|
||||||
|
retainSeed?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const elements = restoreElements(opts.elements, null);
|
const elements = restoreElements(opts.elements, null);
|
||||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||||
@@ -1639,6 +1642,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
y: element.y + gridY - minY,
|
y: element.y + gridY - minY,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
{
|
||||||
|
randomizeSeed: !opts.retainSeed,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const nextElements = [
|
const nextElements = [
|
||||||
@@ -4720,7 +4726,12 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
pointerDownState.drag.hasOccurred = true;
|
pointerDownState.drag.hasOccurred = true;
|
||||||
// prevent dragging even if we're no longer holding cmd/ctrl otherwise
|
// prevent dragging even if we're no longer holding cmd/ctrl otherwise
|
||||||
// it would have weird results (stuff jumping all over the screen)
|
// it would have weird results (stuff jumping all over the screen)
|
||||||
if (selectedElements.length > 0 && !pointerDownState.withCmdOrCtrl) {
|
// Checking for editingElement to avoid jump while editing on mobile #6503
|
||||||
|
if (
|
||||||
|
selectedElements.length > 0 &&
|
||||||
|
!pointerDownState.withCmdOrCtrl &&
|
||||||
|
!this.state.editingElement
|
||||||
|
) {
|
||||||
const [dragX, dragY] = getGridPoint(
|
const [dragX, dragY] = getGridPoint(
|
||||||
pointerCoords.x - pointerDownState.drag.offset.x,
|
pointerCoords.x - pointerDownState.drag.offset.x,
|
||||||
pointerCoords.y - pointerDownState.drag.offset.y,
|
pointerCoords.y - pointerDownState.drag.offset.y,
|
||||||
@@ -5743,7 +5754,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
const imageFile = await fileOpen({
|
const imageFile = await fileOpen({
|
||||||
description: "Image",
|
description: "Image",
|
||||||
extensions: ["jpg", "png", "svg", "gif"],
|
extensions: Object.keys(
|
||||||
|
IMAGE_MIME_TYPES,
|
||||||
|
) as (keyof typeof IMAGE_MIME_TYPES)[],
|
||||||
});
|
});
|
||||||
|
|
||||||
const imageElement = this.createImageElement({
|
const imageElement = this.createImageElement({
|
||||||
|
|||||||
@@ -183,6 +183,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--text-primary-color);
|
color: var(--text-primary-color);
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 0.2fr;
|
grid-template-columns: 1fr 0.2fr;
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ const LibraryMenuItems = ({
|
|||||||
...item,
|
...item,
|
||||||
// duplicate each library item before inserting on canvas to confine
|
// duplicate each library item before inserting on canvas to confine
|
||||||
// ids and bindings to each library item. See #6465
|
// ids and bindings to each library item. See #6465
|
||||||
elements: duplicateElements(item.elements),
|
elements: duplicateElements(item.elements, { randomizeSeed: true }),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
// container in body where the actual tooltip is appended to
|
// container in body where the actual tooltip is appended to
|
||||||
.excalidraw-tooltip {
|
.excalidraw-tooltip {
|
||||||
|
--ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
|
||||||
|
Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
font-family: var(--ui-font);
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
|
||||||
|
|||||||
+16
-16
@@ -105,20 +105,30 @@ export const CANVAS_ONLY_ACTIONS = ["selectAll"];
|
|||||||
|
|
||||||
export const GRID_SIZE = 20; // TODO make it configurable?
|
export const GRID_SIZE = 20; // TODO make it configurable?
|
||||||
|
|
||||||
export const MIME_TYPES = {
|
export const IMAGE_MIME_TYPES = {
|
||||||
excalidraw: "application/vnd.excalidraw+json",
|
|
||||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
|
||||||
json: "application/json",
|
|
||||||
svg: "image/svg+xml",
|
svg: "image/svg+xml",
|
||||||
"excalidraw.svg": "image/svg+xml",
|
|
||||||
png: "image/png",
|
png: "image/png",
|
||||||
"excalidraw.png": "image/png",
|
|
||||||
jpg: "image/jpeg",
|
jpg: "image/jpeg",
|
||||||
gif: "image/gif",
|
gif: "image/gif",
|
||||||
webp: "image/webp",
|
webp: "image/webp",
|
||||||
bmp: "image/bmp",
|
bmp: "image/bmp",
|
||||||
ico: "image/x-icon",
|
ico: "image/x-icon",
|
||||||
|
avif: "image/avif",
|
||||||
|
jfif: "image/jfif",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const MIME_TYPES = {
|
||||||
|
json: "application/json",
|
||||||
|
// excalidraw data
|
||||||
|
excalidraw: "application/vnd.excalidraw+json",
|
||||||
|
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||||
|
// image-encoded excalidraw data
|
||||||
|
"excalidraw.svg": "image/svg+xml",
|
||||||
|
"excalidraw.png": "image/png",
|
||||||
|
// binary
|
||||||
binary: "application/octet-stream",
|
binary: "application/octet-stream",
|
||||||
|
// image
|
||||||
|
...IMAGE_MIME_TYPES,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const EXPORT_DATA_TYPES = {
|
export const EXPORT_DATA_TYPES = {
|
||||||
@@ -189,16 +199,6 @@ export const DEFAULT_EXPORT_PADDING = 10; // px
|
|||||||
|
|
||||||
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
|
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
|
||||||
|
|
||||||
export const ALLOWED_IMAGE_MIME_TYPES = [
|
|
||||||
MIME_TYPES.png,
|
|
||||||
MIME_TYPES.jpg,
|
|
||||||
MIME_TYPES.svg,
|
|
||||||
MIME_TYPES.gif,
|
|
||||||
MIME_TYPES.webp,
|
|
||||||
MIME_TYPES.bmp,
|
|
||||||
MIME_TYPES.ico,
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
|
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
export const SVG_NS = "http://www.w3.org/2000/svg";
|
export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||||
|
|||||||
@@ -354,6 +354,7 @@
|
|||||||
border-radius: var(--space-factor);
|
border-radius: var(--space-factor);
|
||||||
border: 1px solid var(--button-gray-2);
|
border: 1px solid var(--button-gray-2);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
outline: none;
|
outline: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background-image: var(--dropdown-icon);
|
background-image: var(--dropdown-icon);
|
||||||
@@ -413,6 +414,7 @@
|
|||||||
bottom: 30px;
|
bottom: 30px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--button-hover-bg);
|
background-color: var(--button-hover-bg);
|
||||||
|
|||||||
+4
-6
@@ -1,6 +1,6 @@
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { cleanAppStateForExport } from "../appState";
|
import { cleanAppStateForExport } from "../appState";
|
||||||
import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
|
import { IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
|
||||||
import { clearElementsForExport } from "../element";
|
import { clearElementsForExport } from "../element";
|
||||||
import { ExcalidrawElement, FileId } from "../element/types";
|
import { ExcalidrawElement, FileId } from "../element/types";
|
||||||
import { CanvasError } from "../errors";
|
import { CanvasError } from "../errors";
|
||||||
@@ -117,11 +117,9 @@ export const isImageFileHandle = (handle: FileSystemHandle | null) => {
|
|||||||
|
|
||||||
export const isSupportedImageFile = (
|
export const isSupportedImageFile = (
|
||||||
blob: Blob | null | undefined,
|
blob: Blob | null | undefined,
|
||||||
): blob is Blob & { type: typeof ALLOWED_IMAGE_MIME_TYPES[number] } => {
|
): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => {
|
||||||
const { type } = blob || {};
|
const { type } = blob || {};
|
||||||
return (
|
return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
|
||||||
!!type && (ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(type)
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadSceneOrLibraryFromBlob = async (
|
export const loadSceneOrLibraryFromBlob = async (
|
||||||
@@ -157,7 +155,7 @@ export const loadSceneOrLibraryFromBlob = async (
|
|||||||
},
|
},
|
||||||
localAppState,
|
localAppState,
|
||||||
localElements,
|
localElements,
|
||||||
{ repairBindings: true, refreshDimensions: true },
|
{ repairBindings: true, refreshDimensions: false },
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
} else if (isValidLibrary(data)) {
|
} else if (isValidLibrary(data)) {
|
||||||
|
|||||||
+1
-10
@@ -8,16 +8,7 @@ import { EVENT, MIME_TYPES } from "../constants";
|
|||||||
import { AbortError } from "../errors";
|
import { AbortError } from "../errors";
|
||||||
import { debounce } from "../utils";
|
import { debounce } from "../utils";
|
||||||
|
|
||||||
type FILE_EXTENSION =
|
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
||||||
| "gif"
|
|
||||||
| "jpg"
|
|
||||||
| "png"
|
|
||||||
| "excalidraw.png"
|
|
||||||
| "svg"
|
|
||||||
| "excalidraw.svg"
|
|
||||||
| "json"
|
|
||||||
| "excalidraw"
|
|
||||||
| "excalidrawlib";
|
|
||||||
|
|
||||||
const INPUT_CHANGE_INTERVAL_MS = 500;
|
const INPUT_CHANGE_INTERVAL_MS = 500;
|
||||||
|
|
||||||
|
|||||||
+8
-8
@@ -31,14 +31,14 @@ import {
|
|||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { bumpVersion } from "../element/mutateElement";
|
import { bumpVersion } from "../element/mutateElement";
|
||||||
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
|
import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import { MarkOptional, Mutable } from "../utility-types";
|
import { MarkOptional, Mutable } from "../utility-types";
|
||||||
import {
|
import {
|
||||||
detectLineHeight,
|
detectLineHeight,
|
||||||
getDefaultLineHeight,
|
getDefaultLineHeight,
|
||||||
measureTextElement,
|
measureBaseline,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
|
|
||||||
type RestoredAppState = Omit<
|
type RestoredAppState = Omit<
|
||||||
@@ -80,8 +80,7 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const restoreElementWithProperties = <
|
const restoreElementWithProperties = <
|
||||||
T extends Required<Omit<ExcalidrawElement, "subtype" | "customData">> & {
|
T extends Required<Omit<ExcalidrawElement, "customData">> & {
|
||||||
subtype?: ExcalidrawElement["subtype"];
|
|
||||||
customData?: ExcalidrawElement["customData"];
|
customData?: ExcalidrawElement["customData"];
|
||||||
/** @deprecated */
|
/** @deprecated */
|
||||||
boundElementIds?: readonly ExcalidrawElement["id"][];
|
boundElementIds?: readonly ExcalidrawElement["id"][];
|
||||||
@@ -144,9 +143,6 @@ const restoreElementWithProperties = <
|
|||||||
locked: element.locked ?? false,
|
locked: element.locked ?? false,
|
||||||
};
|
};
|
||||||
|
|
||||||
if ("subtype" in element) {
|
|
||||||
base.subtype = element.subtype;
|
|
||||||
}
|
|
||||||
if ("customData" in element) {
|
if ("customData" in element) {
|
||||||
base.customData = element.customData;
|
base.customData = element.customData;
|
||||||
}
|
}
|
||||||
@@ -192,7 +188,11 @@ const restoreElement = (
|
|||||||
: // no element height likely means programmatic use, so default
|
: // no element height likely means programmatic use, so default
|
||||||
// to a fixed line height
|
// to a fixed line height
|
||||||
getDefaultLineHeight(element.fontFamily));
|
getDefaultLineHeight(element.fontFamily));
|
||||||
const baseline = measureTextElement(element, { text }).baseline;
|
const baseline = measureBaseline(
|
||||||
|
element.text,
|
||||||
|
getFontString(element),
|
||||||
|
lineHeight,
|
||||||
|
);
|
||||||
element = restoreElementWithProperties(element, {
|
element = restoreElementWithProperties(element, {
|
||||||
fontSize,
|
fontSize,
|
||||||
fontFamily,
|
fontFamily,
|
||||||
|
|||||||
@@ -40,7 +40,11 @@ import { isBindingElement } from "./typeChecks";
|
|||||||
import { shouldRotateWithDiscreteAngle } from "../keys";
|
import { shouldRotateWithDiscreteAngle } from "../keys";
|
||||||
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||||
import { getShapeForElement } from "../renderer/renderElement";
|
import { getShapeForElement } from "../renderer/renderElement";
|
||||||
import { DRAGGING_THRESHOLD } from "../constants";
|
import {
|
||||||
|
BOUND_TEXT_PADDING,
|
||||||
|
DRAGGING_THRESHOLD,
|
||||||
|
VERTICAL_ALIGN,
|
||||||
|
} from "../constants";
|
||||||
import { Mutable } from "../utility-types";
|
import { Mutable } from "../utility-types";
|
||||||
|
|
||||||
const editorMidPointsCache: {
|
const editorMidPointsCache: {
|
||||||
@@ -1304,6 +1308,16 @@ export class LinearElementEditor {
|
|||||||
}
|
}
|
||||||
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
||||||
y = midSegmentMidpoint[1] - boundTextElement.height / 2;
|
y = midSegmentMidpoint[1] - boundTextElement.height / 2;
|
||||||
|
if (element.points.length === 2) {
|
||||||
|
if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||||
|
y =
|
||||||
|
midSegmentMidpoint[1] -
|
||||||
|
boundTextElement.height -
|
||||||
|
BOUND_TEXT_PADDING * 2;
|
||||||
|
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||||
|
y = midSegmentMidpoint[1] + BOUND_TEXT_PADDING * 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { x, y };
|
return { x, y };
|
||||||
};
|
};
|
||||||
|
|||||||
+38
-20
@@ -13,9 +13,14 @@ import {
|
|||||||
FontFamilyValues,
|
FontFamilyValues,
|
||||||
ExcalidrawTextContainer,
|
ExcalidrawTextContainer,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { arrayToMap, getUpdatedTimestamp, isTestEnv } from "../utils";
|
import {
|
||||||
|
arrayToMap,
|
||||||
|
getFontString,
|
||||||
|
getUpdatedTimestamp,
|
||||||
|
isTestEnv,
|
||||||
|
} from "../utils";
|
||||||
import { randomInteger, randomId } from "../random";
|
import { randomInteger, randomId } from "../random";
|
||||||
import { mutateElement, newElementWith } from "./mutateElement";
|
import { bumpVersion, mutateElement, newElementWith } from "./mutateElement";
|
||||||
import { getNewGroupIdsForDuplication } from "../groups";
|
import { getNewGroupIdsForDuplication } from "../groups";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { getElementAbsoluteCoords } from ".";
|
import { getElementAbsoluteCoords } from ".";
|
||||||
@@ -25,10 +30,10 @@ import {
|
|||||||
getBoundTextElementOffset,
|
getBoundTextElementOffset,
|
||||||
getContainerDims,
|
getContainerDims,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
measureTextElement,
|
measureText,
|
||||||
normalizeText,
|
normalizeText,
|
||||||
wrapTextElement,
|
wrapText,
|
||||||
getMaxContainerWidth,
|
getBoundTextMaxWidth,
|
||||||
getDefaultLineHeight,
|
getDefaultLineHeight,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
import {
|
import {
|
||||||
@@ -53,8 +58,6 @@ type ElementConstructorOpts = MarkOptional<
|
|||||||
| "version"
|
| "version"
|
||||||
| "versionNonce"
|
| "versionNonce"
|
||||||
| "link"
|
| "link"
|
||||||
| "subtype"
|
|
||||||
| "customData"
|
|
||||||
| "strokeStyle"
|
| "strokeStyle"
|
||||||
| "fillStyle"
|
| "fillStyle"
|
||||||
| "strokeColor"
|
| "strokeColor"
|
||||||
@@ -164,12 +167,10 @@ export const newTextElement = (
|
|||||||
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
|
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
|
||||||
const lineHeight = opts.lineHeight || getDefaultLineHeight(fontFamily);
|
const lineHeight = opts.lineHeight || getDefaultLineHeight(fontFamily);
|
||||||
const text = normalizeText(opts.text);
|
const text = normalizeText(opts.text);
|
||||||
const metrics = measureTextElement(
|
const metrics = measureText(
|
||||||
{ ...opts, fontSize, fontFamily, lineHeight },
|
text,
|
||||||
{
|
getFontString({ fontFamily, fontSize }),
|
||||||
text,
|
lineHeight,
|
||||||
customData: opts.customData,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
|
const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
|
||||||
const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
|
const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
|
||||||
@@ -216,9 +217,7 @@ const getAdjustedDimensions = (
|
|||||||
width: nextWidth,
|
width: nextWidth,
|
||||||
height: nextHeight,
|
height: nextHeight,
|
||||||
baseline: nextBaseline,
|
baseline: nextBaseline,
|
||||||
} = measureTextElement(element, {
|
} = measureText(nextText, getFontString(element), element.lineHeight);
|
||||||
text: nextText,
|
|
||||||
});
|
|
||||||
const { textAlign, verticalAlign } = element;
|
const { textAlign, verticalAlign } = element;
|
||||||
let x: number;
|
let x: number;
|
||||||
let y: number;
|
let y: number;
|
||||||
@@ -227,7 +226,11 @@ const getAdjustedDimensions = (
|
|||||||
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
|
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
|
||||||
!element.containerId
|
!element.containerId
|
||||||
) {
|
) {
|
||||||
const prevMetrics = measureTextElement(element);
|
const prevMetrics = measureText(
|
||||||
|
element.text,
|
||||||
|
getFontString(element),
|
||||||
|
element.lineHeight,
|
||||||
|
);
|
||||||
const offsets = getTextElementPositionOffsets(element, {
|
const offsets = getTextElementPositionOffsets(element, {
|
||||||
width: nextWidth - prevMetrics.width,
|
width: nextWidth - prevMetrics.width,
|
||||||
height: nextHeight - prevMetrics.height,
|
height: nextHeight - prevMetrics.height,
|
||||||
@@ -304,9 +307,11 @@ export const refreshTextDimensions = (
|
|||||||
}
|
}
|
||||||
const container = getContainerElement(textElement);
|
const container = getContainerElement(textElement);
|
||||||
if (container) {
|
if (container) {
|
||||||
text = wrapTextElement(textElement, getMaxContainerWidth(container), {
|
text = wrapText(
|
||||||
text,
|
text,
|
||||||
});
|
getFontString(textElement),
|
||||||
|
getBoundTextMaxWidth(container),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const dimensions = getAdjustedDimensions(textElement, text);
|
const dimensions = getAdjustedDimensions(textElement, text);
|
||||||
return { text, ...dimensions };
|
return { text, ...dimensions };
|
||||||
@@ -534,8 +539,16 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
|
|||||||
* it's advised to supply the whole elements array, or sets of elements that
|
* it's advised to supply the whole elements array, or sets of elements that
|
||||||
* are encapsulated (such as library items), if the purpose is to retain
|
* are encapsulated (such as library items), if the purpose is to retain
|
||||||
* bindings to the cloned elements intact.
|
* bindings to the cloned elements intact.
|
||||||
|
*
|
||||||
|
* NOTE by default does not randomize or regenerate anything except the id.
|
||||||
*/
|
*/
|
||||||
export const duplicateElements = (elements: readonly ExcalidrawElement[]) => {
|
export const duplicateElements = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
opts?: {
|
||||||
|
/** NOTE also updates version flags and `updated` */
|
||||||
|
randomizeSeed: boolean;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
const clonedElements: ExcalidrawElement[] = [];
|
const clonedElements: ExcalidrawElement[] = [];
|
||||||
|
|
||||||
const origElementsMap = arrayToMap(elements);
|
const origElementsMap = arrayToMap(elements);
|
||||||
@@ -569,6 +582,11 @@ export const duplicateElements = (elements: readonly ExcalidrawElement[]) => {
|
|||||||
|
|
||||||
clonedElement.id = maybeGetNewId(element.id)!;
|
clonedElement.id = maybeGetNewId(element.id)!;
|
||||||
|
|
||||||
|
if (opts?.randomizeSeed) {
|
||||||
|
clonedElement.seed = randomInteger();
|
||||||
|
bumpVersion(clonedElement);
|
||||||
|
}
|
||||||
|
|
||||||
if (clonedElement.groupIds) {
|
if (clonedElement.groupIds) {
|
||||||
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
|
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
|
||||||
if (!groupNewIdsMap.has(groupId)) {
|
if (!groupNewIdsMap.has(groupId)) {
|
||||||
|
|||||||
@@ -44,10 +44,10 @@ import {
|
|||||||
getBoundTextElementId,
|
getBoundTextElementId,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
handleBindTextResize,
|
handleBindTextResize,
|
||||||
getMaxContainerWidth,
|
getBoundTextMaxWidth,
|
||||||
getApproxMinLineHeight,
|
getApproxMinLineHeight,
|
||||||
measureText,
|
measureText,
|
||||||
getMaxContainerHeight,
|
getBoundTextMaxHeight,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
|
|
||||||
export const normalizeAngle = (angle: number): number => {
|
export const normalizeAngle = (angle: number): number => {
|
||||||
@@ -204,7 +204,7 @@ const measureFontSizeFromWidth = (
|
|||||||
if (hasContainer) {
|
if (hasContainer) {
|
||||||
const container = getContainerElement(element);
|
const container = getContainerElement(element);
|
||||||
if (container) {
|
if (container) {
|
||||||
width = getMaxContainerWidth(container);
|
width = getBoundTextMaxWidth(container);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const nextFontSize = element.fontSize * (nextWidth / width);
|
const nextFontSize = element.fontSize * (nextWidth / width);
|
||||||
@@ -435,8 +435,8 @@ export const resizeSingleElement = (
|
|||||||
|
|
||||||
const nextFont = measureFontSizeFromWidth(
|
const nextFont = measureFontSizeFromWidth(
|
||||||
boundTextElement,
|
boundTextElement,
|
||||||
getMaxContainerWidth(updatedElement),
|
getBoundTextMaxWidth(updatedElement),
|
||||||
getMaxContainerHeight(updatedElement),
|
getBoundTextMaxHeight(updatedElement, boundTextElement),
|
||||||
);
|
);
|
||||||
if (nextFont === null) {
|
if (nextFont === null) {
|
||||||
return;
|
return;
|
||||||
@@ -718,10 +718,10 @@ const resizeMultipleElements = (
|
|||||||
const metrics = measureFontSizeFromWidth(
|
const metrics = measureFontSizeFromWidth(
|
||||||
boundTextElement ?? (element.orig as ExcalidrawTextElement),
|
boundTextElement ?? (element.orig as ExcalidrawTextElement),
|
||||||
boundTextElement
|
boundTextElement
|
||||||
? getMaxContainerWidth(updatedElement)
|
? getBoundTextMaxWidth(updatedElement)
|
||||||
: updatedElement.width,
|
: updatedElement.width,
|
||||||
boundTextElement
|
boundTextElement
|
||||||
? getMaxContainerHeight(updatedElement)
|
? getBoundTextMaxHeight(updatedElement, boundTextElement)
|
||||||
: updatedElement.height,
|
: updatedElement.height,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ import { API } from "../tests/helpers/api";
|
|||||||
import {
|
import {
|
||||||
computeContainerDimensionForBoundText,
|
computeContainerDimensionForBoundText,
|
||||||
getContainerCoords,
|
getContainerCoords,
|
||||||
getMaxContainerWidth,
|
getBoundTextMaxWidth,
|
||||||
getMaxContainerHeight,
|
getBoundTextMaxHeight,
|
||||||
wrapText,
|
wrapText,
|
||||||
detectLineHeight,
|
detectLineHeight,
|
||||||
getLineHeightInPx,
|
getLineHeightInPx,
|
||||||
getDefaultLineHeight,
|
getDefaultLineHeight,
|
||||||
parseTokens,
|
parseTokens,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
import { FontString } from "./types";
|
import { ExcalidrawTextElementWithContainer, FontString } from "./types";
|
||||||
|
|
||||||
describe("Test wrapText", () => {
|
describe("Test wrapText", () => {
|
||||||
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
|
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
|
||||||
@@ -311,7 +311,7 @@ describe("Test measureText", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Test getMaxContainerWidth", () => {
|
describe("Test getBoundTextMaxWidth", () => {
|
||||||
const params = {
|
const params = {
|
||||||
width: 178,
|
width: 178,
|
||||||
height: 194,
|
height: 194,
|
||||||
@@ -319,39 +319,76 @@ describe("Test measureText", () => {
|
|||||||
|
|
||||||
it("should return max width when container is rectangle", () => {
|
it("should return max width when container is rectangle", () => {
|
||||||
const container = API.createElement({ type: "rectangle", ...params });
|
const container = API.createElement({ type: "rectangle", ...params });
|
||||||
expect(getMaxContainerWidth(container)).toBe(168);
|
expect(getBoundTextMaxWidth(container)).toBe(168);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return max width when container is ellipse", () => {
|
it("should return max width when container is ellipse", () => {
|
||||||
const container = API.createElement({ type: "ellipse", ...params });
|
const container = API.createElement({ type: "ellipse", ...params });
|
||||||
expect(getMaxContainerWidth(container)).toBe(116);
|
expect(getBoundTextMaxWidth(container)).toBe(116);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return max width when container is diamond", () => {
|
it("should return max width when container is diamond", () => {
|
||||||
const container = API.createElement({ type: "diamond", ...params });
|
const container = API.createElement({ type: "diamond", ...params });
|
||||||
expect(getMaxContainerWidth(container)).toBe(79);
|
expect(getBoundTextMaxWidth(container)).toBe(79);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Test getMaxContainerHeight", () => {
|
describe("Test getBoundTextMaxHeight", () => {
|
||||||
const params = {
|
const params = {
|
||||||
width: 178,
|
width: 178,
|
||||||
height: 194,
|
height: 194,
|
||||||
|
id: '"container-id',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const boundTextElement = API.createElement({
|
||||||
|
type: "text",
|
||||||
|
id: "text-id",
|
||||||
|
x: 560.51171875,
|
||||||
|
y: 202.033203125,
|
||||||
|
width: 154,
|
||||||
|
height: 175,
|
||||||
|
fontSize: 20,
|
||||||
|
fontFamily: 1,
|
||||||
|
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
|
||||||
|
textAlign: "center",
|
||||||
|
verticalAlign: "middle",
|
||||||
|
containerId: params.id,
|
||||||
|
}) as ExcalidrawTextElementWithContainer;
|
||||||
|
|
||||||
it("should return max height when container is rectangle", () => {
|
it("should return max height when container is rectangle", () => {
|
||||||
const container = API.createElement({ type: "rectangle", ...params });
|
const container = API.createElement({ type: "rectangle", ...params });
|
||||||
expect(getMaxContainerHeight(container)).toBe(184);
|
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(184);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return max height when container is ellipse", () => {
|
it("should return max height when container is ellipse", () => {
|
||||||
const container = API.createElement({ type: "ellipse", ...params });
|
const container = API.createElement({ type: "ellipse", ...params });
|
||||||
expect(getMaxContainerHeight(container)).toBe(127);
|
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(127);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return max height when container is diamond", () => {
|
it("should return max height when container is diamond", () => {
|
||||||
const container = API.createElement({ type: "diamond", ...params });
|
const container = API.createElement({ type: "diamond", ...params });
|
||||||
expect(getMaxContainerHeight(container)).toBe(87);
|
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(87);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return max height when container is arrow", () => {
|
||||||
|
const container = API.createElement({
|
||||||
|
type: "arrow",
|
||||||
|
...params,
|
||||||
|
});
|
||||||
|
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(194);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return max height when container is arrow and height is less than threshold", () => {
|
||||||
|
const container = API.createElement({
|
||||||
|
type: "arrow",
|
||||||
|
...params,
|
||||||
|
height: 70,
|
||||||
|
boundElements: [{ type: "text", id: "text-id" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(
|
||||||
|
boundTextElement.height,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+55
-81
@@ -1,4 +1,3 @@
|
|||||||
import { getSubtypeMethods, SubtypeMethods } from "../subtypes";
|
|
||||||
import { getFontString, arrayToMap, isTestEnv } from "../utils";
|
import { getFontString, arrayToMap, isTestEnv } from "../utils";
|
||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@@ -35,30 +34,6 @@ import {
|
|||||||
} from "./textWysiwyg";
|
} from "./textWysiwyg";
|
||||||
import { ExtractSetType } from "../utility-types";
|
import { ExtractSetType } from "../utility-types";
|
||||||
|
|
||||||
export const measureTextElement = function (element, next) {
|
|
||||||
const map = getSubtypeMethods(element.subtype);
|
|
||||||
if (map?.measureText) {
|
|
||||||
return map.measureText(element, next);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fontSize = next?.fontSize ?? element.fontSize;
|
|
||||||
const font = getFontString({ fontSize, fontFamily: element.fontFamily });
|
|
||||||
const text = next?.text ?? element.text;
|
|
||||||
return measureText(text, font, element.lineHeight);
|
|
||||||
} as SubtypeMethods["measureText"];
|
|
||||||
|
|
||||||
export const wrapTextElement = function (element, containerWidth, next) {
|
|
||||||
const map = getSubtypeMethods(element.subtype);
|
|
||||||
if (map?.wrapText) {
|
|
||||||
return map.wrapText(element, containerWidth, next);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fontSize = next?.fontSize ?? element.fontSize;
|
|
||||||
const font = getFontString({ fontSize, fontFamily: element.fontFamily });
|
|
||||||
const text = next?.text ?? element.originalText;
|
|
||||||
return wrapText(text, font, containerWidth);
|
|
||||||
} as SubtypeMethods["wrapText"];
|
|
||||||
|
|
||||||
export const normalizeText = (text: string) => {
|
export const normalizeText = (text: string) => {
|
||||||
return (
|
return (
|
||||||
text
|
text
|
||||||
@@ -90,47 +65,40 @@ export const redrawTextBoundingBox = (
|
|||||||
boundTextUpdates.text = textElement.text;
|
boundTextUpdates.text = textElement.text;
|
||||||
|
|
||||||
if (container) {
|
if (container) {
|
||||||
maxWidth = getMaxContainerWidth(container);
|
maxWidth = getBoundTextMaxWidth(container);
|
||||||
boundTextUpdates.text = wrapTextElement(textElement, maxWidth);
|
boundTextUpdates.text = wrapText(
|
||||||
|
textElement.originalText,
|
||||||
|
getFontString(textElement),
|
||||||
|
maxWidth,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const metrics = measureTextElement(textElement, {
|
const metrics = measureText(
|
||||||
text: boundTextUpdates.text,
|
boundTextUpdates.text,
|
||||||
});
|
getFontString(textElement),
|
||||||
|
textElement.lineHeight,
|
||||||
|
);
|
||||||
|
|
||||||
boundTextUpdates.width = metrics.width;
|
boundTextUpdates.width = metrics.width;
|
||||||
boundTextUpdates.height = metrics.height;
|
boundTextUpdates.height = metrics.height;
|
||||||
boundTextUpdates.baseline = metrics.baseline;
|
boundTextUpdates.baseline = metrics.baseline;
|
||||||
|
|
||||||
// Maintain coordX for non left-aligned text in case the width has changed
|
|
||||||
if (!container) {
|
|
||||||
if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
|
|
||||||
boundTextUpdates.x += textElement.width - metrics.width;
|
|
||||||
} else if (textElement.textAlign === TEXT_ALIGN.CENTER) {
|
|
||||||
boundTextUpdates.x += textElement.width / 2 - metrics.width / 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (container) {
|
if (container) {
|
||||||
if (isArrowElement(container)) {
|
const containerDims = getContainerDims(container);
|
||||||
const centerX = textElement.x + textElement.width / 2;
|
const maxContainerHeight = getBoundTextMaxHeight(
|
||||||
const centerY = textElement.y + textElement.height / 2;
|
container,
|
||||||
const diffWidth = metrics.width - textElement.width;
|
textElement as ExcalidrawTextElementWithContainer,
|
||||||
const diffHeight = metrics.height - textElement.height;
|
);
|
||||||
boundTextUpdates.x = centerY - (textElement.height + diffHeight) / 2;
|
|
||||||
boundTextUpdates.y = centerX - (textElement.width + diffWidth) / 2;
|
|
||||||
} else {
|
|
||||||
const containerDims = getContainerDims(container);
|
|
||||||
let maxContainerHeight = getMaxContainerHeight(container);
|
|
||||||
|
|
||||||
let nextHeight = containerDims.height;
|
let nextHeight = containerDims.height;
|
||||||
if (metrics.height > maxContainerHeight) {
|
if (metrics.height > maxContainerHeight) {
|
||||||
nextHeight = computeContainerDimensionForBoundText(
|
nextHeight = computeContainerDimensionForBoundText(
|
||||||
metrics.height,
|
metrics.height,
|
||||||
container.type,
|
container.type,
|
||||||
);
|
);
|
||||||
mutateElement(container, { height: nextHeight });
|
mutateElement(container, { height: nextHeight });
|
||||||
maxContainerHeight = getMaxContainerHeight(container);
|
updateOriginalContainerCache(container.id, nextHeight);
|
||||||
updateOriginalContainerCache(container.id, nextHeight);
|
}
|
||||||
}
|
if (!isArrowElement(container)) {
|
||||||
const updatedTextElement = {
|
const updatedTextElement = {
|
||||||
...textElement,
|
...textElement,
|
||||||
...boundTextUpdates,
|
...boundTextUpdates,
|
||||||
@@ -210,15 +178,26 @@ export const handleBindTextResize = (
|
|||||||
let nextHeight = textElement.height;
|
let nextHeight = textElement.height;
|
||||||
let nextWidth = textElement.width;
|
let nextWidth = textElement.width;
|
||||||
const containerDims = getContainerDims(container);
|
const containerDims = getContainerDims(container);
|
||||||
const maxWidth = getMaxContainerWidth(container);
|
const maxWidth = getBoundTextMaxWidth(container);
|
||||||
const maxHeight = getMaxContainerHeight(container);
|
const maxHeight = getBoundTextMaxHeight(
|
||||||
|
container,
|
||||||
|
textElement as ExcalidrawTextElementWithContainer,
|
||||||
|
);
|
||||||
let containerHeight = containerDims.height;
|
let containerHeight = containerDims.height;
|
||||||
let nextBaseLine = textElement.baseline;
|
let nextBaseLine = textElement.baseline;
|
||||||
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
||||||
if (text) {
|
if (text) {
|
||||||
text = wrapTextElement(textElement, maxWidth);
|
text = wrapText(
|
||||||
|
textElement.originalText,
|
||||||
|
getFontString(textElement),
|
||||||
|
maxWidth,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const metrics = measureTextElement(textElement, { text });
|
const metrics = measureText(
|
||||||
|
text,
|
||||||
|
getFontString(textElement),
|
||||||
|
textElement.lineHeight,
|
||||||
|
);
|
||||||
nextHeight = metrics.height;
|
nextHeight = metrics.height;
|
||||||
nextWidth = metrics.width;
|
nextWidth = metrics.width;
|
||||||
nextBaseLine = metrics.baseline;
|
nextBaseLine = metrics.baseline;
|
||||||
@@ -275,8 +254,8 @@ export const computeBoundTextPosition = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const containerCoords = getContainerCoords(container);
|
const containerCoords = getContainerCoords(container);
|
||||||
const maxContainerHeight = getMaxContainerHeight(container);
|
const maxContainerHeight = getBoundTextMaxHeight(container, boundTextElement);
|
||||||
const maxContainerWidth = getMaxContainerWidth(container);
|
const maxContainerWidth = getBoundTextMaxWidth(container);
|
||||||
|
|
||||||
let x;
|
let x;
|
||||||
let y;
|
let y;
|
||||||
@@ -816,7 +795,11 @@ export const shouldAllowVerticalAlign = (
|
|||||||
const hasBoundContainer = isBoundToContainer(element);
|
const hasBoundContainer = isBoundToContainer(element);
|
||||||
if (hasBoundContainer) {
|
if (hasBoundContainer) {
|
||||||
const container = getContainerElement(element);
|
const container = getContainerElement(element);
|
||||||
if (isTextElement(element) && isArrowElement(container)) {
|
if (
|
||||||
|
isTextElement(element) &&
|
||||||
|
isArrowElement(container) &&
|
||||||
|
container.points.length > 2
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -909,18 +892,10 @@ export const computeContainerDimensionForBoundText = (
|
|||||||
return dimension + padding;
|
return dimension + padding;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
|
export const getBoundTextMaxWidth = (container: ExcalidrawElement) => {
|
||||||
const width = getContainerDims(container).width;
|
const width = getContainerDims(container).width;
|
||||||
if (isArrowElement(container)) {
|
if (isArrowElement(container)) {
|
||||||
const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
|
return width - BOUND_TEXT_PADDING * 8 * 2;
|
||||||
if (containerWidth <= 0) {
|
|
||||||
const boundText = getBoundTextElement(container);
|
|
||||||
if (boundText) {
|
|
||||||
return boundText.width;
|
|
||||||
}
|
|
||||||
return BOUND_TEXT_PADDING * 8 * 2;
|
|
||||||
}
|
|
||||||
return containerWidth;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (container.type === "ellipse") {
|
if (container.type === "ellipse") {
|
||||||
@@ -937,16 +912,15 @@ export const getMaxContainerWidth = (container: ExcalidrawElement) => {
|
|||||||
return width - BOUND_TEXT_PADDING * 2;
|
return width - BOUND_TEXT_PADDING * 2;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getMaxContainerHeight = (container: ExcalidrawElement) => {
|
export const getBoundTextMaxHeight = (
|
||||||
|
container: ExcalidrawElement,
|
||||||
|
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||||
|
) => {
|
||||||
const height = getContainerDims(container).height;
|
const height = getContainerDims(container).height;
|
||||||
if (isArrowElement(container)) {
|
if (isArrowElement(container)) {
|
||||||
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
||||||
if (containerHeight <= 0) {
|
if (containerHeight <= 0) {
|
||||||
const boundText = getBoundTextElement(container);
|
return boundTextElement.height;
|
||||||
if (boundText) {
|
|
||||||
return boundText.height;
|
|
||||||
}
|
|
||||||
return BOUND_TEXT_PADDING * 8 * 2;
|
|
||||||
}
|
}
|
||||||
return height;
|
return height;
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-71
@@ -32,8 +32,8 @@ import {
|
|||||||
normalizeText,
|
normalizeText,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
wrapText,
|
wrapText,
|
||||||
getMaxContainerHeight,
|
getBoundTextMaxHeight,
|
||||||
getMaxContainerWidth,
|
getBoundTextMaxWidth,
|
||||||
computeContainerDimensionForBoundText,
|
computeContainerDimensionForBoundText,
|
||||||
detectLineHeight,
|
detectLineHeight,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
@@ -47,7 +47,6 @@ import { LinearElementEditor } from "./linearElementEditor";
|
|||||||
import { parseClipboard } from "../clipboard";
|
import { parseClipboard } from "../clipboard";
|
||||||
|
|
||||||
const getTransform = (
|
const getTransform = (
|
||||||
offsetX: number,
|
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
angle: number,
|
angle: number,
|
||||||
@@ -65,7 +64,7 @@ const getTransform = (
|
|||||||
if (height > maxHeight && zoom.value !== 1) {
|
if (height > maxHeight && zoom.value !== 1) {
|
||||||
translateY = (maxHeight * (zoom.value - 1)) / 2;
|
translateY = (maxHeight * (zoom.value - 1)) / 2;
|
||||||
}
|
}
|
||||||
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg) translate(${offsetX}px, 0px)`;
|
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const originalContainerCache: {
|
const originalContainerCache: {
|
||||||
@@ -159,30 +158,13 @@ export const textWysiwyg = ({
|
|||||||
const container = getContainerElement(updatedTextElement);
|
const container = getContainerElement(updatedTextElement);
|
||||||
let maxWidth = updatedTextElement.width;
|
let maxWidth = updatedTextElement.width;
|
||||||
|
|
||||||
// Editing metrics
|
let maxHeight = updatedTextElement.height;
|
||||||
const eMetrics = measureText(
|
let textElementWidth = updatedTextElement.width;
|
||||||
container && updatedTextElement.containerId
|
|
||||||
? wrapText(
|
|
||||||
updatedTextElement.originalText,
|
|
||||||
getFontString(updatedTextElement),
|
|
||||||
getMaxContainerWidth(container),
|
|
||||||
)
|
|
||||||
: updatedTextElement.originalText,
|
|
||||||
getFontString(updatedTextElement),
|
|
||||||
updatedTextElement.lineHeight,
|
|
||||||
);
|
|
||||||
|
|
||||||
let maxHeight = eMetrics.height;
|
|
||||||
let textElementWidth = Math.max(updatedTextElement.width, eMetrics.width);
|
|
||||||
// Set to element height by default since that's
|
// Set to element height by default since that's
|
||||||
// what is going to be used for unbounded text
|
// what is going to be used for unbounded text
|
||||||
let textElementHeight = Math.max(updatedTextElement.height, maxHeight);
|
let textElementHeight = updatedTextElement.height;
|
||||||
|
|
||||||
if (container && updatedTextElement.containerId) {
|
if (container && updatedTextElement.containerId) {
|
||||||
textElementHeight = Math.min(
|
|
||||||
getMaxContainerHeight(container),
|
|
||||||
textElementHeight,
|
|
||||||
);
|
|
||||||
if (isArrowElement(container)) {
|
if (isArrowElement(container)) {
|
||||||
const boundTextCoords =
|
const boundTextCoords =
|
||||||
LinearElementEditor.getBoundTextElementPosition(
|
LinearElementEditor.getBoundTextElementPosition(
|
||||||
@@ -191,8 +173,6 @@ export const textWysiwyg = ({
|
|||||||
);
|
);
|
||||||
coordX = boundTextCoords.x;
|
coordX = boundTextCoords.x;
|
||||||
coordY = boundTextCoords.y;
|
coordY = boundTextCoords.y;
|
||||||
} else {
|
|
||||||
coordX = Math.max(coordX, getContainerCoords(container).x);
|
|
||||||
}
|
}
|
||||||
const propertiesUpdated = textPropertiesUpdated(
|
const propertiesUpdated = textPropertiesUpdated(
|
||||||
updatedTextElement,
|
updatedTextElement,
|
||||||
@@ -206,18 +186,7 @@ export const textWysiwyg = ({
|
|||||||
}
|
}
|
||||||
if (propertiesUpdated) {
|
if (propertiesUpdated) {
|
||||||
// update height of the editor after properties updated
|
// update height of the editor after properties updated
|
||||||
const font = getFontString(updatedTextElement);
|
textElementHeight = updatedTextElement.height;
|
||||||
textElementHeight =
|
|
||||||
updatedTextElement.lineHeight *
|
|
||||||
wrapText(
|
|
||||||
updatedTextElement.originalText,
|
|
||||||
font,
|
|
||||||
getMaxContainerWidth(container),
|
|
||||||
).split("\n").length;
|
|
||||||
textElementHeight = Math.max(
|
|
||||||
textElementHeight,
|
|
||||||
updatedTextElement.height,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let originalContainerData;
|
let originalContainerData;
|
||||||
@@ -236,8 +205,11 @@ export const textWysiwyg = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
maxWidth = getMaxContainerWidth(container);
|
maxWidth = getBoundTextMaxWidth(container);
|
||||||
maxHeight = getMaxContainerHeight(container);
|
maxHeight = getBoundTextMaxHeight(
|
||||||
|
container,
|
||||||
|
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||||
|
);
|
||||||
|
|
||||||
// autogrow container height if text exceeds
|
// autogrow container height if text exceeds
|
||||||
if (!isArrowElement(container) && textElementHeight > maxHeight) {
|
if (!isArrowElement(container) && textElementHeight > maxHeight) {
|
||||||
@@ -261,17 +233,14 @@ export const textWysiwyg = ({
|
|||||||
);
|
);
|
||||||
mutateElement(container, { height: targetContainerHeight });
|
mutateElement(container, { height: targetContainerHeight });
|
||||||
}
|
}
|
||||||
// Start pushing text upward until a diff of 30px (padding)
|
// update y coord as you type, not needed for arrow as we calculate
|
||||||
// is reached
|
// position from the container element for editor and canvas when rendering labelled arrows
|
||||||
else {
|
else if (!isArrowElement(container)) {
|
||||||
const containerCoords = getContainerCoords(container);
|
const containerCoords = getContainerCoords(container);
|
||||||
|
|
||||||
// vertically center align the text
|
// vertically center align the text
|
||||||
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
|
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
|
||||||
if (!isArrowElement(container)) {
|
coordY = containerCoords.y + maxHeight / 2 - textElementHeight / 2;
|
||||||
coordY =
|
|
||||||
containerCoords.y + maxHeight / 2 - textElementHeight / 2;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||||
coordY = containerCoords.y + (maxHeight - textElementHeight);
|
coordY = containerCoords.y + (maxHeight - textElementHeight);
|
||||||
@@ -297,29 +266,12 @@ export const textWysiwyg = ({
|
|||||||
editable.selectionEnd = editable.value.length - diff;
|
editable.selectionEnd = editable.value.length - diff;
|
||||||
}
|
}
|
||||||
|
|
||||||
let transformWidth = updatedTextElement.width;
|
|
||||||
if (!container) {
|
if (!container) {
|
||||||
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
|
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
|
||||||
textElementWidth = Math.min(textElementWidth, maxWidth);
|
textElementWidth = Math.min(textElementWidth, maxWidth);
|
||||||
} else {
|
} else {
|
||||||
textElementWidth += 0.5;
|
textElementWidth += 0.5;
|
||||||
transformWidth += 0.5;
|
|
||||||
}
|
}
|
||||||
// Horizontal offset in case updatedTextElement has a non-WYSIWYG subtype
|
|
||||||
const offWidth = container
|
|
||||||
? Math.min(
|
|
||||||
0,
|
|
||||||
updatedTextElement.width - Math.min(maxWidth, eMetrics.width),
|
|
||||||
)
|
|
||||||
: Math.min(maxWidth, updatedTextElement.width) -
|
|
||||||
Math.min(maxWidth, eMetrics.width);
|
|
||||||
const offsetX =
|
|
||||||
textAlign === "right"
|
|
||||||
? offWidth
|
|
||||||
: textAlign === "center"
|
|
||||||
? offWidth / 2
|
|
||||||
: 0;
|
|
||||||
const { width: w, height: h } = updatedTextElement;
|
|
||||||
|
|
||||||
let lineHeight = updatedTextElement.lineHeight;
|
let lineHeight = updatedTextElement.lineHeight;
|
||||||
|
|
||||||
@@ -338,15 +290,13 @@ export const textWysiwyg = ({
|
|||||||
font: getFontString(updatedTextElement),
|
font: getFontString(updatedTextElement),
|
||||||
// must be defined *after* font ¯\_(ツ)_/¯
|
// must be defined *after* font ¯\_(ツ)_/¯
|
||||||
lineHeight,
|
lineHeight,
|
||||||
width: `${Math.min(textElementWidth, maxWidth)}px`,
|
width: `${textElementWidth}px`,
|
||||||
height: `${textElementHeight}px`,
|
height: `${textElementHeight}px`,
|
||||||
left: `${viewportX}px`,
|
left: `${viewportX}px`,
|
||||||
top: `${viewportY}px`,
|
top: `${viewportY}px`,
|
||||||
transformOrigin: `${w / 2}px ${h / 2}px`,
|
|
||||||
transform: getTransform(
|
transform: getTransform(
|
||||||
offsetX,
|
textElementWidth,
|
||||||
transformWidth,
|
textElementHeight,
|
||||||
updatedTextElement.height,
|
|
||||||
getTextElementAngle(updatedTextElement),
|
getTextElementAngle(updatedTextElement),
|
||||||
appState,
|
appState,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
@@ -427,7 +377,7 @@ export const textWysiwyg = ({
|
|||||||
const wrappedText = wrapText(
|
const wrappedText = wrapText(
|
||||||
`${editable.value}${data}`,
|
`${editable.value}${data}`,
|
||||||
font,
|
font,
|
||||||
getMaxContainerWidth(container),
|
getBoundTextMaxWidth(container),
|
||||||
);
|
);
|
||||||
const width = getTextWidth(wrappedText, font);
|
const width = getTextWidth(wrappedText, font);
|
||||||
editable.style.width = `${width}px`;
|
editable.style.width = `${width}px`;
|
||||||
@@ -444,7 +394,7 @@ export const textWysiwyg = ({
|
|||||||
const wrappedText = wrapText(
|
const wrappedText = wrapText(
|
||||||
normalizeText(editable.value),
|
normalizeText(editable.value),
|
||||||
font,
|
font,
|
||||||
getMaxContainerWidth(container!),
|
getBoundTextMaxWidth(container!),
|
||||||
);
|
);
|
||||||
const { width, height } = measureText(
|
const { width, height } = measureText(
|
||||||
wrappedText,
|
wrappedText,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Subtype } from "../subtypes";
|
|
||||||
import { Point } from "../types";
|
import { Point } from "../types";
|
||||||
import {
|
import {
|
||||||
FONT_FAMILY,
|
FONT_FAMILY,
|
||||||
@@ -65,7 +64,6 @@ type _ExcalidrawElementBase = Readonly<{
|
|||||||
updated: number;
|
updated: number;
|
||||||
link: string | null;
|
link: string | null;
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
subtype?: Subtype;
|
|
||||||
customData?: Record<string, any>;
|
customData?: Record<string, any>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ export const loadScene = async (
|
|||||||
await importFromBackend(id, privateKey),
|
await importFromBackend(id, privateKey),
|
||||||
localDataState?.appState,
|
localDataState?.appState,
|
||||||
localDataState?.elements,
|
localDataState?.elements,
|
||||||
{ repairBindings: true, refreshDimensions: true },
|
{ repairBindings: true, refreshDimensions: false },
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
data = restore(localDataState || null, null, null, {
|
data = restore(localDataState || null, null, null, {
|
||||||
|
|||||||
@@ -220,15 +220,7 @@ export const exportToClipboard = async (
|
|||||||
} else if (opts.type === "png") {
|
} else if (opts.type === "png") {
|
||||||
await copyBlobToClipboardAsPng(exportToBlob(opts));
|
await copyBlobToClipboardAsPng(exportToBlob(opts));
|
||||||
} else if (opts.type === "json") {
|
} else if (opts.type === "json") {
|
||||||
const appState = {
|
await copyToClipboard(opts.elements, opts.files);
|
||||||
offsetTop: 0,
|
|
||||||
offsetLeft: 0,
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
...getDefaultAppState(),
|
|
||||||
...opts.appState,
|
|
||||||
};
|
|
||||||
await copyToClipboard(opts.elements, appState, opts.files);
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Invalid export type");
|
throw new Error("Invalid export type");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ import {
|
|||||||
getContainerCoords,
|
getContainerCoords,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
getLineHeightInPx,
|
getLineHeightInPx,
|
||||||
getMaxContainerHeight,
|
getBoundTextMaxHeight,
|
||||||
getMaxContainerWidth,
|
getBoundTextMaxWidth,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
|
||||||
@@ -868,14 +868,17 @@ const drawElementFromCanvas = (
|
|||||||
"true" &&
|
"true" &&
|
||||||
hasBoundTextElement(element)
|
hasBoundTextElement(element)
|
||||||
) {
|
) {
|
||||||
|
const textElement = getBoundTextElement(
|
||||||
|
element,
|
||||||
|
) as ExcalidrawTextElementWithContainer;
|
||||||
const coords = getContainerCoords(element);
|
const coords = getContainerCoords(element);
|
||||||
context.strokeStyle = "#c92a2a";
|
context.strokeStyle = "#c92a2a";
|
||||||
context.lineWidth = 3;
|
context.lineWidth = 3;
|
||||||
context.strokeRect(
|
context.strokeRect(
|
||||||
(coords.x + renderConfig.scrollX) * window.devicePixelRatio,
|
(coords.x + renderConfig.scrollX) * window.devicePixelRatio,
|
||||||
(coords.y + renderConfig.scrollY) * window.devicePixelRatio,
|
(coords.y + renderConfig.scrollY) * window.devicePixelRatio,
|
||||||
getMaxContainerWidth(element) * window.devicePixelRatio,
|
getBoundTextMaxWidth(element) * window.devicePixelRatio,
|
||||||
getMaxContainerHeight(element) * window.devicePixelRatio,
|
getBoundTextMaxHeight(element, textElement) * window.devicePixelRatio,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
import { isTextElement, refreshTextDimensions } from "../element";
|
import { isTextElement, refreshTextDimensions } from "../element";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
|
import { isBoundToContainer } from "../element/typeChecks";
|
||||||
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
|
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
|
||||||
import { invalidateShapeForElement } from "../renderer/renderElement";
|
import { invalidateShapeForElement } from "../renderer/renderElement";
|
||||||
import { getFontString } from "../utils";
|
import { getFontString } from "../utils";
|
||||||
@@ -52,7 +53,7 @@ export class Fonts {
|
|||||||
let didUpdate = false;
|
let didUpdate = false;
|
||||||
|
|
||||||
this.scene.mapElements((element) => {
|
this.scene.mapElements((element) => {
|
||||||
if (isTextElement(element)) {
|
if (isTextElement(element) && !isBoundToContainer(element)) {
|
||||||
invalidateShapeForElement(element);
|
invalidateShapeForElement(element);
|
||||||
didUpdate = true;
|
didUpdate = true;
|
||||||
return newElementWith(element, {
|
return newElementWith(element, {
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import { ExcalidrawElement, ExcalidrawTextElement } from "./element/types";
|
|
||||||
|
|
||||||
// Subtype Names
|
|
||||||
export type Subtype = string;
|
|
||||||
|
|
||||||
// Subtype Methods
|
|
||||||
export type SubtypeMethods = {
|
|
||||||
measureText: (
|
|
||||||
element: Pick<
|
|
||||||
ExcalidrawTextElement,
|
|
||||||
| "subtype"
|
|
||||||
| "customData"
|
|
||||||
| "fontSize"
|
|
||||||
| "fontFamily"
|
|
||||||
| "text"
|
|
||||||
| "lineHeight"
|
|
||||||
>,
|
|
||||||
next?: {
|
|
||||||
fontSize?: number;
|
|
||||||
text?: string;
|
|
||||||
customData?: ExcalidrawElement["customData"];
|
|
||||||
},
|
|
||||||
) => { width: number; height: number; baseline: number };
|
|
||||||
wrapText: (
|
|
||||||
element: Pick<
|
|
||||||
ExcalidrawTextElement,
|
|
||||||
| "subtype"
|
|
||||||
| "customData"
|
|
||||||
| "fontSize"
|
|
||||||
| "fontFamily"
|
|
||||||
| "originalText"
|
|
||||||
| "lineHeight"
|
|
||||||
>,
|
|
||||||
containerWidth: number,
|
|
||||||
next?: {
|
|
||||||
fontSize?: number;
|
|
||||||
text?: string;
|
|
||||||
customData?: ExcalidrawElement["customData"];
|
|
||||||
},
|
|
||||||
) => string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MethodMap = { subtype: Subtype; methods: Partial<SubtypeMethods> };
|
|
||||||
const methodMaps = [] as Array<MethodMap>;
|
|
||||||
|
|
||||||
// Use `getSubtypeMethods` to call subtype-specialized methods, like `render`.
|
|
||||||
export const getSubtypeMethods = (
|
|
||||||
subtype: Subtype | undefined,
|
|
||||||
): Partial<SubtypeMethods> | undefined => {
|
|
||||||
const map = methodMaps.find((method) => method.subtype === subtype);
|
|
||||||
return map?.methods;
|
|
||||||
};
|
|
||||||
@@ -5,7 +5,7 @@ exports[`Test Linear Elements Test bound text element should match styles for te
|
|||||||
class="excalidraw-wysiwyg"
|
class="excalidraw-wysiwyg"
|
||||||
data-type="wysiwyg"
|
data-type="wysiwyg"
|
||||||
dir="auto"
|
dir="auto"
|
||||||
style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform-origin: 5px 12.5px; transform: translate(0px, 0px) scale(1) rotate(0deg) translate(0px, 0px); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -7.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
|
style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -7.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
wrap="off"
|
wrap="off"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import { render, waitFor, GlobalTestState } from "./test-utils";
|
import {
|
||||||
|
render,
|
||||||
|
waitFor,
|
||||||
|
GlobalTestState,
|
||||||
|
createPasteEvent,
|
||||||
|
} from "./test-utils";
|
||||||
import { Pointer, Keyboard } from "./helpers/ui";
|
import { Pointer, Keyboard } from "./helpers/ui";
|
||||||
import ExcalidrawApp from "../excalidraw-app";
|
import ExcalidrawApp from "../excalidraw-app";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
@@ -9,6 +14,8 @@ import {
|
|||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import { getElementBounds } from "../element";
|
import { getElementBounds } from "../element";
|
||||||
import { NormalizedZoomValue } from "../types";
|
import { NormalizedZoomValue } from "../types";
|
||||||
|
import { API } from "./helpers/api";
|
||||||
|
import { copyToClipboard } from "../clipboard";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
@@ -35,38 +42,28 @@ const setClipboardText = (text: string) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendPasteEvent = () => {
|
const sendPasteEvent = (text?: string) => {
|
||||||
const clipboardEvent = new Event("paste", {
|
const clipboardEvent = createPasteEvent(
|
||||||
bubbles: true,
|
text || (() => window.navigator.clipboard.readText()),
|
||||||
cancelable: true,
|
);
|
||||||
composed: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// set `clipboardData` properties.
|
|
||||||
// @ts-ignore
|
|
||||||
clipboardEvent.clipboardData = {
|
|
||||||
getData: () => window.navigator.clipboard.readText(),
|
|
||||||
files: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
document.dispatchEvent(clipboardEvent);
|
document.dispatchEvent(clipboardEvent);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pasteWithCtrlCmdShiftV = () => {
|
const pasteWithCtrlCmdShiftV = (text?: string) => {
|
||||||
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
|
||||||
//triggering keydown with an empty clipboard
|
//triggering keydown with an empty clipboard
|
||||||
Keyboard.keyPress(KEYS.V);
|
Keyboard.keyPress(KEYS.V);
|
||||||
//triggering paste event with faked clipboard
|
//triggering paste event with faked clipboard
|
||||||
sendPasteEvent();
|
sendPasteEvent(text);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const pasteWithCtrlCmdV = () => {
|
const pasteWithCtrlCmdV = (text?: string) => {
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
//triggering keydown with an empty clipboard
|
//triggering keydown with an empty clipboard
|
||||||
Keyboard.keyPress(KEYS.V);
|
Keyboard.keyPress(KEYS.V);
|
||||||
//triggering paste event with faked clipboard
|
//triggering paste event with faked clipboard
|
||||||
sendPasteEvent();
|
sendPasteEvent(text);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,6 +86,32 @@ beforeEach(async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("general paste behavior", () => {
|
||||||
|
it("should randomize seed on paste", async () => {
|
||||||
|
const rectangle = API.createElement({ type: "rectangle" });
|
||||||
|
const clipboardJSON = (await copyToClipboard([rectangle], null))!;
|
||||||
|
|
||||||
|
pasteWithCtrlCmdV(clipboardJSON);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(h.elements.length).toBe(1);
|
||||||
|
expect(h.elements[0].seed).not.toBe(rectangle.seed);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should retain seed on shift-paste", async () => {
|
||||||
|
const rectangle = API.createElement({ type: "rectangle" });
|
||||||
|
const clipboardJSON = (await copyToClipboard([rectangle], null))!;
|
||||||
|
|
||||||
|
// assert we don't randomize seed on shift-paste
|
||||||
|
pasteWithCtrlCmdShiftV(clipboardJSON);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(h.elements.length).toBe(1);
|
||||||
|
expect(h.elements[0].seed).toBe(rectangle.seed);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("paste text as single lines", () => {
|
describe("paste text as single lines", () => {
|
||||||
it("should create an element for each line when copying with Ctrl/Cmd+V", async () => {
|
it("should create an element for each line when copying with Ctrl/Cmd+V", async () => {
|
||||||
const text = "sajgfakfn\naaksfnknas\nakefnkasf";
|
const text = "sajgfakfn\naaksfnknas\nakefnkasf";
|
||||||
|
|||||||
+7
-14
@@ -1,5 +1,10 @@
|
|||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import { GlobalTestState, render, waitFor } from "./test-utils";
|
import {
|
||||||
|
createPasteEvent,
|
||||||
|
GlobalTestState,
|
||||||
|
render,
|
||||||
|
waitFor,
|
||||||
|
} from "./test-utils";
|
||||||
import { UI, Pointer } from "./helpers/ui";
|
import { UI, Pointer } from "./helpers/ui";
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import { actionFlipHorizontal, actionFlipVertical } from "../actions";
|
import { actionFlipHorizontal, actionFlipVertical } from "../actions";
|
||||||
@@ -680,19 +685,7 @@ describe("freedraw", () => {
|
|||||||
describe("image", () => {
|
describe("image", () => {
|
||||||
const createImage = async () => {
|
const createImage = async () => {
|
||||||
const sendPasteEvent = (file?: File) => {
|
const sendPasteEvent = (file?: File) => {
|
||||||
const clipboardEvent = new Event("paste", {
|
const clipboardEvent = createPasteEvent("", file ? [file] : []);
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
composed: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// set `clipboardData` properties.
|
|
||||||
// @ts-ignore
|
|
||||||
clipboardEvent.clipboardData = {
|
|
||||||
getData: () => window.navigator.clipboard.readText(),
|
|
||||||
files: [file],
|
|
||||||
};
|
|
||||||
|
|
||||||
document.dispatchEvent(clipboardEvent);
|
document.dispatchEvent(clipboardEvent);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { resize, rotate } from "./utils";
|
|||||||
import {
|
import {
|
||||||
getBoundTextElementPosition,
|
getBoundTextElementPosition,
|
||||||
wrapText,
|
wrapText,
|
||||||
getMaxContainerWidth,
|
getBoundTextMaxWidth,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import * as textElementUtils from "../element/textElement";
|
import * as textElementUtils from "../element/textElement";
|
||||||
import { ROUNDNESS, VERTICAL_ALIGN } from "../constants";
|
import { ROUNDNESS, VERTICAL_ALIGN } from "../constants";
|
||||||
@@ -729,10 +729,11 @@ describe("Test Linear Elements", () => {
|
|||||||
type: "text",
|
type: "text",
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
text: wrapText(text, font, getMaxContainerWidth(container)),
|
text: wrapText(text, font, getBoundTextMaxWidth(container)),
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
width: 30,
|
width: 30,
|
||||||
height: 20,
|
height: 20,
|
||||||
|
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||||
}) as ExcalidrawTextElementWithContainer;
|
}) as ExcalidrawTextElementWithContainer;
|
||||||
|
|
||||||
container = {
|
container = {
|
||||||
@@ -1108,9 +1109,17 @@ describe("Test Linear Elements", () => {
|
|||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not render vertical align tool when element selected", () => {
|
it("should render vertical align tool when element selected only for two pointer arrow", () => {
|
||||||
createTwoPointerLinearElement("arrow");
|
let arrow = createTwoPointerLinearElement("arrow");
|
||||||
const arrow = h.elements[0] as ExcalidrawLinearElement;
|
|
||||||
|
createBoundTextElement(DEFAULT_TEXT, arrow);
|
||||||
|
API.setSelectedElements([arrow]);
|
||||||
|
|
||||||
|
expect(queryByTestId(container, "align-top")).not.toBeNull();
|
||||||
|
expect(queryByTestId(container, "align-middle")).not.toBeNull();
|
||||||
|
expect(queryByTestId(container, "align-bottom")).not.toBeNull();
|
||||||
|
|
||||||
|
arrow = createThreePointerLinearElement("arrow");
|
||||||
|
|
||||||
createBoundTextElement(DEFAULT_TEXT, arrow);
|
createBoundTextElement(DEFAULT_TEXT, arrow);
|
||||||
API.setSelectedElements([arrow]);
|
API.setSelectedElements([arrow]);
|
||||||
@@ -1149,7 +1158,7 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(rect.x).toBe(400);
|
expect(rect.x).toBe(400);
|
||||||
expect(rect.y).toBe(0);
|
expect(rect.y).toBe(0);
|
||||||
expect(
|
expect(
|
||||||
wrapText(textElement.originalText, font, getMaxContainerWidth(arrow)),
|
wrapText(textElement.originalText, font, getBoundTextMaxWidth(arrow)),
|
||||||
).toMatchInlineSnapshot(`
|
).toMatchInlineSnapshot(`
|
||||||
"Online whiteboard collaboration
|
"Online whiteboard collaboration
|
||||||
made easy"
|
made easy"
|
||||||
@@ -1172,7 +1181,7 @@ describe("Test Linear Elements", () => {
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
wrapText(textElement.originalText, font, getMaxContainerWidth(arrow)),
|
wrapText(textElement.originalText, font, getBoundTextMaxWidth(arrow)),
|
||||||
).toMatchInlineSnapshot(`
|
).toMatchInlineSnapshot(`
|
||||||
"Online whiteboard
|
"Online whiteboard
|
||||||
collaboration made
|
collaboration made
|
||||||
|
|||||||
@@ -190,3 +190,24 @@ export const toggleMenu = (container: HTMLElement) => {
|
|||||||
// open menu
|
// open menu
|
||||||
fireEvent.click(container.querySelector(".dropdown-menu-button")!);
|
fireEvent.click(container.querySelector(".dropdown-menu-button")!);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createPasteEvent = (
|
||||||
|
text:
|
||||||
|
| string
|
||||||
|
| /* getData function */ ((type: string) => string | Promise<string>),
|
||||||
|
files?: File[],
|
||||||
|
) => {
|
||||||
|
return Object.assign(
|
||||||
|
new Event("paste", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
clipboardData: {
|
||||||
|
getData: typeof text === "string" ? () => text : text,
|
||||||
|
files: files || [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
+4
-4
@@ -29,9 +29,9 @@ import { isOverScrollBars } from "./scene";
|
|||||||
import { MaybeTransformHandleType } from "./element/transformHandles";
|
import { MaybeTransformHandleType } from "./element/transformHandles";
|
||||||
import Library from "./data/library";
|
import Library from "./data/library";
|
||||||
import type { FileSystemHandle } from "./data/filesystem";
|
import type { FileSystemHandle } from "./data/filesystem";
|
||||||
import type { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
|
import type { IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
|
||||||
import { ContextMenuItems } from "./components/ContextMenu";
|
import { ContextMenuItems } from "./components/ContextMenu";
|
||||||
import { Merge, ForwardRef } from "./utility-types";
|
import { Merge, ForwardRef, ValueOf } from "./utility-types";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
export type Point = Readonly<RoughPoint>;
|
export type Point = Readonly<RoughPoint>;
|
||||||
@@ -60,7 +60,7 @@ export type DataURL = string & { _brand: "DataURL" };
|
|||||||
|
|
||||||
export type BinaryFileData = {
|
export type BinaryFileData = {
|
||||||
mimeType:
|
mimeType:
|
||||||
| typeof ALLOWED_IMAGE_MIME_TYPES[number]
|
| ValueOf<typeof IMAGE_MIME_TYPES>
|
||||||
// future user or unknown file type
|
// future user or unknown file type
|
||||||
| typeof MIME_TYPES.binary;
|
| typeof MIME_TYPES.binary;
|
||||||
id: FileId;
|
id: FileId;
|
||||||
@@ -419,7 +419,7 @@ export type AppClassProperties = {
|
|||||||
FileId,
|
FileId,
|
||||||
{
|
{
|
||||||
image: HTMLImageElement | Promise<HTMLImageElement>;
|
image: HTMLImageElement | Promise<HTMLImageElement>;
|
||||||
mimeType: typeof ALLOWED_IMAGE_MIME_TYPES[number];
|
mimeType: ValueOf<typeof IMAGE_MIME_TYPES>;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
files: BinaryFiles;
|
files: BinaryFiles;
|
||||||
|
|||||||
Reference in New Issue
Block a user