Compare commits

..

7 Commits

Author SHA1 Message Date
Ryan Di 73d54e3446 merge: and fix sides change for diamonds 2025-06-25 18:13:01 +10:00
Ryan Di f93b040fff test: snapshots 2025-06-20 17:28:15 +10:00
Ryan Di 53cae28d2a fix: lint 2025-06-20 17:26:46 +10:00
Ryan Di 70e515f560 fix: inconsistency between preview and actual insertion 2025-06-20 17:25:58 +10:00
Ryan Di 5516e7c819 feat: bind to mid point 2025-06-20 17:21:55 +10:00
Ryan Di 6eb93b281a feat: fit text to bound container 2025-06-02 17:51:19 +10:00
Ryan Di 2a50000ec8 feat: add toggle to use elbow 2025-06-02 17:51:19 +10:00
28 changed files with 1169 additions and 616 deletions
+1 -1
View File
@@ -24,4 +24,4 @@ jobs:
- name: Auto release
run: |
yarn add @actions/core -W
yarn release --tag=next --non-interactive
yarn autorelease
+55
View File
@@ -0,0 +1,55 @@
name: Auto release excalidraw preview
on:
issue_comment:
types: [created, edited]
jobs:
Auto-release-excalidraw-preview:
name: Auto release preview
if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request
runs-on: ubuntu-latest
steps:
- name: React to release comment
uses: peter-evans/create-or-update-comment@v1
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
comment-id: ${{ github.event.comment.id }}
reactions: "+1"
- name: Get PR SHA
id: sha
uses: actions/github-script@v4
with:
result-encoding: string
script: |
const { owner, repo, number } = context.issue;
const pr = await github.pulls.get({
owner,
repo,
pull_number: number,
});
return pr.data.head.sha
- uses: actions/checkout@v2
with:
ref: ${{ steps.sha.outputs.result }}
fetch-depth: 2
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
with:
node-version: 18.x
- name: Set up publish access
run: |
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Auto release preview
id: "autorelease"
run: |
yarn add @actions/core -W
yarn autorelease preview ${{ github.event.issue.number }}
- name: Post comment post release
if: always()
uses: peter-evans/create-or-update-comment@v1
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
issue-number: ${{ github.event.issue.number }}
body: "@${{ github.event.comment.user.login }} ${{ steps.autorelease.outputs.result }}"
@@ -28,12 +28,32 @@ To start the example app using the `@excalidraw/excalidraw` package, follow the
## Releasing
### Create a test release
You can create a test release by posting the below comment in your pull request:
```bash
@excalibot trigger release
```
Once the version is released `@excalibot` will post a comment with the release version.
### Creating a production release
To release the next stable version follow the below steps:
```bash
yarn release --tag=latest --version=0.19.0
yarn prerelease:excalidraw
```
You will need to pass the `latest` tag with `version` for which you want to create the release. This will make the changes needed before publishing the packages into NPM, like updating dependencies of all `@excalidraw/*` packages, generating new entries in `CHANGELOG.md` and more.
You need to pass the `version` for which you want to create the release. This will make the changes needed before making the release like updating `package.json`, `changelog` and more.
The next step is to run the `release` script:
```bash
yarn release:excalidraw
```
This will publish the package.
Right now there are two steps to create a production release but once this works fine these scripts will be combined and more automation will be done.
+1 -2
View File
@@ -3,8 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"build:packages": "yarn --cwd ../../ build:packages",
"build:workspace": "yarn build:packages && yarn copy:assets",
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp -r ../../packages/excalidraw/dist/prod/fonts ./public",
"dev": "yarn build:workspace && next dev -p 3005",
"build": "yarn build:workspace && next build",
+1 -1
View File
@@ -17,6 +17,6 @@
"build": "vite build",
"preview": "vite preview --port 5002",
"build:preview": "yarn build && yarn preview",
"build:packages": "yarn --cwd ../../ build:packages"
"build:package": "yarn workspace @excalidraw/excalidraw run build:esm"
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"outputDirectory": "dist",
"installCommand": "yarn install",
"buildCommand": "yarn build:packages && yarn build"
"buildCommand": "yarn build:package && yarn build"
}
+5 -10
View File
@@ -52,17 +52,13 @@
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
"build:app": "yarn --cwd ./excalidraw-app build:app",
"build:common": "yarn --cwd ./packages/common build:esm",
"build:element": "yarn --cwd ./packages/element build:esm",
"build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm",
"build:math": "yarn --cwd ./packages/math build:esm",
"build:packages": "yarn build:common && yarn build:math && yarn build:element && yarn build:excalidraw",
"build:package": "yarn --cwd ./packages/excalidraw build:esm",
"build:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build",
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
"start": "yarn --cwd ./excalidraw-app start",
"start:production": "yarn --cwd ./excalidraw-app start:production",
"start:example": "yarn build:packages && yarn --cwd ./examples/with-script-in-browser start",
"start:example": "yarn build:package && yarn --cwd ./examples/with-script-in-browser start",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
"test:app": "vitest",
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
@@ -80,10 +76,9 @@
"locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"release": "node scripts/release.js",
"release:test": "node scripts/release.js --tag=test",
"release:next": "node scripts/release.js --tag=next",
"release:latest": "node scripts/release.js --tag=latest",
"autorelease": "node scripts/autorelease.js",
"prerelease:excalidraw": "node scripts/prerelease.js",
"release:excalidraw": "node scripts/release.js",
"rm:build": "rimraf --glob excalidraw-app/build excalidraw-app/dist excalidraw-app/dev-dist packages/*/dist packages/*/build examples/*/build examples/*/dist",
"rm:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules",
"clean-install": "yarn rm:node_modules && yarn install"
+2 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@excalidraw/common",
"version": "0.18.0",
"version": "0.1.0",
"type": "module",
"types": "./dist/types/common/src/index.d.ts",
"main": "./dist/prod/index.js",
@@ -13,10 +13,7 @@
"default": "./dist/prod/index.js"
},
"./*": {
"types": "./dist/types/common/src/*.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
"types": "./dist/types/common/src/*.d.ts"
}
},
"files": [
+2 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@excalidraw/element",
"version": "0.18.0",
"version": "0.1.0",
"type": "module",
"types": "./dist/types/element/src/index.d.ts",
"main": "./dist/prod/index.js",
@@ -13,10 +13,7 @@
"default": "./dist/prod/index.js"
},
"./*": {
"types": "./dist/types/element/src/*.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
"types": "./dist/types/element/src/*.d.ts"
}
},
"files": [
@@ -55,9 +52,5 @@
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
},
"dependencies": {
"@excalidraw/common": "0.18.0",
"@excalidraw/math": "0.18.0"
}
}
+438
View File
@@ -56,6 +56,7 @@ import {
isFixedPointBinding,
isFrameLikeElement,
isLinearElement,
isRectangularElement,
isRectanguloidElement,
isTextElement,
} from "./typeChecks";
@@ -63,6 +64,11 @@ import {
import { aabbForElement, elementCenterPoint } from "./bounds";
import { updateElbowArrowPoints } from "./elbowArrow";
import {
deconstructDiamondElement,
deconstructRectanguloidElement,
} from "./utils";
import type { Scene } from "./Scene";
import type { Bounds } from "./bounds";
@@ -82,6 +88,7 @@ import type {
FixedPoint,
FixedPointBinding,
PointsPositionUpdates,
ExcalidrawRectanguloidElement,
} from "./types";
export type SuggestedBinding =
@@ -2279,3 +2286,434 @@ export const normalizeFixedPoint = <T extends FixedPoint | null>(
}
return fixedPoint as any as T extends null ? null : FixedPoint;
};
type Side =
| "top"
| "top-right"
| "right"
| "bottom-right"
| "bottom"
| "bottom-left"
| "left"
| "top-left";
type ShapeType = "rectangle" | "ellipse" | "diamond";
const getShapeType = (element: ExcalidrawBindableElement): ShapeType => {
if (element.type === "ellipse" || element.type === "diamond") {
return element.type;
}
return "rectangle";
};
interface SectorConfig {
// center angle of the sector in degrees
centerAngle: number;
// width of the sector in degrees
sectorWidth: number;
side: Side;
}
// Define sector configurations for different shape types
const SHAPE_CONFIGS: Record<ShapeType, SectorConfig[]> = {
// rectangle: 15° corners, 75° edges
rectangle: [
{ centerAngle: 0, sectorWidth: 75, side: "right" },
{ centerAngle: 45, sectorWidth: 15, side: "bottom-right" },
{ centerAngle: 90, sectorWidth: 75, side: "bottom" },
{ centerAngle: 135, sectorWidth: 15, side: "bottom-left" },
{ centerAngle: 180, sectorWidth: 75, side: "left" },
{ centerAngle: 225, sectorWidth: 15, side: "top-left" },
{ centerAngle: 270, sectorWidth: 75, side: "top" },
{ centerAngle: 315, sectorWidth: 15, side: "top-right" },
],
// diamond: 15° vertices, 75° edges
diamond: [
{ centerAngle: 0, sectorWidth: 15, side: "right" },
{ centerAngle: 45, sectorWidth: 75, side: "bottom-right" },
{ centerAngle: 90, sectorWidth: 15, side: "bottom" },
{ centerAngle: 135, sectorWidth: 75, side: "bottom-left" },
{ centerAngle: 180, sectorWidth: 15, side: "left" },
{ centerAngle: 225, sectorWidth: 75, side: "top-left" },
{ centerAngle: 270, sectorWidth: 15, side: "top" },
{ centerAngle: 315, sectorWidth: 75, side: "top-right" },
],
// ellipse: 15° cardinal points, 75° diagonals
ellipse: [
{ centerAngle: 0, sectorWidth: 15, side: "right" },
{ centerAngle: 45, sectorWidth: 75, side: "bottom-right" },
{ centerAngle: 90, sectorWidth: 15, side: "bottom" },
{ centerAngle: 135, sectorWidth: 75, side: "bottom-left" },
{ centerAngle: 180, sectorWidth: 15, side: "left" },
{ centerAngle: 225, sectorWidth: 75, side: "top-left" },
{ centerAngle: 270, sectorWidth: 15, side: "top" },
{ centerAngle: 315, sectorWidth: 75, side: "top-right" },
],
};
const getSectorBoundaries = (
config: SectorConfig[],
): Array<{ start: number; end: number; side: Side }> => {
return config.map((sector, index) => {
const halfWidth = sector.sectorWidth / 2;
let start = sector.centerAngle - halfWidth;
let end = sector.centerAngle + halfWidth;
// normalize angles to [0, 360) range
start = ((start % 360) + 360) % 360;
end = ((end % 360) + 360) % 360;
return { start, end, side: sector.side };
});
};
// determine which side a point falls into using adaptive sectors
const getShapeSideAdaptive = (
fixedPoint: FixedPoint,
shapeType: ShapeType,
): Side => {
const [x, y] = fixedPoint;
// convert to centered coordinates
const centerX = x - 0.5;
const centerY = y - 0.5;
// calculate angle
let angle = Math.atan2(centerY, centerX);
if (angle < 0) {
angle += 2 * Math.PI;
}
const degrees = (angle * 180) / Math.PI;
// get sector configuration for this shape type
const config = SHAPE_CONFIGS[shapeType];
const boundaries = getSectorBoundaries(config);
// find which sector the angle falls into
for (const boundary of boundaries) {
if (boundary.start <= boundary.end) {
// Normal case: sector doesn't cross 0°
if (degrees >= boundary.start && degrees <= boundary.end) {
return boundary.side;
}
} else if (degrees >= boundary.start || degrees <= boundary.end) {
return boundary.side;
}
}
// fallback - find nearest sector center
let minDiff = Infinity;
let nearestSide = config[0].side;
for (const sector of config) {
let diff = Math.abs(degrees - sector.centerAngle);
// handle wraparound
if (diff > 180) {
diff = 360 - diff;
}
if (diff < minDiff) {
minDiff = diff;
nearestSide = sector.side;
}
}
return nearestSide;
};
export const getBindingSideMidPoint = (
binding: FixedPointBinding,
elementsMap: ElementsMap,
) => {
const bindableElement = elementsMap.get(binding.elementId);
if (
!bindableElement ||
bindableElement.isDeleted ||
!isBindableElement(bindableElement)
) {
return null;
}
const center = elementCenterPoint(bindableElement, elementsMap);
const shapeType = getShapeType(bindableElement);
const side = getShapeSideAdaptive(
normalizeFixedPoint(binding.fixedPoint),
shapeType,
);
// small offset to avoid precision issues in elbow
const OFFSET = 0.01;
if (bindableElement.type === "diamond") {
const [sides, corners] = deconstructDiamondElement(bindableElement);
const [bottomRight, bottomLeft, topLeft, topRight] = sides;
let x: number;
let y: number;
switch (side) {
case "left": {
// left vertex - use the center of the left corner curve
if (corners.length >= 3) {
const leftCorner = corners[2];
const midPoint = leftCorner[1];
x = midPoint[0] - OFFSET;
y = midPoint[1];
} else {
// fallback for non-rounded diamond
const midPoint = getMidPoint(bottomLeft[1], topLeft[0]);
x = midPoint[0] - OFFSET;
y = midPoint[1];
}
break;
}
case "right": {
if (corners.length >= 1) {
const rightCorner = corners[0];
const midPoint = rightCorner[1];
x = midPoint[0] + OFFSET;
y = midPoint[1];
} else {
const midPoint = getMidPoint(topRight[1], bottomRight[0]);
x = midPoint[0] + OFFSET;
y = midPoint[1];
}
break;
}
case "top": {
if (corners.length >= 4) {
const topCorner = corners[3];
const midPoint = topCorner[1];
x = midPoint[0];
y = midPoint[1] - OFFSET;
} else {
const midPoint = getMidPoint(topLeft[1], topRight[0]);
x = midPoint[0];
y = midPoint[1] - OFFSET;
}
break;
}
case "bottom": {
if (corners.length >= 2) {
const bottomCorner = corners[1];
const midPoint = bottomCorner[1];
x = midPoint[0];
y = midPoint[1] + OFFSET;
} else {
const midPoint = getMidPoint(bottomRight[1], bottomLeft[0]);
x = midPoint[0];
y = midPoint[1] + OFFSET;
}
break;
}
case "top-right": {
const midPoint = getMidPoint(topRight[0], topRight[1]);
x = midPoint[0] + OFFSET * 0.707;
y = midPoint[1] - OFFSET * 0.707;
break;
}
case "bottom-right": {
const midPoint = getMidPoint(bottomRight[0], bottomRight[1]);
x = midPoint[0] + OFFSET * 0.707;
y = midPoint[1] + OFFSET * 0.707;
break;
}
case "bottom-left": {
const midPoint = getMidPoint(bottomLeft[0], bottomLeft[1]);
x = midPoint[0] - OFFSET * 0.707;
y = midPoint[1] + OFFSET * 0.707;
break;
}
case "top-left": {
const midPoint = getMidPoint(topLeft[0], topLeft[1]);
x = midPoint[0] - OFFSET * 0.707;
y = midPoint[1] - OFFSET * 0.707;
break;
}
default: {
return null;
}
}
return pointRotateRads(pointFrom(x, y), center, bindableElement.angle);
}
if (bindableElement.type === "ellipse") {
const ellipseCenterX = bindableElement.x + bindableElement.width / 2;
const ellipseCenterY = bindableElement.y + bindableElement.height / 2;
const radiusX = bindableElement.width / 2;
const radiusY = bindableElement.height / 2;
let x: number;
let y: number;
switch (side) {
case "top": {
x = ellipseCenterX;
y = ellipseCenterY - radiusY - OFFSET;
break;
}
case "right": {
x = ellipseCenterX + radiusX + OFFSET;
y = ellipseCenterY;
break;
}
case "bottom": {
x = ellipseCenterX;
y = ellipseCenterY + radiusY + OFFSET;
break;
}
case "left": {
x = ellipseCenterX - radiusX - OFFSET;
y = ellipseCenterY;
break;
}
case "top-right": {
const angle = -Math.PI / 4;
const ellipseX = radiusX * Math.cos(angle);
const ellipseY = radiusY * Math.sin(angle);
x = ellipseCenterX + ellipseX + OFFSET * 0.707;
y = ellipseCenterY + ellipseY - OFFSET * 0.707;
break;
}
case "bottom-right": {
const angle = Math.PI / 4;
const ellipseX = radiusX * Math.cos(angle);
const ellipseY = radiusY * Math.sin(angle);
x = ellipseCenterX + ellipseX + OFFSET * 0.707;
y = ellipseCenterY + ellipseY + OFFSET * 0.707;
break;
}
case "bottom-left": {
const angle = (3 * Math.PI) / 4;
const ellipseX = radiusX * Math.cos(angle);
const ellipseY = radiusY * Math.sin(angle);
x = ellipseCenterX + ellipseX - OFFSET * 0.707;
y = ellipseCenterY + ellipseY + OFFSET * 0.707;
break;
}
case "top-left": {
const angle = (-3 * Math.PI) / 4;
const ellipseX = radiusX * Math.cos(angle);
const ellipseY = radiusY * Math.sin(angle);
x = ellipseCenterX + ellipseX - OFFSET * 0.707;
y = ellipseCenterY + ellipseY - OFFSET * 0.707;
break;
}
default: {
return null;
}
}
return pointRotateRads(pointFrom(x, y), center, bindableElement.angle);
}
if (isRectangularElement(bindableElement)) {
const [sides, corners] = deconstructRectanguloidElement(
bindableElement as ExcalidrawRectanguloidElement,
);
const [top, right, bottom, left] = sides;
let x: number;
let y: number;
switch (side) {
case "top": {
const midPoint = getMidPoint(top[0], top[1]);
x = midPoint[0];
y = midPoint[1] - OFFSET;
break;
}
case "right": {
const midPoint = getMidPoint(right[0], right[1]);
x = midPoint[0] + OFFSET;
y = midPoint[1];
break;
}
case "bottom": {
const midPoint = getMidPoint(bottom[0], bottom[1]);
x = midPoint[0];
y = midPoint[1] + OFFSET;
break;
}
case "left": {
const midPoint = getMidPoint(left[0], left[1]);
x = midPoint[0] - OFFSET;
y = midPoint[1];
break;
}
case "top-left": {
if (corners.length >= 1) {
const corner = corners[0];
const p1 = corner[0];
const p2 = corner[3];
const midPoint = getMidPoint(p1, p2);
x = midPoint[0] - OFFSET * 0.707;
y = midPoint[1] - OFFSET * 0.707;
} else {
x = bindableElement.x - OFFSET;
y = bindableElement.y - OFFSET;
}
break;
}
case "top-right": {
if (corners.length >= 2) {
const corner = corners[1];
const p1 = corner[0];
const p2 = corner[3];
const midPoint = getMidPoint(p1, p2);
x = midPoint[0] + OFFSET * 0.707;
y = midPoint[1] - OFFSET * 0.707;
} else {
x = bindableElement.x + bindableElement.width + OFFSET;
y = bindableElement.y - OFFSET;
}
break;
}
case "bottom-right": {
if (corners.length >= 3) {
const corner = corners[2];
const p1 = corner[0];
const p2 = corner[3];
const midPoint = getMidPoint(p1, p2);
x = midPoint[0] + OFFSET * 0.707;
y = midPoint[1] + OFFSET * 0.707;
} else {
x = bindableElement.x + bindableElement.width + OFFSET;
y = bindableElement.y + bindableElement.height + OFFSET;
}
break;
}
case "bottom-left": {
if (corners.length >= 4) {
const corner = corners[3];
const p1 = corner[0];
const p2 = corner[3];
const midPoint = getMidPoint(p1, p2);
x = midPoint[0] - OFFSET * 0.707;
y = midPoint[1] + OFFSET * 0.707;
} else {
x = bindableElement.x - OFFSET;
y = bindableElement.y + bindableElement.height + OFFSET;
}
break;
}
default: {
return null;
}
}
return pointRotateRads(pointFrom(x, y), center, bindableElement.angle);
}
return null;
};
const getMidPoint = (p1: GlobalPoint, p2: GlobalPoint): GlobalPoint => {
return pointFrom((p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2);
};
+1 -7
View File
@@ -593,10 +593,6 @@ class App extends React.Component<AppProps, AppState> {
* insert to DOM before user initially scrolls to them) */
private initializedEmbeds = new Set<ExcalidrawIframeLikeElement["id"]>();
private handleToastClose = () => {
this.setToast(null);
};
private elementsPendingErasure: ElementsPendingErasure = new Set();
public flowChartCreator: FlowChartCreator = new FlowChartCreator();
@@ -1711,16 +1707,14 @@ class App extends React.Component<AppProps, AppState> {
/>
</ElementCanvasButtons>
)}
{this.state.toast !== null && (
<Toast
message={this.state.toast.message}
onClose={this.handleToastClose}
onClose={() => this.setToast(null)}
duration={this.state.toast.duration}
closable={this.state.toast.closable}
/>
)}
{this.state.contextMenu && (
<ContextMenu
items={this.state.contextMenu.items}
@@ -366,15 +366,14 @@ export const adjustBoundTextSize = (
container: ExcalidrawTextContainer,
boundText: ExcalidrawTextElementWithContainer,
scene: Scene,
wrapTextFirst = true,
) => {
const maxWidth = getBoundTextMaxWidth(container, boundText);
const maxHeight = getBoundTextMaxHeight(container, boundText);
const wrappedText = wrapText(
boundText.text,
getFontString(boundText),
maxWidth,
);
const wrappedText = wrapTextFirst
? wrapText(boundText.text, getFontString(boundText), maxWidth)
: boundText.originalText;
let metrics = measureText(
wrappedText,
@@ -6,5 +6,19 @@
font-weight: 700;
padding-inline: 2.5rem;
}
&-arrow-type {
display: flex;
flex-direction: row;
align-items: center;
gap: 8;
// height: 2rem;
svg {
width: 1rem;
height: 1rem;
display: block;
}
}
}
}
@@ -5,11 +5,13 @@ import { EDITOR_LS_KEYS, debounce, isDevEnv } from "@excalidraw/common";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { useApp } from "../App";
import { ArrowRightIcon } from "../icons";
import { ArrowRightIcon, elbowArrowIcon, roundArrowIcon } from "../icons";
import { EditorLocalStorage } from "../../data/EditorLocalStorage";
import { t } from "../../i18n";
import Trans from "../Trans";
import { RadioGroup } from "../RadioGroup";
import { TTDDialogInput } from "./TTDDialogInput";
import { TTDDialogOutput } from "./TTDDialogOutput";
import { TTDDialogPanel } from "./TTDDialogPanel";
@@ -43,6 +45,7 @@ const MermaidToExcalidraw = ({
);
const deferredText = useDeferredValue(text.trim());
const [error, setError] = useState<Error | null>(null);
const [arrowType, setArrowType] = useState<"arrow" | "elbow">("arrow");
const canvasRef = useRef<HTMLDivElement>(null);
const data = useRef<{
@@ -59,6 +62,7 @@ const MermaidToExcalidraw = ({
mermaidToExcalidrawLib,
setError,
mermaidDefinition: deferredText,
useElbow: arrowType === "elbow",
}).catch((err) => {
if (isDevEnv()) {
console.error("Failed to parse mermaid definition", err);
@@ -66,7 +70,7 @@ const MermaidToExcalidraw = ({
});
debouncedSaveMermaidDefinition(deferredText);
}, [deferredText, mermaidToExcalidrawLib]);
}, [deferredText, mermaidToExcalidrawLib, arrowType]);
useEffect(
() => () => {
@@ -76,6 +80,19 @@ const MermaidToExcalidraw = ({
);
const onInsertToEditor = () => {
convertMermaidToExcalidraw({
canvasRef,
data,
mermaidToExcalidrawLib,
setError,
mermaidDefinition: deferredText,
useElbow: arrowType === "elbow",
}).catch((err) => {
if (isDevEnv()) {
console.error("Failed to parse mermaid definition", err);
}
});
insertToEditor({
app,
data,
@@ -123,6 +140,29 @@ const MermaidToExcalidraw = ({
icon: ArrowRightIcon,
}}
renderSubmitShortcut={() => <TTDDialogSubmitShortcut />}
renderBottomRight={() => (
<div className="dialog-mermaid-arrow-type">
<RadioGroup
name={"mermaid arrow config"}
value={arrowType}
onChange={(value: "arrow" | "elbow") => {
setArrowType(value);
}}
choices={[
{
value: "arrow",
label: roundArrowIcon,
ariaLabel: `${t("labels.arrowtype_round")}`,
},
{
value: "elbow",
label: elbowArrowIcon,
ariaLabel: `${t("labels.arrowtype_elbowed")}`,
},
]}
/>
</div>
)}
>
<TTDDialogOutput
canvasRef={canvasRef}
@@ -234,6 +234,11 @@ $verticalBreakpoint: 861px;
}
}
.ttd-dialog-panel-button-container-left {
display: flex;
align-items: center;
}
.ttd-dialog-panel-button {
&.excalidraw-button {
font-family: inherit;
@@ -42,22 +42,28 @@ export const TTDDialogPanel = ({
className={clsx("ttd-dialog-panel-button-container", {
invisible: !panelAction,
})}
style={{ display: "flex", alignItems: "center" }}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Button
className="ttd-dialog-panel-button"
onSelect={panelAction ? panelAction.action : () => {}}
disabled={panelActionDisabled || onTextSubmitInProgess}
>
<div className={clsx({ invisible: onTextSubmitInProgess })}>
{panelAction?.label}
{panelAction?.icon && <span>{panelAction.icon}</span>}
</div>
{onTextSubmitInProgess && <Spinner />}
</Button>
{!panelActionDisabled &&
!onTextSubmitInProgess &&
renderSubmitShortcut?.()}
<div className="ttd-dialog-panel-button-container-left">
<Button
className="ttd-dialog-panel-button"
onSelect={panelAction ? panelAction.action : () => {}}
disabled={panelActionDisabled || onTextSubmitInProgess}
>
<div className={clsx({ invisible: onTextSubmitInProgess })}>
{panelAction?.label}
{panelAction?.icon && <span>{panelAction.icon}</span>}
</div>
{onTextSubmitInProgess && <Spinner />}
</Button>
{!panelActionDisabled &&
!onTextSubmitInProgess &&
renderSubmitShortcut?.()}
</div>
{renderBottomRight?.()}
</div>
</div>
@@ -52,6 +52,7 @@ interface ConvertMermaidToExcalidrawFormatProps {
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>;
useElbow?: boolean;
}
export const convertMermaidToExcalidraw = async ({
@@ -60,6 +61,7 @@ export const convertMermaidToExcalidraw = async ({
mermaidDefinition,
setError,
data,
useElbow = false,
}: ConvertMermaidToExcalidrawFormatProps) => {
const canvasNode = canvasRef.current;
const parent = canvasNode?.parentElement;
@@ -87,9 +89,11 @@ export const convertMermaidToExcalidraw = async ({
const { elements, files } = ret;
setError(null);
// Store the converted elements (which now include adjusted elbow arrow points)
data.current = {
elements: convertToExcalidrawElements(elements, {
regenerateIds: true,
useElbow,
}),
files,
};
@@ -314,7 +314,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"versionNonce": Any<Number>,
"verticalAlign": "top",
"width": 100,
"x": 560,
"x": 355,
"y": 226.5,
}
`;
@@ -334,8 +334,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endArrowhead": "arrow",
"endBinding": {
"elementId": "text-2",
"focus": 0,
"gap": 16,
"focus": -0,
"gap": 1,
},
"fillStyle": "solid",
"frameId": null,
@@ -2141,10 +2141,10 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontSize": 20,
"fontSize": 0,
"frameId": null,
"groupIds": [],
"height": 50,
"height": 0,
"id": Any<String>,
"index": "a6",
"isDeleted": false,
@@ -2159,17 +2159,19 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"strokeColor": "#1098ad",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "ANOTHER STYLED
LABELLED ARROW",
"text": "ANOTHER
STYLED
LABELLE
D ARROW",
"textAlign": "center",
"type": "text",
"updated": 1,
"version": 3,
"version": 4,
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 140,
"x": 80,
"y": 275,
"width": 70,
"x": 115,
"y": 300,
}
`;
@@ -2183,10 +2185,10 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontSize": 20,
"fontSize": 0,
"frameId": null,
"groupIds": [],
"height": 50,
"height": 0,
"id": Any<String>,
"index": "a7",
"isDeleted": false,
@@ -2201,17 +2203,19 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"strokeColor": "#099268",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "ANOTHER STYLED
LABELLED ARROW",
"text": "ANOTHER
STYLED
LABELLE
D ARROW",
"textAlign": "center",
"type": "text",
"updated": 1,
"version": 3,
"version": 4,
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 140,
"x": 80,
"y": 375,
"width": 70,
"x": 115,
"y": 400,
}
`;
@@ -2229,7 +2233,7 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 35,
"height": 10,
"id": Any<String>,
"index": "a0",
"isDeleted": false,
@@ -2266,7 +2270,7 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 85,
"height": 14,
"id": Any<String>,
"index": "a1",
"isDeleted": false,
@@ -2303,7 +2307,7 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 170,
"height": 20,
"id": Any<String>,
"index": "a2",
"isDeleted": false,
@@ -2340,7 +2344,7 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 120,
"height": 20,
"id": Any<String>,
"index": "a3",
"isDeleted": false,
@@ -2377,7 +2381,7 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 85,
"height": 10,
"id": Any<String>,
"index": "a4",
"isDeleted": false,
@@ -2414,7 +2418,7 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 120,
"height": 14,
"id": Any<String>,
"index": "a5",
"isDeleted": false,
@@ -2447,10 +2451,10 @@ exports[`Test Transform > should transform to text containers when label provide
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontSize": 20,
"fontSize": 0,
"frameId": null,
"groupIds": [],
"height": 25,
"height": 0,
"id": Any<String>,
"index": "a6",
"isDeleted": false,
@@ -2469,7 +2473,7 @@ exports[`Test Transform > should transform to text containers when label provide
"textAlign": "center",
"type": "text",
"updated": 1,
"version": 3,
"version": 4,
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 240,
@@ -2488,10 +2492,10 @@ exports[`Test Transform > should transform to text containers when label provide
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontSize": 20,
"fontSize": 0,
"frameId": null,
"groupIds": [],
"height": 50,
"height": 0,
"id": Any<String>,
"index": "a7",
"isDeleted": false,
@@ -2511,12 +2515,12 @@ CONTAINER",
"textAlign": "center",
"type": "text",
"updated": 1,
"version": 3,
"version": 4,
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 120,
"x": 539.7893218813452,
"y": 117.44796179957173,
"y": 107.05025253169417,
}
`;
@@ -2530,10 +2534,10 @@ exports[`Test Transform > should transform to text containers when label provide
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontSize": 20,
"fontSize": 0,
"frameId": null,
"groupIds": [],
"height": 75,
"height": 0,
"id": Any<String>,
"index": "a8",
"isDeleted": false,
@@ -2555,12 +2559,12 @@ CONTAINER",
"textAlign": "center",
"type": "text",
"updated": 1,
"version": 3,
"version": 4,
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 90,
"x": 195,
"y": 197.5,
"y": 160,
}
`;
@@ -2574,10 +2578,10 @@ exports[`Test Transform > should transform to text containers when label provide
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontSize": 20,
"fontSize": 0,
"frameId": null,
"groupIds": [],
"height": 50,
"height": 0,
"id": Any<String>,
"index": "a9",
"isDeleted": false,
@@ -2597,12 +2601,12 @@ TEXT CONTAINER",
"textAlign": "center",
"type": "text",
"updated": 1,
"version": 3,
"version": 4,
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 140,
"x": 180,
"y": 435,
"y": 410,
}
`;
@@ -2616,10 +2620,10 @@ exports[`Test Transform > should transform to text containers when label provide
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontSize": 20,
"fontSize": 0,
"frameId": null,
"groupIds": [],
"height": 75,
"height": 0,
"id": Any<String>,
"index": "aA",
"isDeleted": false,
@@ -2640,7 +2644,7 @@ CONTAINER",
"textAlign": "left",
"type": "text",
"updated": 1,
"version": 3,
"version": 4,
"versionNonce": Any<Number>,
"verticalAlign": "top",
"width": 160,
@@ -2659,10 +2663,10 @@ exports[`Test Transform > should transform to text containers when label provide
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontSize": 20,
"fontSize": 0,
"frameId": null,
"groupIds": [],
"height": 75,
"height": 0,
"id": Any<String>,
"index": "aB",
"isDeleted": false,
@@ -2683,11 +2687,11 @@ CONTAINER",
"textAlign": "center",
"type": "text",
"updated": 1,
"version": 3,
"version": 4,
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 120,
"x": 539.7893218813452,
"y": 522.5735931288071,
"y": 507.0502525316942,
}
`;
+351 -254
View File
@@ -16,7 +16,12 @@ import {
getLineHeight,
} from "@excalidraw/common";
import { bindLinearElement } from "@excalidraw/element";
import {
bindLinearElement,
calculateFixedPointForElbowArrowBinding,
getBindingSideMidPoint,
isElbowArrow,
} from "@excalidraw/element";
import {
newArrowElement,
newElement,
@@ -31,8 +36,6 @@ import { isArrowElement } from "@excalidraw/element";
import { syncInvalidIndices } from "@excalidraw/element";
import { redrawTextBoundingBox } from "@excalidraw/element";
import { LinearElementEditor } from "@excalidraw/element";
import { getCommonBounds } from "@excalidraw/element";
@@ -63,6 +66,8 @@ import type {
import type { MarkOptional } from "@excalidraw/common/utility-types";
import { adjustBoundTextSize } from "../components/ConvertElementTypePopup";
export type ValidLinearElement = {
type: "arrow" | "line";
x: number;
@@ -238,247 +243,11 @@ const bindTextToContainer = (
}),
});
redrawTextBoundingBox(textElement, container, scene);
adjustBoundTextSize(container as any, textElement as any, scene, false);
return [container, textElement] as const;
};
const bindLinearElementToElement = (
linearElement: ExcalidrawArrowElement,
start: ValidLinearElement["start"],
end: ValidLinearElement["end"],
elementStore: ElementStore,
scene: Scene,
): {
linearElement: ExcalidrawLinearElement;
startBoundElement?: ExcalidrawElement;
endBoundElement?: ExcalidrawElement;
} => {
let startBoundElement;
let endBoundElement;
Object.assign(linearElement, {
startBinding: linearElement?.startBinding || null,
endBinding: linearElement.endBinding || null,
});
if (start) {
const width = start?.width ?? DEFAULT_DIMENSION;
const height = start?.height ?? DEFAULT_DIMENSION;
let existingElement;
if (start.id) {
existingElement = elementStore.getElement(start.id);
if (!existingElement) {
console.error(`No element for start binding with id ${start.id} found`);
}
}
const startX = start.x || linearElement.x - width;
const startY = start.y || linearElement.y - height / 2;
const startType = existingElement ? existingElement.type : start.type;
if (startType) {
if (startType === "text") {
let text = "";
if (existingElement && existingElement.type === "text") {
text = existingElement.text;
} else if (start.type === "text") {
text = start.text;
}
if (!text) {
console.error(
`No text found for start binding text element for ${linearElement.id}`,
);
}
startBoundElement = newTextElement({
x: startX,
y: startY,
type: "text",
...existingElement,
...start,
text,
});
// to position the text correctly when coordinates not provided
Object.assign(startBoundElement, {
x: start.x || linearElement.x - startBoundElement.width,
y: start.y || linearElement.y - startBoundElement.height / 2,
});
} else {
switch (startType) {
case "rectangle":
case "ellipse":
case "diamond": {
startBoundElement = newElement({
x: startX,
y: startY,
width,
height,
...existingElement,
...start,
type: startType,
});
break;
}
default: {
assertNever(
linearElement as never,
`Unhandled element start type "${start.type}"`,
true,
);
}
}
}
bindLinearElement(
linearElement,
startBoundElement as ExcalidrawBindableElement,
"start",
scene,
);
}
}
if (end) {
const height = end?.height ?? DEFAULT_DIMENSION;
const width = end?.width ?? DEFAULT_DIMENSION;
let existingElement;
if (end.id) {
existingElement = elementStore.getElement(end.id);
if (!existingElement) {
console.error(`No element for end binding with id ${end.id} found`);
}
}
const endX = end.x || linearElement.x + linearElement.width;
const endY = end.y || linearElement.y - height / 2;
const endType = existingElement ? existingElement.type : end.type;
if (endType) {
if (endType === "text") {
let text = "";
if (existingElement && existingElement.type === "text") {
text = existingElement.text;
} else if (end.type === "text") {
text = end.text;
}
if (!text) {
console.error(
`No text found for end binding text element for ${linearElement.id}`,
);
}
endBoundElement = newTextElement({
x: endX,
y: endY,
type: "text",
...existingElement,
...end,
text,
});
// to position the text correctly when coordinates not provided
Object.assign(endBoundElement, {
y: end.y || linearElement.y - endBoundElement.height / 2,
});
} else {
switch (endType) {
case "rectangle":
case "ellipse":
case "diamond": {
endBoundElement = newElement({
x: endX,
y: endY,
width,
height,
...existingElement,
...end,
type: endType,
});
break;
}
default: {
assertNever(
linearElement as never,
`Unhandled element end type "${endType}"`,
true,
);
}
}
}
bindLinearElement(
linearElement,
endBoundElement as ExcalidrawBindableElement,
"end",
scene,
);
}
}
// Safe check to early return for single point
if (linearElement.points.length < 2) {
return {
linearElement,
startBoundElement,
endBoundElement,
};
}
// Update start/end points by 0.5 so bindings don't overlap with start/end bound element coordinates.
const endPointIndex = linearElement.points.length - 1;
const delta = 0.5;
const newPoints = cloneJSON<readonly LocalPoint[]>(linearElement.points);
// left to right so shift the arrow towards right
if (
linearElement.points[endPointIndex][0] >
linearElement.points[endPointIndex - 1][0]
) {
newPoints[0][0] = delta;
newPoints[endPointIndex][0] -= delta;
}
// right to left so shift the arrow towards left
if (
linearElement.points[endPointIndex][0] <
linearElement.points[endPointIndex - 1][0]
) {
newPoints[0][0] = -delta;
newPoints[endPointIndex][0] += delta;
}
// top to bottom so shift the arrow towards top
if (
linearElement.points[endPointIndex][1] >
linearElement.points[endPointIndex - 1][1]
) {
newPoints[0][1] = delta;
newPoints[endPointIndex][1] -= delta;
}
// bottom to top so shift the arrow towards bottom
if (
linearElement.points[endPointIndex][1] <
linearElement.points[endPointIndex - 1][1]
) {
newPoints[0][1] = -delta;
newPoints[endPointIndex][1] += delta;
}
Object.assign(
linearElement,
LinearElementEditor.getNormalizeElementPointsAndCoords({
...linearElement,
points: newPoints,
}),
);
return {
linearElement,
startBoundElement,
endBoundElement,
};
};
class ElementStore {
excalidrawElements = new Map<string, ExcalidrawElement>();
@@ -505,9 +274,317 @@ class ElementStore {
};
}
const createBoundElement = (
binding: ValidLinearElement["start"] | ValidLinearElement["end"],
linearElement: ExcalidrawArrowElement,
edge: "start" | "end",
elementStore: ElementStore,
): ExcalidrawElement | undefined => {
if (!binding) {
return undefined;
}
const width = binding?.width ?? DEFAULT_DIMENSION;
const height = binding?.height ?? DEFAULT_DIMENSION;
let existingElement;
if (binding.id) {
existingElement = elementStore.getElement(binding.id);
if (!existingElement) {
console.error(
`No element for ${edge} binding with id ${binding.id} found`,
);
return undefined;
}
}
const x =
binding.x ||
(edge === "start"
? linearElement.x - width
: linearElement.x + linearElement.width);
const y = binding.y || linearElement.y - height / 2;
const elementType = existingElement ? existingElement.type : binding.type;
if (!elementType) {
return undefined;
}
if (elementType === "text") {
let text = "";
if (existingElement && existingElement.type === "text") {
text = existingElement.text;
} else if (binding.type === "text") {
text = binding.text;
}
if (!text) {
console.error(
`No text found for ${edge} binding text element for ${linearElement.id}`,
);
return undefined;
}
const textElement = newTextElement({
x,
y,
type: "text",
...existingElement,
...binding,
text,
});
// to position the text correctly when coordinates not provided
Object.assign(textElement, {
x:
binding.x ||
(edge === "start" ? linearElement.x - textElement.width : x),
y: binding.y || linearElement.y - textElement.height / 2,
});
return textElement;
}
switch (elementType) {
case "rectangle":
case "ellipse":
case "diamond": {
return newElement({
x,
y,
width,
height,
...existingElement,
...binding,
type: elementType,
});
}
default: {
assertNever(
elementType as never,
`Unhandled element ${edge} type "${elementType}"`,
true,
);
return undefined;
}
}
};
const bindLinearElementToElement = (
linearElement: ExcalidrawArrowElement,
start: ValidLinearElement["start"],
end: ValidLinearElement["end"],
elementStore: ElementStore,
scene: Scene,
): {
linearElement: ExcalidrawLinearElement;
startBoundElement?: ExcalidrawElement;
endBoundElement?: ExcalidrawElement;
} => {
let startBoundElement;
let endBoundElement;
Object.assign(linearElement, {
startBinding: linearElement?.startBinding || null,
endBinding: linearElement.endBinding || null,
});
if (start) {
startBoundElement = createBoundElement(
start,
linearElement,
"start",
elementStore,
);
if (startBoundElement) {
elementStore.add(startBoundElement);
scene.replaceAllElements(elementStore.getElementsMap());
bindLinearElement(
linearElement,
startBoundElement as ExcalidrawBindableElement,
"start",
scene,
);
}
}
if (end) {
endBoundElement = createBoundElement(
end,
linearElement,
"end",
elementStore,
);
if (endBoundElement) {
elementStore.add(endBoundElement);
scene.replaceAllElements(elementStore.getElementsMap());
bindLinearElement(
linearElement,
endBoundElement as ExcalidrawBindableElement,
"end",
scene,
);
}
}
if (linearElement.points.length < 2) {
return {
linearElement,
startBoundElement,
endBoundElement,
};
}
// update start/end points by 0.5 so bindings don't overlap with start/end bound element coordinates.
if (!isElbowArrow(linearElement)) {
const endPointIndex = linearElement.points.length - 1;
const delta = 0.5;
const newPoints = cloneJSON<readonly LocalPoint[]>(linearElement.points);
// left to right so shift the arrow towards right
if (
linearElement.points[endPointIndex][0] >
linearElement.points[endPointIndex - 1][0]
) {
newPoints[0][0] = delta;
newPoints[endPointIndex][0] -= delta;
}
// right to left so shift the arrow towards left
if (
linearElement.points[endPointIndex][0] <
linearElement.points[endPointIndex - 1][0]
) {
newPoints[0][0] = -delta;
newPoints[endPointIndex][0] += delta;
}
// top to bottom so shift the arrow towards top
if (
linearElement.points[endPointIndex][1] >
linearElement.points[endPointIndex - 1][1]
) {
newPoints[0][1] = delta;
newPoints[endPointIndex][1] -= delta;
}
// bottom to top so shift the arrow towards bottom
if (
linearElement.points[endPointIndex][1] <
linearElement.points[endPointIndex - 1][1]
) {
newPoints[0][1] = -delta;
newPoints[endPointIndex][1] += delta;
}
Object.assign(
linearElement,
LinearElementEditor.getNormalizeElementPointsAndCoords({
...linearElement,
points: newPoints,
}),
);
}
return {
linearElement,
startBoundElement,
endBoundElement,
};
};
const adjustElbowArrowPoints = (elements: ExcalidrawElement[]) => {
const elementsMap = arrayToMap(elements) as NonDeletedSceneElementsMap;
const scene = new Scene(elementsMap);
elements.forEach((element) => {
if (isElbowArrow(element) && (element.startBinding || element.endBinding)) {
if (element.endBinding && element.endBinding.elementId) {
const midPoint = getBindingSideMidPoint(
element.endBinding,
elementsMap,
);
const endBindableElement = elementsMap.get(
element.endBinding.elementId,
) as ExcalidrawBindableElement;
if (midPoint) {
LinearElementEditor.movePoints(
element,
scene,
new Map([
[
element.points.length - 1,
{
point: pointFrom(
midPoint[0] - element.x,
midPoint[1] - element.y,
),
isDragging: true,
},
],
]),
);
const newFixedPoint = calculateFixedPointForElbowArrowBinding(
element,
endBindableElement,
"end",
elementsMap,
).fixedPoint;
if (newFixedPoint) {
Object.assign(element.endBinding, {
fixedPoint: newFixedPoint,
});
}
}
}
if (element.startBinding && element.startBinding.elementId) {
const midPoint = getBindingSideMidPoint(
element.startBinding,
elementsMap,
);
const startBindableElement = elementsMap.get(
element.startBinding.elementId,
) as ExcalidrawBindableElement;
if (midPoint) {
LinearElementEditor.movePoints(
element,
scene,
new Map([
[
0,
{
point: pointFrom(
midPoint[0] - element.x,
midPoint[1] - element.y,
),
isDragging: true,
},
],
]),
);
const newFixedPoint = calculateFixedPointForElbowArrowBinding(
element,
startBindableElement,
"start",
elementsMap,
).fixedPoint;
if (newFixedPoint) {
Object.assign(element.startBinding, {
fixedPoint: newFixedPoint,
});
}
}
}
}
});
};
export const convertToExcalidrawElements = (
elementsSkeleton: ExcalidrawElementSkeleton[] | null,
opts?: { regenerateIds: boolean },
opts?: { regenerateIds: boolean; useElbow?: boolean },
) => {
if (!elementsSkeleton) {
return [];
@@ -560,19 +637,32 @@ export const convertToExcalidrawElements = (
case "arrow": {
const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
excalidrawElement = newArrowElement({
width,
height,
endArrowhead: "arrow",
points: [pointFrom(0, 0), pointFrom(width, height)],
...element,
type: "arrow",
});
Object.assign(
excalidrawElement,
getSizeFromPoints(excalidrawElement.points),
);
if (!opts?.useElbow) {
excalidrawElement = newArrowElement({
width,
height,
endArrowhead: "arrow",
points: [pointFrom(0, 0), pointFrom(width, height)],
...element,
type: "arrow",
elbowed: opts?.useElbow,
});
Object.assign(
excalidrawElement,
getSizeFromPoints(excalidrawElement.points),
);
} else {
excalidrawElement = newArrowElement({
width,
height,
endArrowhead: "arrow",
...element,
type: "arrow",
elbowed: opts?.useElbow,
roundness: null,
});
}
break;
}
case "text": {
@@ -804,5 +894,12 @@ export const convertToExcalidrawElements = (
}
}
return elementStore.getElements();
const finalElements = elementStore.getElements();
// Adjust elbow arrow points now that all elements are in the scene
if (opts?.useElbow) {
adjustElbowArrowPoints(finalElements);
}
return finalElements;
};
+7 -10
View File
@@ -66,22 +66,12 @@
"last 1 safari version"
]
},
"repository": "https://github.com/excalidraw/excalidraw",
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"homepage": "https://github.com/excalidraw/excalidraw/tree/master/packages/excalidraw",
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildPackage.js && yarn gen:types"
},
"peerDependencies": {
"react": "^17.0.2 || ^18.2.0 || ^19.0.0",
"react-dom": "^17.0.2 || ^18.2.0 || ^19.0.0"
},
"dependencies": {
"@braintree/sanitize-url": "6.0.2",
"@excalidraw/common": "0.18.0",
"@excalidraw/element": "0.18.0",
"@excalidraw/math": "0.18.0",
"@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/mermaid-to-excalidraw": "1.1.2",
"@excalidraw/random-username": "1.1.0",
@@ -134,5 +124,12 @@
"harfbuzzjs": "0.3.6",
"jest-diff": "29.7.0",
"typescript": "4.9.4"
},
"repository": "https://github.com/excalidraw/excalidraw",
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"homepage": "https://github.com/excalidraw/excalidraw/tree/master/packages/excalidraw",
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildPackage.js && yarn gen:types"
}
}
@@ -6,7 +6,7 @@ exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active too
B --&gt; C{Let me think}
C --&gt;|One| D[Laptop]
C --&gt;|Two| E[iPhone]
C --&gt;|Three| F[Car]</textarea><div class="ttd-dialog-panel-button-container invisible" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class=""></div></button></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-panel__header"><label>Preview</label></div><div class="ttd-dialog-output-wrapper"><div style="opacity: 1;" class="ttd-dialog-output-canvas-container"><canvas width="89" height="158" dir="ltr"></canvas></div></div><div class="ttd-dialog-panel-button-container" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"
C --&gt;|Three| F[Car]</textarea><div class="ttd-dialog-panel-button-container invisible" style="display: flex; align-items: center; justify-content: space-between;"><div class="ttd-dialog-panel-button-container-left"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class=""></div></button></div></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-panel__header"><label>Preview</label></div><div class="ttd-dialog-output-wrapper"><div style="opacity: 1;" class="ttd-dialog-output-canvas-container"><canvas width="89" height="158" dir="ltr"></canvas></div></div><div class="ttd-dialog-panel-button-container" style="display: flex; align-items: center; justify-content: space-between;"><div class="ttd-dialog-panel-button-container-left"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div><div class="dialog-mermaid-arrow-type"><div class="RadioGroup"><div class="RadioGroup__choice active" title="Curved arrow"><input aria-label="Curved arrow" type="radio" checked="" name="mermaid arrow config"><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 24 24" class="" fill="none" stroke-width="2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g><path d="M16,12L20,9L16,6"></path><path d="M6 20c0 -6.075 4.925 -11 11 -11h3"></path></g></svg></div><div class="RadioGroup__choice" title="Elbow arrow"><input aria-label="Elbow arrow" type="radio" name="mermaid arrow config"><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 24 24" class="" fill="none" stroke-width="2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M4,19L10,19C11.097,19 12,18.097 12,17L12,9C12,7.903 12.903,7 14,7L21,7"></path><path d="M18 4l3 3l-3 3"></path></g></svg></div></div></div></div></div></div></div></div></div></div></div></div>"
`;
exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = `
+2 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@excalidraw/math",
"version": "0.18.0",
"version": "0.1.0",
"type": "module",
"types": "./dist/types/math/src/index.d.ts",
"main": "./dist/prod/index.js",
@@ -13,10 +13,7 @@
"default": "./dist/prod/index.js"
},
"./*": {
"types": "./dist/types/math/src/*.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
"types": "./dist/types/math/src/*.d.ts"
}
},
"files": [
@@ -59,8 +56,5 @@
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
},
"dependencies": {
"@excalidraw/common": "0.18.0"
}
}
+71
View File
@@ -0,0 +1,71 @@
const { exec, execSync } = require("child_process");
const fs = require("fs");
const core = require("@actions/core");
const excalidrawDir = `${__dirname}/../packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`;
const pkg = require(excalidrawPackage);
const isPreview = process.argv.slice(2)[0] === "preview";
const getShortCommitHash = () => {
return execSync("git rev-parse --short HEAD").toString().trim();
};
const publish = () => {
const tag = isPreview ? "preview" : "next";
try {
execSync(`yarn --frozen-lockfile`);
execSync(`yarn run build:esm`, { cwd: excalidrawDir });
execSync(`yarn --cwd ${excalidrawDir} publish --tag ${tag}`);
console.info(`Published ${pkg.name}@${tag}🎉`);
core.setOutput(
"result",
`**Preview version has been shipped** :rocket:
You can use [@excalidraw/excalidraw@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw/v/${pkg.version}) for testing!`,
);
} catch (error) {
core.setOutput("result", "package couldn't be published :warning:!");
console.error(error);
process.exit(1);
}
};
// get files changed between prev and head commit
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
if (error || stderr) {
console.error(error);
core.setOutput("result", ":warning: Package couldn't be published!");
process.exit(1);
}
const changedFiles = stdout.trim().split("\n");
const excalidrawPackageFiles = changedFiles.filter((file) => {
return (
file.indexOf("packages/excalidraw") >= 0 ||
file.indexOf("buildPackage.js") > 0
);
});
if (!excalidrawPackageFiles.length) {
console.info("Skipping release as no valid diff found");
core.setOutput("result", "Skipping release as no valid diff found");
process.exit(0);
}
// update package.json
let version = `${pkg.version}-${getShortCommitHash()}`;
// update readme
if (isPreview) {
// use pullNumber-commithash as the version for preview
const pullRequestNumber = process.argv.slice(3)[0];
version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`;
}
pkg.version = version;
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
console.info("Publish in progress...");
publish();
});
+4 -1
View File
@@ -11,9 +11,12 @@ const getConfig = (outdir) => ({
entryNames: "[name]",
assetNames: "[dir]/[name]",
alias: {
"@excalidraw/common": path.resolve(__dirname, "../packages/common/src"),
"@excalidraw/element": path.resolve(__dirname, "../packages/element/src"),
"@excalidraw/excalidraw": path.resolve(__dirname, "../packages/excalidraw"),
"@excalidraw/math": path.resolve(__dirname, "../packages/math/src"),
"@excalidraw/utils": path.resolve(__dirname, "../packages/utils/src"),
},
external: ["@excalidraw/common", "@excalidraw/element", "@excalidraw/math"],
});
function buildDev(config) {
+4 -1
View File
@@ -28,9 +28,12 @@ const getConfig = (outdir) => ({
assetNames: "[dir]/[name]",
chunkNames: "[dir]/[name]-[hash]",
alias: {
"@excalidraw/common": path.resolve(__dirname, "../packages/common/src"),
"@excalidraw/element": path.resolve(__dirname, "../packages/element/src"),
"@excalidraw/excalidraw": path.resolve(__dirname, "../packages/excalidraw"),
"@excalidraw/math": path.resolve(__dirname, "../packages/math/src"),
"@excalidraw/utils": path.resolve(__dirname, "../packages/utils/src"),
},
external: ["@excalidraw/common", "@excalidraw/element", "@excalidraw/math"],
loader: {
".woff2": "file",
},
+38
View File
@@ -0,0 +1,38 @@
const fs = require("fs");
const util = require("util");
const exec = util.promisify(require("child_process").exec);
const updateChangelog = require("./updateChangelog");
const excalidrawDir = `${__dirname}/../packages/excalidraw/`;
const excalidrawPackage = `${excalidrawDir}/package.json`;
const updatePackageVersion = (nextVersion) => {
const pkg = require(excalidrawPackage);
pkg.version = nextVersion;
const content = `${JSON.stringify(pkg, null, 2)}\n`;
fs.writeFileSync(excalidrawPackage, content, "utf-8");
};
const prerelease = async (nextVersion) => {
try {
await updateChangelog(nextVersion);
updatePackageVersion(nextVersion);
await exec(`git add -u`);
await exec(
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
);
console.info("Done!");
} catch (error) {
console.error(error);
process.exit(1);
}
};
const nextVersion = process.argv.slice(2)[0];
if (!nextVersion) {
console.error("Pass the next version to release!");
process.exit(1);
}
prerelease(nextVersion);
+19 -230
View File
@@ -1,239 +1,28 @@
const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");
const updateChangelog = require("./updateChangelog");
const excalidrawDir = `${__dirname}/../packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`;
const pkg = require(excalidrawPackage);
// skipping utils for now, as it has independent release process
const PACKAGES = ["common", "math", "element", "excalidraw"];
const PACKAGES_DIR = path.resolve(__dirname, "../packages");
/**
* Returns the arguments for the release script.
*
* Usage examples:
* - yarn release --help -> prints this help message
* - yarn release -> publishes `@excalidraw` packages with "test" tag and "-[hash]" version suffix
* - yarn release --tag=test -> same as above
* - yarn release --tag=next -> publishes `@excalidraw` packages with "next" tag and version "-[hash]" suffix
* - yarn release --tag=next --non-interactive -> skips interactive prompts (runs on CI/CD), otherwise same as above
* - yarn release --tag=latest --version=0.19.0 -> publishes `@excalidraw` packages with "latest" tag and version "0.19.0" & prepares changelog for the release
*
* @returns [tag, version, nonInteractive]
*/
const getArguments = () => {
let tag = "test";
let version = "";
let nonInteractive = false;
for (const argument of process.argv.slice(2)) {
if (/--help/.test(argument)) {
console.info(`Available arguments:
--tag=<tag> -> (optional) "test" (default), "next" for auto release, "latest" for stable release
--version=<version> -> (optional) for "next" and "test", (required) for "latest" i.e. "0.19.0"
--non-interactive -> (optional) disables interactive prompts`);
console.info(`\nUsage examples:
- yarn release -> publishes \`@excalidraw\` packages with "test" tag and "-[hash]" version suffix
- yarn release --tag=test -> same as above
- yarn release --tag=next -> publishes \`@excalidraw\` packages with "next" tag and version "-[hash]" suffix
- yarn release --tag=next --non-interactive -> skips interactive prompts (runs on CI/CD), otherwise same as above
- yarn release --tag=latest --version=0.19.0 -> publishes \`@excalidraw\` packages with "latest" tag and version "0.19.0" & prepares changelog for the release`);
process.exit(0);
}
if (/--tag=/.test(argument)) {
tag = argument.split("=")[1];
}
if (/--version=/.test(argument)) {
version = argument.split("=")[1];
}
if (/--non-interactive/.test(argument)) {
nonInteractive = true;
}
}
if (tag !== "latest" && tag !== "next" && tag !== "test") {
console.error(`Unsupported tag "${tag}", use "latest", "next" or "test".`);
process.exit(1);
}
if (tag === "latest" && !version) {
console.error("Pass the version to make the latest stable release!");
process.exit(1);
}
if (!version) {
// set the next version based on the excalidraw package version + commit hash
const excalidrawPackageVersion = require(getPackageJsonPath(
"excalidraw",
)).version;
const hash = getShortCommitHash();
if (!excalidrawPackageVersion.includes(hash)) {
version = `${excalidrawPackageVersion}-${hash}`;
} else {
// ensuring idempotency
version = excalidrawPackageVersion;
}
}
console.info(`Running with tag "${tag}" and version "${version}"...`);
return [tag, version, nonInteractive];
};
const validatePackageName = (packageName) => {
if (!PACKAGES.includes(packageName)) {
console.error(`Package "${packageName}" not found!`);
const publish = () => {
try {
console.info("Installing the dependencies in root folder...");
execSync(`yarn --frozen-lockfile`);
console.info("Installing the dependencies in excalidraw directory...");
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
console.info("Building ESM Package...");
execSync(`yarn run build:esm`, { cwd: excalidrawDir });
console.info("Publishing the package...");
execSync(`yarn --cwd ${excalidrawDir} publish`);
} catch (error) {
console.error(error);
process.exit(1);
}
};
const getPackageJsonPath = (packageName) => {
validatePackageName(packageName);
return path.resolve(PACKAGES_DIR, packageName, "package.json");
const release = () => {
publish();
console.info(`Published ${pkg.version}!`);
};
const updatePackageJsons = (nextVersion) => {
const packageJsons = new Map();
for (const packageName of PACKAGES) {
const pkg = require(getPackageJsonPath(packageName));
pkg.version = nextVersion;
if (pkg.dependencies) {
for (const dependencyName of PACKAGES) {
if (!pkg.dependencies[`@excalidraw/${dependencyName}`]) {
continue;
}
pkg.dependencies[`@excalidraw/${dependencyName}`] = nextVersion;
}
}
packageJsons.set(packageName, `${JSON.stringify(pkg, null, 2)}\n`);
}
// modify once, to avoid inconsistent state
for (const packageName of PACKAGES) {
const content = packageJsons.get(packageName);
fs.writeFileSync(getPackageJsonPath(packageName), content, "utf-8");
}
};
const getShortCommitHash = () => {
return execSync("git rev-parse --short HEAD").toString().trim();
};
const askToCommit = (tag, nextVersion) => {
if (tag !== "latest") {
return Promise.resolve();
}
return new Promise((resolve) => {
const rl = require("readline").createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question(
"Do you want to commit these changes to git? (Y/n): ",
(answer) => {
rl.close();
if (answer.toLowerCase() === "y") {
execSync(`git add -u`);
execSync(
`git commit -m "chore: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
);
} else {
console.warn(
"Skipping commit. Don't forget to commit manually later!",
);
}
resolve();
},
);
});
};
const buildPackages = () => {
console.info("Running yarn install...");
execSync(`yarn --frozen-lockfile`, { stdio: "inherit" });
console.info("Removing existing build artifacts...");
execSync(`yarn rm:build`, { stdio: "inherit" });
for (const packageName of PACKAGES) {
console.info(`Building "@excalidraw/${packageName}"...`);
execSync(`yarn run build:esm`, {
cwd: path.resolve(PACKAGES_DIR, packageName),
stdio: "inherit",
});
}
};
const askToPublish = (tag, version) => {
return new Promise((resolve) => {
const rl = require("readline").createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question(
"Do you want to publish these changes to npm? (Y/n): ",
(answer) => {
rl.close();
if (answer.toLowerCase() === "y") {
publishPackages(tag, version);
} else {
console.info("Skipping publish.");
}
resolve();
},
);
});
};
const publishPackages = (tag, version) => {
for (const packageName of PACKAGES) {
execSync(`yarn publish --tag ${tag}`, {
cwd: path.resolve(PACKAGES_DIR, packageName),
stdio: "inherit",
});
console.info(
`Published "@excalidraw/${packageName}@${tag}" with version "${version}"! 🎉`,
);
}
};
/** main */
(async () => {
const [tag, version, nonInteractive] = getArguments();
buildPackages();
if (tag === "latest") {
await updateChangelog(version);
}
updatePackageJsons(version);
if (nonInteractive) {
publishPackages(tag, version);
} else {
await askToCommit(tag, version);
await askToPublish(tag, version);
}
})();
release();
+2 -4
View File
@@ -20,16 +20,14 @@ const headerForType = {
perf: "Performance",
build: "Build",
};
const badCommits = [];
const getCommitHashForLastVersion = async () => {
try {
const commitMessage = `"release @excalidraw/excalidraw"`;
const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
const { stdout } = await exec(
`git log --format=format:"%H" --grep=${commitMessage}`,
);
// take commit hash from latest release
return stdout.split(/\r?\n/)[0];
return stdout;
} catch (error) {
console.error(error);
}