Compare commits

..

7 Commits

Author SHA1 Message Date
Mark Tolmacs
a9f57f0fc1 feat: Cross-stitching fine-tune 2025-12-03 14:08:53 +00:00
Mark Tolmacs
4a67c3e9b7 feat: Cross-stitch algo 2025-12-03 09:20:02 +00:00
Mark Tolmacs
fdb8aaf44e fix: Orientation 2025-12-02 21:33:08 +00:00
Mark Tolmacs
6f4081e371 feat: Basic outlining polygon 2025-12-02 18:18:00 +00:00
David Luzar
d080833f4d chore: bump typescript@5.9.3 (#10431) 2025-12-01 22:37:42 +01:00
Márk Tolmács
451bcac0b7 fix: Ctrl/Alt elbow arrow jumps (#10432)
* fix: Ctrl/Alt elbow arrow jumps

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Trigger build

* style

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-12-01 17:06:08 +00:00
Ethan Olesinski
06f01e11f8 style: remove blue lines (#10425)
remove blue lines
2025-12-01 11:15:29 +00:00
18 changed files with 342 additions and 70 deletions

View File

@@ -23,23 +23,17 @@
<br />
<p align="center">
<a href="https://github.com/excalidraw/excalidraw/blob/master/LICENSE">
<img alt="Excalidraw is released under the MIT license." src="https://img.shields.io/badge/license-MIT-blue.svg" />
</a>
<img alt="Excalidraw is released under the MIT license." src="https://img.shields.io/badge/license-MIT-blue.svg" /></a>
<a href="https://www.npmjs.com/package/@excalidraw/excalidraw">
<img alt="npm downloads/month" src="https://img.shields.io/npm/dm/@excalidraw/excalidraw" />
</a>
<img alt="npm downloads/month" src="https://img.shields.io/npm/dm/@excalidraw/excalidraw" /></a>
<a href="https://docs.excalidraw.com/docs/introduction/contributing">
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" />
</a>
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a>
<a href="https://discord.gg/UexuTaE">
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/>
</a>
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/></a>
<a href="https://deepwiki.com/excalidraw/excalidraw">
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" />
</a>
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
<a href="https://twitter.com/excalidraw">
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
</a>
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/></a>
</p>
<div align="center">

View File

@@ -441,7 +441,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
};
private decryptPayload = async (
iv: Uint8Array,
iv: Uint8Array<ArrayBuffer>,
encryptedData: ArrayBuffer,
decryptionKey: string,
): Promise<ValueOf<SocketUpdateDataSource>> => {
@@ -562,7 +562,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
// All socket listeners are moving to Portal
this.portal.socket.on(
"client-broadcast",
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
async (encryptedData: ArrayBuffer, iv: Uint8Array<ArrayBuffer>) => {
if (!this.portal.roomKey) {
return;
}

View File

@@ -105,8 +105,8 @@ const decryptElements = async (
data: FirebaseStoredScene,
roomKey: string,
): Promise<readonly ExcalidrawElement[]> => {
const ciphertext = data.ciphertext.toUint8Array();
const iv = data.iv.toUint8Array();
const ciphertext = data.ciphertext.toUint8Array() as Uint8Array<ArrayBuffer>;
const iv = data.iv.toUint8Array() as Uint8Array<ArrayBuffer>;
const decrypted = await decryptData(iv, ciphertext, roomKey);
const decodedData = new TextDecoder("utf-8").decode(

View File

@@ -34,7 +34,7 @@
"prettier": "2.6.2",
"rewire": "6.0.0",
"rimraf": "^5.0.0",
"typescript": "4.9.4",
"typescript": "5.9.3",
"vite": "5.0.12",
"vite-plugin-checker": "0.7.2",
"vite-plugin-ejs": "1.7.0",

View File

@@ -0,0 +1,255 @@
import {
type GlobalPoint,
type LineSegment,
lineSegment,
lineSegmentIntersectionPoints,
type LocalPoint,
pointDistanceSq,
pointFrom,
pointFromVector,
vectorAntiNormal,
vectorFromPoint,
vectorNormal,
vectorNormalize,
vectorScale,
} from "@excalidraw/math";
import { debugDrawLine } from "@excalidraw/common";
import { type ExcalidrawFreeDrawElement } from "./types";
const offset = (
x: number,
y: number,
pressure: number,
direction: "left" | "right",
origin: LocalPoint,
) => {
const p = pointFrom<LocalPoint>(x, y);
const v = vectorNormalize(vectorFromPoint(p, origin));
const normal = direction === "left" ? vectorNormal(v) : vectorAntiNormal(v);
const scaled = vectorScale(normal, pressure / 2);
return pointFromVector(scaled, origin);
};
function generateSegments(
input:
| readonly [x: number, y: number, pressure: number][]
| readonly [x: number, y: number][],
element: ExcalidrawFreeDrawElement,
pressureMultiplier: number = 1,
minimumPressure: number = 1,
): LineSegment<LocalPoint>[] {
if (input.length < 3) {
return [];
}
let idx = 0;
const segments = Array(input.length * 4 - 4);
segments[idx++] = lineSegment(
offset(
input[1][0],
input[1][1],
Math.max((input[1][2] ?? 5) * pressureMultiplier, minimumPressure),
"left",
pointFrom<LocalPoint>(input[0][0], input[0][1]),
),
offset(
input[0][0],
input[0][1],
Math.max((input[1][2] ?? 5) * pressureMultiplier, minimumPressure),
"right",
pointFrom<LocalPoint>(input[1][0], input[1][1]),
),
);
for (let i = 2; i < input.length; i++) {
const a = segments[idx - 1][1];
const b = offset(
input[i][0],
input[i][1],
Math.max((input[1][2] ?? 5) * pressureMultiplier, minimumPressure),
"left",
pointFrom<LocalPoint>(input[i - 1][0], input[i - 1][1]),
);
const c = offset(
input[i - 1][0],
input[i - 1][1],
Math.max((input[1][2] ?? 5) * pressureMultiplier, minimumPressure),
"right",
pointFrom<LocalPoint>(input[i][0], input[i][1]),
);
segments[idx++] = lineSegment(a, b); // Bridge segment
segments[idx++] = lineSegment(b, c); // Main segment
}
// Turnaround segments
const prev = segments[idx - 1][1];
segments[idx++] = lineSegment(
prev,
pointFrom<LocalPoint>(
input[input.length - 1][0],
input[input.length - 1][1],
),
);
segments[idx++] = lineSegment(
pointFrom<LocalPoint>(
input[input.length - 1][0],
input[input.length - 1][1],
),
offset(
input[input.length - 2][0],
input[input.length - 2][1],
Math.max((input[1][2] ?? 5) * pressureMultiplier, minimumPressure),
"left",
pointFrom<LocalPoint>(
input[input.length - 1][0],
input[input.length - 1][1],
),
),
);
for (let i = input.length - 2; i > 0; i--) {
const a = segments[idx - 1][1];
const b = offset(
input[i + 1][0],
input[i + 1][1],
Math.max((input[1][2] ?? 5) * pressureMultiplier, minimumPressure),
"right",
pointFrom<LocalPoint>(input[i][0], input[i][1]),
);
const c = offset(
input[i - 1][0],
input[i - 1][1],
Math.max((input[1][2] ?? 5) * pressureMultiplier, minimumPressure),
"left",
pointFrom<LocalPoint>(input[i][0], input[i][1]),
);
segments[idx++] = lineSegment(a, b); // Main segment
segments[idx++] = lineSegment(b, c); // Bridge segment
}
const last = segments[idx - 1][1];
segments[idx++] = lineSegment(
last,
offset(
input[1][0],
input[1][1],
Math.max((input[1][2] ?? 5) * pressureMultiplier, minimumPressure),
"right",
pointFrom<LocalPoint>(input[0][0], input[0][1]),
),
);
// Closing cap
segments[idx++] = lineSegment(
segments[idx - 2][1],
pointFrom<LocalPoint>(input[0][0], input[0][1]),
);
segments[idx++] = lineSegment(
pointFrom<LocalPoint>(input[0][0], input[0][1]),
segments[0][0],
);
return segments;
}
export function getStroke(
input:
| readonly [x: number, y: number, pressure: number][]
| readonly [x: number, y: number][],
options: any,
element: ExcalidrawFreeDrawElement,
): LocalPoint[] {
const segments: (LineSegment<LocalPoint> | undefined)[] = generateSegments(
input,
element,
);
const MIN_DIST_SQ = 0.2 ** 2;
for (let j = 0; j < segments.length; j++) {
for (let i = j + 1; i < segments.length; i++) {
const a = segments[j];
const b = segments[i];
if (!a || !b) {
continue;
}
const intersection = lineSegmentIntersectionPoints(a, b);
if (
intersection &&
pointDistanceSq(a[0], intersection) > MIN_DIST_SQ &&
pointDistanceSq(a[1], intersection) > MIN_DIST_SQ &&
i === j + 2
) {
a[1] = intersection;
segments[j + 1] = undefined;
b[0] = intersection;
}
}
}
debugSegments(
segments.filter((s): s is LineSegment<LocalPoint> => !!s),
input,
element,
);
return [
...(segments[0] ? [segments[0][0]] : []),
...segments
.filter((s): s is LineSegment<LocalPoint> => !!s)
.map((s) => s[1]),
];
}
function debugSegments(
segments: LineSegment<LocalPoint>[],
input: readonly [number, number, number][] | readonly [number, number][],
element: ExcalidrawFreeDrawElement,
): void {
const colors = [
"#FF0000",
"#00FF00",
"#0000FF",
// "#FFFF00",
// "#00FFFF",
// "#FF00FF",
// "#C0C0C0",
// "#800000",
// "#808000",
// "#008000",
// "#800080",
// "#008080",
// "#000080",
];
segments.forEach((s, i) => {
debugDrawLine(
lineSegment(
pointFrom<GlobalPoint>(element.x + s[0][0], element.y + s[0][1]),
pointFrom<GlobalPoint>(element.x + s[1][0], element.y + s[1][1]),
),
{ color: colors[i % colors.length], permanent: true },
);
});
input.forEach((p, i) => {
if (i === 0) {
return;
}
debugDrawLine(
lineSegment(
pointFrom<GlobalPoint>(
element.x + input[i - 1][0],
element.y + input[i - 1][1],
),
pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]),
),
{ color: "#000000", permanent: true },
);
});
}

View File

@@ -1,5 +1,5 @@
import rough from "roughjs/bin/rough";
import { getStroke } from "perfect-freehand";
//import { getStroke } from "perfect-freehand";
import {
type GlobalPoint,
@@ -63,8 +63,8 @@ import {
} from "./typeChecks";
import { getContainingFrame } from "./frame";
import { getCornerRadius } from "./utils";
import { ShapeCache } from "./shape";
import { getStroke } from "./freedraw";
import type {
ExcalidrawElement,
@@ -1102,10 +1102,10 @@ export function getFreedrawOutlineAsSegments(
export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
// If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]];
? (element.points as readonly [number, number][])
: ((element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]]) as [number, number, number][]);
// Consider changing the options for simulated pressure vs real pressure
const options: StrokeOptions = {
@@ -1118,7 +1118,16 @@ export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
last: true,
};
return getStroke(inputPoints as number[][], options) as [number, number][];
// return getStroke(
// [
// [0, 0],
// [30, -30],
// [60, -30],
// ],
// options,
// element,
// );
return getStroke(inputPoints, options, element) as [number, number][];
}
function med(A: number[], B: number[]) {

View File

@@ -11463,7 +11463,14 @@ class App extends React.Component<AppProps, AppState> {
): void => {
const selectionElement = this.state.selectionElement;
const pointerCoords = pointerDownState.lastCoords;
if (selectionElement && this.state.activeTool.type !== "eraser") {
const selectedElements = this.scene.getSelectedElements(this.state);
const onlyBindingElementSelected =
selectedElements?.length === 1 && isBindingElement(selectedElements[0]);
if (
selectionElement &&
this.state.activeTool.type !== "eraser" &&
!onlyBindingElementSelected
) {
dragNewElement({
newElement: selectionElement,
elementType: this.state.activeTool.type,
@@ -11527,25 +11534,27 @@ class App extends React.Component<AppProps, AppState> {
snapLines,
});
dragNewElement({
newElement,
elementType: this.state.activeTool.type,
originX: pointerDownState.originInGrid.x,
originY: pointerDownState.originInGrid.y,
x: gridX,
y: gridY,
width: distance(pointerDownState.originInGrid.x, gridX),
height: distance(pointerDownState.originInGrid.y, gridY),
shouldMaintainAspectRatio: isImageElement(newElement)
? !shouldMaintainAspectRatio(event)
: shouldMaintainAspectRatio(event),
shouldResizeFromCenter: shouldResizeFromCenter(event),
zoom: this.state.zoom.value,
scene: this.scene,
widthAspectRatio: aspectRatio,
originOffset: this.state.originSnapOffset,
informMutation,
});
if (!isBindingElement(newElement)) {
dragNewElement({
newElement,
elementType: this.state.activeTool.type,
originX: pointerDownState.originInGrid.x,
originY: pointerDownState.originInGrid.y,
x: gridX,
y: gridY,
width: distance(pointerDownState.originInGrid.x, gridX),
height: distance(pointerDownState.originInGrid.y, gridY),
shouldMaintainAspectRatio: isImageElement(newElement)
? !shouldMaintainAspectRatio(event)
: shouldMaintainAspectRatio(event),
shouldResizeFromCenter: shouldResizeFromCenter(event),
zoom: this.state.zoom.value,
scene: this.scene,
widthAspectRatio: aspectRatio,
originOffset: this.state.originSnapOffset,
informMutation,
});
}
this.setState({
newElement,

View File

@@ -222,7 +222,7 @@ function dataView(
*
* @param buffers each buffer (chunk) must be at most 2^32 bits large (~4GB)
*/
const concatBuffers = (...buffers: Uint8Array[]) => {
const concatBuffers = (...buffers: Uint8Array[]): Uint8Array<ArrayBuffer> => {
const bufferView = new Uint8Array(
VERSION_DATAVIEW_BYTES +
NEXT_CHUNK_SIZE_DATAVIEW_BYTES * buffers.length +
@@ -295,12 +295,12 @@ const splitBuffers = (concatenatedBuffer: Uint8Array) => {
/** @private */
const _encryptAndCompress = async (
data: Uint8Array | string,
data: Uint8Array<ArrayBuffer> | string,
encryptionKey: string,
) => {
const { encryptedBuffer, iv } = await encryptData(
encryptionKey,
deflate(data),
deflate(data) as Uint8Array<ArrayBuffer>,
);
return { iv, buffer: new Uint8Array(encryptedBuffer) };
@@ -330,7 +330,7 @@ export const compressData = async <T extends Record<string, any> = never>(
: {
metadata: T;
}),
): Promise<Uint8Array> => {
): Promise<Uint8Array<ArrayBuffer>> => {
const fileInfo: FileEncodingInfo = {
version: 2,
compression: "pako@1",
@@ -355,8 +355,8 @@ export const compressData = async <T extends Record<string, any> = never>(
/** @private */
const _decryptAndDecompress = async (
iv: Uint8Array,
decryptedBuffer: Uint8Array,
iv: Uint8Array<ArrayBuffer>,
decryptedBuffer: Uint8Array<ArrayBuffer>,
decryptionKey: string,
isCompressed: boolean,
) => {

View File

@@ -4,7 +4,7 @@ import { blobToArrayBuffer } from "./blob";
export const IV_LENGTH_BYTES = 12;
export const createIV = () => {
export const createIV = (): Uint8Array<ArrayBuffer> => {
const arr = new Uint8Array(IV_LENGTH_BYTES);
return window.crypto.getRandomValues(arr);
};
@@ -49,12 +49,12 @@ export const getCryptoKey = (key: string, usage: KeyUsage) =>
export const encryptData = async (
key: string | CryptoKey,
data: Uint8Array | ArrayBuffer | Blob | File | string,
): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array }> => {
data: Uint8Array<ArrayBuffer> | ArrayBuffer | Blob | File | string,
): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array<ArrayBuffer> }> => {
const importedKey =
typeof key === "string" ? await getCryptoKey(key, "encrypt") : key;
const iv = createIV();
const buffer: ArrayBuffer | Uint8Array =
const buffer: ArrayBuffer | Uint8Array<ArrayBuffer> =
typeof data === "string"
? new TextEncoder().encode(data)
: data instanceof Uint8Array
@@ -71,15 +71,15 @@ export const encryptData = async (
iv,
},
importedKey,
buffer as ArrayBuffer | Uint8Array,
buffer,
);
return { encryptedBuffer, iv };
};
export const decryptData = async (
iv: Uint8Array,
encrypted: Uint8Array | ArrayBuffer,
iv: Uint8Array<ArrayBuffer>,
encrypted: Uint8Array<ArrayBuffer> | ArrayBuffer,
privateKey: string,
): Promise<ArrayBuffer> => {
const key = await getCryptoKey(privateKey, "decrypt");

View File

@@ -42,7 +42,7 @@ declare module "png-chunk-text" {
function decode(data: Uint8Array): { keyword: string; text: string };
}
declare module "png-chunks-encode" {
function encode(chunks: TEXtChunk[]): Uint8Array;
function encode(chunks: TEXtChunk[]): Uint8Array<ArrayBuffer>;
export = encode;
}
declare module "png-chunks-extract" {

View File

@@ -18,7 +18,7 @@ export function useOutsideClick<T extends HTMLElement>(
* Returning `undefined` will fallback to the default behavior.
*/
isInside?: (
event: Event & { target: HTMLElement },
event: Event & { target: T },
/** the element of the passed ref */
container: T,
) => boolean | undefined,

View File

@@ -133,6 +133,6 @@
"fonteditor-core": "2.4.1",
"harfbuzzjs": "0.3.6",
"jest-diff": "29.7.0",
"typescript": "4.9.4"
"typescript": "5.9.3"
}
}

View File

@@ -20,7 +20,7 @@ const load = (): Promise<{
subset: (
fontBuffer: ArrayBuffer,
codePoints: ReadonlySet<number>,
) => Uint8Array;
) => Uint8Array<ArrayBuffer>;
}> => {
return new Promise(async (resolve, reject) => {
try {

View File

@@ -18,7 +18,7 @@ type Vector = any;
let loadedWasm: ReturnType<typeof load> | null = null;
// re-map from internal vector into byte array
function convertFromVecToUint8Array(vector: Vector): Uint8Array {
function convertFromVecToUint8Array(vector: Vector): Uint8Array<ArrayBuffer> {
const arr = [];
for (let i = 0, l = vector.size(); i < l; i++) {
arr.push(vector.get(i));
@@ -29,8 +29,8 @@ function convertFromVecToUint8Array(vector: Vector): Uint8Array {
// TODO: consider adding support for fetching the wasm from an URL (external CDN, data URL, etc.)
const load = (): Promise<{
compress: (buffer: ArrayBuffer) => Uint8Array;
decompress: (buffer: ArrayBuffer) => Uint8Array;
compress: (buffer: ArrayBuffer) => Uint8Array<ArrayBuffer>;
decompress: (buffer: ArrayBuffer) => Uint8Array<ArrayBuffer>;
}> => {
return new Promise((resolve, reject) => {
try {

View File

@@ -464,7 +464,7 @@ export class API {
static readFile = async <T extends "utf8" | null>(
filepath: string,
encoding?: T,
): Promise<T extends "utf8" ? string : Buffer> => {
): Promise<T extends "utf8" ? string : ArrayBuffer> => {
filepath = path.isAbsolute(filepath)
? filepath
: path.resolve(path.join(__dirname, "../", filepath));

View File

@@ -158,3 +158,8 @@ export const vectorNormalize = (v: Vector): Vector => {
* Calculate the right-hand normal of the vector.
*/
export const vectorNormal = (v: Vector): Vector => vector(v[1], -v[0]);
/**
* Calculate the left-hand normal of the vector.
*/
export const vectorAntiNormal = (v: Vector): Vector => vector(-v[1], v[0]);

View File

@@ -62,7 +62,7 @@
"devDependencies": {
"cross-env": "7.0.3",
"fonteditor-core": "2.4.0",
"typescript": "4.9.4",
"typescript": "5.9.3",
"wawoff2": "2.0.1",
"which": "4.0.0"
},

View File

@@ -9290,10 +9290,10 @@ typed-array-length@^1.0.7:
possible-typed-array-names "^1.0.0"
reflect.getprototypeof "^1.0.6"
typescript@4.9.4:
version "4.9.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.4.tgz#a2a3d2756c079abda241d75f149df9d561091e78"
integrity sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==
typescript@5.9.3:
version "5.9.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
typescript@^5:
version "5.8.2"