Compare commits

...

31 Commits

Author SHA1 Message Date
dwelle 9448cca81d feat(editor): highlight duplicate matches in mermaid editor 2026-03-24 16:16:44 +01:00
dwelle 217c59a13a feat(editor): highlight TTD assistant responses 2026-03-24 16:14:26 +01:00
Márk Tolmács d6f0f34fe9 fix: Rotated rounded arrow center point (#10962) 2026-03-23 15:54:59 +01:00
Márk Tolmács 75789f620d fix: Other endpoint is not immediately updated on midpoint snap (#10933)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-03-23 14:54:44 +00:00
David Luzar a9ca16eb42 chore(packages/excalidraw): export Fonts helper class (#11008) 2026-03-21 22:44:27 +01:00
Márk Tolmács 987173b52f fix: Arrow point index Out-of-Bounds (#10922)
* fix: Make OOB not fatal

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

* fix: More conservative temp arrow state update

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

* chore: Capture condition variables in binding restoration failure

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

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-21 19:26:47 +01:00
David Luzar 81ab857a6f feat(editor): various text related improvements (#10979) 2026-03-19 16:00:58 +01:00
David Luzar e8b4620a96 feat(editor): put caret at pointer coords when clicking on selected text element (#10970) 2026-03-18 19:14:44 +01:00
David Luzar 2b0e4c9623 fix(editor): remove leftover debug code path (#10954) 2026-03-14 13:12:48 +01:00
David Luzar c9ba7f839c chore(editor): bump @excalidraw/mermaid-to-excalidraw@2.1.1 (#10944) 2026-03-12 17:32:13 +01:00
David Luzar b4ce7c713b fix(editor): arrowhead picker overflowing viewport (#10943) 2026-03-12 16:08:46 +01:00
David Luzar 816c81c12e feat(editor): ERD arrowheads and diagrams (#10940) 2026-03-11 22:00:31 +01:00
David Luzar 92d25446d6 feat(packages/excalidraw): tweak and expose more API around state and lifecycle (#10939) 2026-03-11 16:51:03 +01:00
David Luzar e73a5b0116 docs(packages/excalidraw): improve readme (#10932) 2026-03-11 09:49:12 +01:00
David Luzar 21dd1cfacc feat(packages/excalidraw): state tracking, api hook, and others (#10870) 2026-03-08 23:15:18 +01:00
David Luzar fa1f7d9f22 feat(packages/excalidraw): export throttleRAF (#10912) 2026-03-07 12:05:33 +01:00
David Luzar 3d8c12fba4 fix(editor): do not conditionally disable midpoint snapping menu preference (#10906) 2026-03-06 20:44:57 +01:00
David Luzar 757dfeb6ad fix(editor): call throttleRAF with lastArgs and remove trailing (#10905)
Co-authored-by: Varun Chawla <varun_6april@hotmail.com>
Co-authored-by: aziamimoh <aziamimoh@users.noreply.github.com>
Co-authored-by: pgzcoa <pgzcoa@users.noreply.github.com>
Co-authored-by: TinaZhang24 <TinaZhang24@users.noreply.github.com>
2026-03-06 20:40:36 +01:00
David Luzar a0e93b6040 feat(editor): sync export theme with ui theme (#10903) 2026-03-06 18:37:28 +01:00
Hendrik Horstmann 499e9d64a5 fix: dropdownMenu item badge position (#10895) 2026-03-06 08:41:49 +00:00
David Luzar c1dbbdf678 feat(editor): mermaid code editor & improve parsing (#10897) 2026-03-05 18:52:41 +01:00
David Luzar 47c254216b fix(editor): disable snap-to-midpoint menu item when arrow-binding disabled (#10885) 2026-03-04 16:48:33 +01:00
Hendrik Horstmann d1cff91b75 fix: spacing in the left menu (#10880) 2026-03-03 22:11:30 +00:00
Márk Tolmács 437595fa65 feat: Arrow binding is a preference (#10839)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-03-03 21:55:40 +00:00
David Luzar 60b275880d feat(editor): support radar chart and multiple series for other chart types (#10824) 2026-02-26 16:13:15 +01:00
zsviczian cae9d2bcbd fix: "hand" tool active after exiting view mode if laser point was used (#10841) 2026-02-26 12:55:13 +01:00
David Luzar 2874f9e48c fix(editor): simplify and fix midpoint highlighting (#10832) 2026-02-24 21:11:46 +01:00
Márk Tolmács 0b3a5e7cc4 fix: Multi-point arrow bound point update (#10831)
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-02-24 13:32:44 +01:00
Márk Tolmács 7ea3229e17 fix(editor): Hardened fixed point and bound element parsing in restore (#10816)
* fix: Reinforce fixedPoint restore

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

* fix: Even more hardened boundElement in restore

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

* fix: Extract constant

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

* fix: Remove superfluous check from restore

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

* chore: Remove non-needed code path

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

* fix: More robust number test for fixedPoint parsing

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

* fix: Validate bindings for element being parsed

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

* unrelated type safety

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-02-23 19:22:27 +00:00
David Luzar b0404b10b6 chore(debug): add debug.logChanged() and make easy to import (#10828) 2026-02-23 20:20:37 +01:00
David Luzar eb959128ac feat(editor): allow laser-pointing in view mode (#10802)
* feat(editor): allow laser pointing in view mode

* feat: allow switching between laser/hand in view mode

* fix lint

* factor out to utils

* fix: only handle primary clicks with the selection/laser tools
2026-02-20 22:49:46 +01:00
156 changed files with 11128 additions and 3009 deletions
+22 -1
View File
@@ -39,5 +39,26 @@
"allowReferrer": true
}
]
}
},
"overrides": [
{
"files": ["packages/excalidraw/**/*.{ts,tsx}"],
"excludedFiles": ["packages/excalidraw/**/*.test.{ts,tsx}", "packages/excalidraw/**/*.test.*.{ts,tsx}"],
"rules": {
"@typescript-eslint/no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@excalidraw/excalidraw"],
"message": "Do not import from the barrel 'index.tsx' files. Use direct relative imports to the specific module instead.",
"allowTypeImports": true
}
],
"paths": [".", "..", "../..", "../../..", "../../../..", "../../../../..", "../index", "../../index", "../../../index", "../../../../index"]
}
]
}
}
]
}
@@ -172,7 +172,7 @@ convertToExcalidrawElements([
type: "arrow",
x: 450,
y: 20,
startArrowhead: "dot",
startArrowhead: "circle",
endArrowhead: "triangle",
strokeColor: "#1971c2",
strokeWidth: 2,
@@ -1,4 +1,4 @@
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/data/transform";
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/element/transform";
import type { FileId } from "@excalidraw/excalidraw/element/types";
const elements: ExcalidrawElementSkeleton[] = [
+5 -5
View File
@@ -3,14 +3,14 @@
"version": "1.0.0",
"private": true,
"dependencies": {
"react": "19.0.0",
"react-dom": "19.0.0",
"@excalidraw/excalidraw": "*",
"browser-fs-access": "0.29.1"
"browser-fs-access": "0.38.0",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"vite": "5.0.12",
"typescript": "^5"
"typescript": "^5",
"vite": "5.0.12"
},
"scripts": {
"start": "vite",
-36
View File
@@ -4,8 +4,6 @@ import { unstable_batchedUpdates } from "react-dom";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
const INPUT_CHANGE_INTERVAL_MS = 500;
export type ResolvablePromise<T> = Promise<T> & {
resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void;
reject: (error: Error) => void;
@@ -54,40 +52,6 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
extensions,
mimeTypes,
multiple: opts.multiple ?? false,
legacySetup: (resolve, reject, input) => {
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
const focusHandler = () => {
checkForFile();
document.addEventListener("keyup", scheduleRejection);
document.addEventListener("pointerup", scheduleRejection);
scheduleRejection();
};
const checkForFile = () => {
// this hack might not work when expecting multiple files
if (input.files?.length) {
const ret = opts.multiple ? [...input.files] : input.files[0];
resolve(ret as RetType);
}
};
requestAnimationFrame(() => {
window.addEventListener("focus", focusHandler);
});
const interval = window.setInterval(() => {
checkForFile();
}, INPUT_CHANGE_INTERVAL_MS);
return (rejectPromise) => {
clearInterval(interval);
scheduleRejection.cancel();
window.removeEventListener("focus", focusHandler);
document.removeEventListener("keyup", scheduleRejection);
document.removeEventListener("pointerup", scheduleRejection);
if (rejectPromise) {
// so that something is shown in console if we need to debug this
console.warn("Opening the file was canceled (legacy-fs).");
rejectPromise(new Error("Request Aborted"));
}
};
},
}) as Promise<RetType>;
};
+92 -20
View File
@@ -5,6 +5,8 @@ import {
CaptureUpdateAction,
reconcileElements,
useEditorInterface,
ExcalidrawAPIProvider,
useExcalidrawAPI,
} from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
@@ -34,7 +36,6 @@ import {
import polyfill from "@excalidraw/excalidraw/polyfill";
import { useCallback, useEffect, useRef, useState } from "react";
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
import { t } from "@excalidraw/excalidraw/i18n";
import {
@@ -74,6 +75,7 @@ import type {
BinaryFiles,
ExcalidrawInitialDataState,
UIAppState,
ExcalidrawProps,
} from "@excalidraw/excalidraw/types";
import type { ResolutionType } from "@excalidraw/common/utility-types";
import type { ResolvablePromise } from "@excalidraw/common/utils";
@@ -114,6 +116,7 @@ import {
} from "./data";
import { updateStaleImageStatuses } from "./data/FileManager";
import { FileStatusStore } from "./data/fileStatusStore";
import {
importFromLocalStorage,
importUsernameFromLocalStorage,
@@ -369,6 +372,8 @@ const initializeScene = async (opts: {
};
const ExcalidrawWrapper = () => {
const excalidrawAPI = useExcalidrawAPI();
const [errorMessage, setErrorMessage] = useState("");
const isCollabDisabled = isRunningInIframe();
@@ -399,9 +404,6 @@ const ExcalidrawWrapper = () => {
}, VERSION_TIMEOUT);
}, []);
const [excalidrawAPI, excalidrawRefCallback] =
useCallbackRefState<ExcalidrawImperativeAPI>();
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
const [collabAPI] = useAtom(collabAPIAtom);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
@@ -433,18 +435,15 @@ const ExcalidrawWrapper = () => {
}
}, [excalidrawAPI]);
useEffect(() => {
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
return;
}
const loadImages = (
data: ResolutionType<typeof initializeScene>,
isInitialLoad = false,
) => {
if (!data.scene) {
// ---------------------------------------------------------------------------
// Hoisted loadImages
// ---------------------------------------------------------------------------
const loadImages = useCallback(
(data: ResolutionType<typeof initializeScene>, isInitialLoad = false) => {
if (!data.scene || !excalidrawAPI) {
return;
}
if (collabAPI?.isCollaborating()) {
if (data.scene.elements) {
collabAPI
@@ -471,6 +470,12 @@ const ExcalidrawWrapper = () => {
}, [] as FileId[]) || [];
if (data.isExternalScene) {
if (fileIds.length) {
// Direct Firebase call (not through FileManager), so track manually
FileStatusStore.updateStatuses(
fileIds.map((id) => [id, "loading"]),
);
}
loadFilesFromFirebase(
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
data.key,
@@ -482,12 +487,18 @@ const ExcalidrawWrapper = () => {
erroredFiles,
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
});
FileStatusStore.updateStatuses([
...loadedFiles.map((f) => [f.id, "loaded"] as [FileId, "loaded"]),
...[...erroredFiles.keys()].map(
(id) => [id, "error"] as [FileId, "error"],
),
]);
});
} else if (isInitialLoad) {
if (fileIds.length) {
LocalData.fileStorage
.getFiles(fileIds)
.then(({ loadedFiles, erroredFiles }) => {
.then(async ({ loadedFiles, erroredFiles }) => {
if (loadedFiles.length) {
excalidrawAPI.addFiles(loadedFiles);
}
@@ -500,10 +511,19 @@ const ExcalidrawWrapper = () => {
}
// on fresh load, clear unused files from IDB (from previous
// session)
LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
LocalData.fileStorage.clearObsoleteFiles({
currentFileIds: fileIds,
});
}
}
};
},
[collabAPI, excalidrawAPI],
);
useEffect(() => {
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
return;
}
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
loadImages(data, /* isInitialLoad */ true);
@@ -628,7 +648,7 @@ const ExcalidrawWrapper = () => {
false,
);
};
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode, loadImages]);
useEffect(() => {
const unloadHandler = (event: BeforeUnloadEvent) => {
@@ -773,6 +793,56 @@ const ExcalidrawWrapper = () => {
[setShareDialogState],
);
// ---------------------------------------------------------------------------
// onExport — intercepts file save to wait for pending image loads
// ---------------------------------------------------------------------------
const onExport: Required<ExcalidrawProps>["onExport"] = useCallback(
async function* () {
let snapshot = FileStatusStore.getSnapshot();
const { pending, total } = FileStatusStore.getPendingCount(
snapshot.value,
);
if (pending === 0) {
return;
}
// Yield initial progress
yield {
type: "progress",
progress: (total - pending) / total,
message: `Loading images (${total - pending}/${total})...`,
};
// Wait for all pending images to finish
while (true) {
snapshot = await FileStatusStore.pull(snapshot.version);
const { pending: nowPending, total: nowTotal } =
FileStatusStore.getPendingCount(snapshot.value);
yield {
type: "progress",
progress: (nowTotal - nowPending) / nowTotal,
message: `Loading images (${nowTotal - nowPending}/${nowTotal})...`,
};
if (nowPending === 0) {
await new Promise((r) => setTimeout(r, 500));
yield {
type: "progress",
message: `Preparing export...`,
};
return;
}
}
},
[],
);
// const onExport = () => {
// return new Promise((r) => setTimeout(r, 2500));
// // console.log("onExport");
// };
// browsers generally prevent infinite self-embedding, there are
// cases where it still happens, and while we disallow self-embedding
// by not whitelisting our own origin, this serves as an additional guard
@@ -839,8 +909,8 @@ const ExcalidrawWrapper = () => {
})}
>
<Excalidraw
excalidrawAPI={excalidrawRefCallback}
onChange={onChange}
onExport={onExport}
initialData={initialStatePromiseRef.current.promise}
isCollaborating={isCollaborating}
onPointerUpdate={collabAPI?.onPointerUpdate}
@@ -1206,7 +1276,9 @@ const ExcalidrawApp = () => {
return (
<TopErrorBoundary>
<Provider store={appJotaiStore}>
<ExcalidrawWrapper />
<ExcalidrawAPIProvider>
<ExcalidrawWrapper />
</ExcalidrawAPIProvider>
</Provider>
</TopErrorBoundary>
);
+2
View File
@@ -72,6 +72,7 @@ import {
FileManager,
updateStaleImageStatuses,
} from "../data/FileManager";
import { FileStatusStore } from "../data/fileStatusStore";
import { LocalData } from "../data/LocalData";
import {
isSavedToFirebase,
@@ -149,6 +150,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
};
this.portal = new Portal(this);
this.fileManager = new FileManager({
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
getFiles: async (fileIds) => {
const { roomId, roomKey } = this.portal;
if (!roomId || !roomKey) {
@@ -414,7 +414,6 @@ export const debugRenderer = throttleRAF(
) => {
_debugRenderer(canvas, appState, elements, scale);
},
{ trailing: true },
);
export const loadSavedDebugState = () => {
+22
View File
@@ -40,10 +40,12 @@ export class FileManager {
private _getFiles;
private _saveFiles;
private _onFileStatusChange;
constructor({
getFiles,
saveFiles,
onFileStatusChange,
}: {
getFiles: (fileIds: FileId[]) => Promise<{
loadedFiles: BinaryFileData[];
@@ -53,9 +55,13 @@ export class FileManager {
savedFiles: Map<FileId, BinaryFileData>;
erroredFiles: Map<FileId, BinaryFileData>;
}>;
onFileStatusChange?: (
updates: Array<[FileId, "loading" | "loaded" | "error"]>,
) => void;
}) {
this._getFiles = getFiles;
this._saveFiles = saveFiles;
this._onFileStatusChange = onFileStatusChange;
}
/**
@@ -146,6 +152,8 @@ export class FileManager {
this.fetchingFiles.set(id, true);
}
this._onFileStatusChange?.(ids.map((id) => [id, "loading"]));
try {
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
@@ -156,6 +164,13 @@ export class FileManager {
this.erroredFiles_fetch.set(fileId, true);
}
this._onFileStatusChange?.([
...loadedFiles.map((f) => [f.id, "loaded"] as [FileId, "loaded"]),
...[...erroredFiles.keys()].map(
(id) => [id, "error"] as [FileId, "error"],
),
]);
return { loadedFiles, erroredFiles };
} finally {
for (const id of ids) {
@@ -195,6 +210,13 @@ export class FileManager {
};
reset() {
if (this._onFileStatusChange && this.fetchingFiles.size) {
this._onFileStatusChange(
[...this.fetchingFiles.keys()].map(
(id) => [id, "error"] as [FileId, "error"],
),
);
}
this.fetchingFiles.clear();
this.savingFiles.clear();
this.savedFiles.clear();
+2
View File
@@ -42,6 +42,7 @@ import type { MaybePromise } from "@excalidraw/common/utility-types";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager";
import { FileStatusStore } from "./fileStatusStore";
import { Locker } from "./Locker";
import { updateBrowserStateVersion } from "./tabSync";
@@ -166,6 +167,7 @@ export class LocalData {
// ---------------------------------------------------------------------------
static fileStorage = new LocalFileManager({
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
getFiles(ids) {
return getMany(ids, filesStore).then(
async (filesData: (BinaryFileData | undefined)[]) => {
+48
View File
@@ -0,0 +1,48 @@
import { VersionedSnapshotStore } from "@excalidraw/common";
import type { FileId } from "@excalidraw/element/types";
export type FileLoadingStatus = "loading" | "loaded" | "error";
export class FileStatusStore {
private static store = new VersionedSnapshotStore<
Map<FileId, FileLoadingStatus>
>(new Map());
static getSnapshot() {
return this.store.getSnapshot();
}
static pull(sinceVersion?: number) {
return this.store.pull(sinceVersion);
}
static updateStatuses(updates: Array<[FileId, FileLoadingStatus]>) {
if (!updates.length) {
return;
}
this.store.update((prev) => {
let changed = false;
const next = new Map(prev);
for (const [id, status] of updates) {
if (next.get(id) !== status) {
next.set(id, status);
changed = true;
}
}
return changed ? next : prev;
});
}
static getPendingCount(statuses: Map<FileId, FileLoadingStatus>) {
let pending = 0;
let total = 0;
for (const status of statuses.values()) {
total++;
if (status === "loading") {
pending++;
}
}
return { pending, total };
}
}
+10 -1
View File
@@ -106,6 +106,10 @@ export default defineConfig(({ mode }) => {
if (id.includes("@excalidraw/mermaid-to-excalidraw")) {
return "mermaid-to-excalidraw";
}
if (id.includes("@codemirror/") || id.includes("@lezer/")) {
return "codemirror.chunk";
}
},
},
},
@@ -150,6 +154,11 @@ export default defineConfig(({ mode }) => {
"**/locales/**",
"service-worker.js",
"**/*.chunk-*.js",
// CodeMirrorEditor can't be assigned a `.chunk` name via
// manualChunks because Rollup would hoist shared deps (React)
// via a static import from the main bundle, defeating lazy
// loading. So we exclude it by name instead.
"**/CodeMirrorEditor-*.js",
],
runtimeCaching: [
{
@@ -189,7 +198,7 @@ export default defineConfig(({ mode }) => {
},
},
{
urlPattern: new RegExp(".chunk-.+.js"),
urlPattern: new RegExp("(.chunk-.+|CodeMirrorEditor-.+)\\.js"),
handler: "CacheFirst",
options: {
cacheName: "chunk",
@@ -1,9 +1,3 @@
declare global {
interface Window {
debug: typeof Debug;
}
}
const lessPrecise = (num: number, precision = 5) =>
parseFloat(num.toPrecision(precision));
@@ -157,6 +151,70 @@ export class Debug {
return ret;
};
};
private static CHANGED_CACHE: Record<string, Record<string, unknown>> = {};
public static logChanged(name: string, obj: Record<string, unknown>) {
const prev = Debug.CHANGED_CACHE[name];
Debug.CHANGED_CACHE[name] = obj;
if (!prev) {
return;
}
const allKeys = new Set([...Object.keys(prev), ...Object.keys(obj)]);
const changed: Record<string, { prev: unknown; next: unknown }> = {};
for (const key of allKeys) {
const prevVal = prev[key];
const nextVal = obj[key];
if (!deepEqual(prevVal, nextVal)) {
changed[key] = { prev: prevVal, next: nextVal };
}
}
if (Object.keys(changed).length > 0) {
console.info(`[${name}] changed:`, changed);
}
}
}
function deepEqual(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) {
return true;
}
if (
a === null ||
b === null ||
typeof a !== "object" ||
typeof b !== "object"
) {
return false;
}
if (Array.isArray(a) !== Array.isArray(b)) {
return false;
}
const keysA = Object.keys(a as Record<string, unknown>);
const keysB = Object.keys(b as Record<string, unknown>);
if (keysA.length !== keysB.length) {
return false;
}
for (const key of keysA) {
if (
!deepEqual(
(a as Record<string, unknown>)[key],
(b as Record<string, unknown>)[key],
)
) {
return false;
}
}
return true;
}
//@ts-ignore
window.debug = Debug;
+74
View File
@@ -0,0 +1,74 @@
import { AppEventBus } from "./appEventBus";
type TestEvents = {
initialize: [api: number];
pointerUp: [pointerId: string];
viewState: [zoom: number];
};
const behavior = {
initialize: { cardinality: "once", replay: "last" },
pointerUp: { cardinality: "many", replay: "none" },
viewState: { cardinality: "many", replay: "last" },
} as const;
const flushMicrotasks = async () => Promise.resolve();
describe("AppEventBus", () => {
it("replays once events to late callback and Promise subscribers", async () => {
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
bus.emit("initialize", 42);
const calls: number[] = [];
bus.on("initialize", (value) => {
calls.push(value);
});
expect(calls).toEqual([]);
await flushMicrotasks();
expect(calls).toEqual([42]);
await expect(bus.on("initialize")).resolves.toBe(42);
});
it("does not replay stream events to late subscribers", async () => {
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
bus.emit("pointerUp", "first");
const calls: string[] = [];
bus.on("pointerUp", (pointerId) => {
calls.push(pointerId);
});
await flushMicrotasks();
expect(calls).toEqual([]);
bus.emit("pointerUp", "second");
expect(calls).toEqual(["second"]);
});
it("replays replay-last stream events and stays subscribed", async () => {
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
bus.emit("viewState", 1);
const calls: number[] = [];
bus.on("viewState", (zoom) => {
calls.push(zoom);
});
await flushMicrotasks();
expect(calls).toEqual([1]);
bus.emit("viewState", 2);
expect(calls).toEqual([1, 2]);
});
it("throws when emitting a once event twice", () => {
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
bus.emit("initialize", 1);
expect(() => {
bus.emit("initialize", 2);
}).toThrow('Event "initialize" can only be emitted once');
});
});
+136
View File
@@ -0,0 +1,136 @@
import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types";
import { Emitter } from "./emitter";
import { isProdEnv } from "./utils";
export type AppEventPayloadMap = Record<string, unknown[]>;
export type AppEventBehavior = {
cardinality: "once" | "many";
replay: "none" | "last";
};
export type AppEventBehaviorMap<Events extends AppEventPayloadMap> = {
[K in keyof Events]: AppEventBehavior;
};
type AwaitableAppEventKeys<
Events extends AppEventPayloadMap,
Behavior extends AppEventBehaviorMap<Events>,
> = {
[K in keyof Events]: Behavior[K]["cardinality"] extends "once"
? Behavior[K]["replay"] extends "last"
? K
: never
: never;
}[keyof Events];
type AppEventPromiseValue<Args extends any[]> = Args extends [infer Only]
? Only
: Args;
export class AppEventBus<
Events extends AppEventPayloadMap,
Behavior extends AppEventBehaviorMap<Events>,
> {
private readonly emitters = new Map<keyof Events, Emitter<any>>();
private readonly lastPayload = new Map<keyof Events, any[]>();
private readonly emittedOnce = new Set<keyof Events>();
constructor(private readonly behavior: Behavior) {}
private getEmitter<K extends keyof Events>(name: K): Emitter<Events[K]> {
let emitter = this.emitters.get(name);
if (!emitter) {
emitter = new Emitter<any>();
this.emitters.set(name, emitter);
}
return emitter as Emitter<Events[K]>;
}
private toPromiseValue<Args extends any[]>(
args: Args,
): AppEventPromiseValue<Args> {
return (args.length === 1 ? args[0] : args) as AppEventPromiseValue<Args>;
}
public on<K extends keyof Events>(
name: K,
callback: (...args: Events[K]) => void,
): UnsubscribeCallback;
public on<K extends AwaitableAppEventKeys<Events, Behavior>>(
name: K,
): Promise<AppEventPromiseValue<Events[K]>>;
public on<K extends keyof Events>(
name: K,
callback?: (...args: Events[K]) => void,
): UnsubscribeCallback | Promise<AppEventPromiseValue<Events[K]>> {
const eventBehavior = this.behavior[name];
const cachedPayload = this.lastPayload.get(name) as Events[K] | undefined;
if (callback) {
if (eventBehavior.replay === "last" && cachedPayload) {
queueMicrotask(() => callback(...cachedPayload));
if (eventBehavior.cardinality === "once") {
return () => {};
}
}
return this.getEmitter(name).on(callback);
}
if (
eventBehavior.cardinality !== "once" ||
eventBehavior.replay !== "last"
) {
throw new Error(`Event "${String(name)}" requires a callback`);
}
if (cachedPayload) {
return Promise.resolve(this.toPromiseValue(cachedPayload));
}
return new Promise<AppEventPromiseValue<Events[K]>>((resolve) => {
this.getEmitter(name).once((...args: Events[K]) => {
resolve(this.toPromiseValue(args));
});
});
}
public emit<K extends keyof Events>(name: K, ...args: Events[K]) {
const eventBehavior = this.behavior[name];
if (!isProdEnv()) {
if (eventBehavior.cardinality === "once") {
if (this.emittedOnce.has(name)) {
throw new Error(`Event "${String(name)}" can only be emitted once`);
}
this.emittedOnce.add(name);
}
}
if (eventBehavior.replay === "last") {
this.lastPayload.set(name, args);
}
try {
this.getEmitter(name).trigger(...args);
} finally {
if (eventBehavior.cardinality === "once") {
this.getEmitter(name).clear();
}
}
}
public clear() {
this.lastPayload.clear();
this.emittedOnce.clear();
for (const emitter of this.emitters.values()) {
emitter.clear();
}
this.emitters.clear();
}
}
+14 -15
View File
@@ -240,22 +240,21 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
// -----------------------------------------------------------------------------
// !!!MUST BE WITHOUT GRAY, TRANSPARENT AND BLACK!!!
export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) =>
[
// 2nd row
COLOR_PALETTE.cyan[index],
COLOR_PALETTE.blue[index],
COLOR_PALETTE.violet[index],
COLOR_PALETTE.grape[index],
COLOR_PALETTE.pink[index],
export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) => [
// 2nd row
COLOR_PALETTE.cyan[index],
COLOR_PALETTE.blue[index],
COLOR_PALETTE.violet[index],
COLOR_PALETTE.grape[index],
COLOR_PALETTE.pink[index],
// 3rd row
COLOR_PALETTE.green[index],
COLOR_PALETTE.teal[index],
COLOR_PALETTE.yellow[index],
COLOR_PALETTE.orange[index],
COLOR_PALETTE.red[index],
] as const;
// 3rd row
COLOR_PALETTE.green[index],
COLOR_PALETTE.teal[index],
COLOR_PALETTE.yellow[index],
COLOR_PALETTE.orange[index],
COLOR_PALETTE.red[index],
];
// -----------------------------------------------------------------------------
// other helpers
+3
View File
@@ -11,4 +11,7 @@ export * from "./random";
export * from "./url";
export * from "./utils";
export * from "./emitter";
export * from "./appEventBus";
export * from "./editorInterface";
export * from "./versionedSnapshotStore";
export { Debug } from "../debug";
+89
View File
@@ -3,6 +3,12 @@ import {
mapFind,
reduceToCommonValue,
} from "@excalidraw/common";
import { vi } from "vitest";
// Import directly to avoid the @excalidraw/common throttleRAF mock from setupTests.ts.
import { throttleRAF } from "./utils";
type RafCallback = FrameRequestCallback;
describe("@excalidraw/common/utils", () => {
describe("isTransparent()", () => {
@@ -79,4 +85,87 @@ describe("@excalidraw/common/utils", () => {
expect(mapFind([1, 2], () => null)).toBe(undefined);
});
});
describe("throttleRAF()", () => {
let frameCallbacks: Map<number, RafCallback>;
let nextFrameId: number;
const runScheduledFrame = (timestamp = 16) => {
const callbacks = [...frameCallbacks.values()];
frameCallbacks.clear();
callbacks.forEach((callback) => callback(timestamp));
};
beforeEach(() => {
frameCallbacks = new Map();
nextFrameId = 0;
vi.spyOn(window, "requestAnimationFrame").mockImplementation(
(callback) => {
const frameId = ++nextFrameId;
frameCallbacks.set(frameId, callback);
return frameId;
},
);
vi.spyOn(window, "cancelAnimationFrame").mockImplementation((frameId) => {
frameCallbacks.delete(frameId);
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should invoke the callback with the last args from the same frame", () => {
const fn = vi.fn();
const throttled = throttleRAF(fn);
throttled("first", 1);
throttled("second", 2);
throttled("last", 3);
expect(fn).not.toHaveBeenCalled();
expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1);
runScheduledFrame();
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith("last", 3);
});
it("should flush the pending callback immediately", () => {
const fn = vi.fn();
const throttled = throttleRAF(fn);
throttled("first");
throttled("last");
throttled.flush();
expect(window.cancelAnimationFrame).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith("last");
runScheduledFrame();
expect(fn).toHaveBeenCalledTimes(1);
});
it("should cancel the pending callback", () => {
const fn = vi.fn();
const throttled = throttleRAF(fn);
throttled("first");
throttled("last");
throttled.cancel();
expect(window.cancelAnimationFrame).toHaveBeenCalledTimes(1);
runScheduledFrame();
expect(fn).not.toHaveBeenCalled();
});
});
});
+23 -24
View File
@@ -1,5 +1,7 @@
import { average } from "@excalidraw/math";
import type { GlobalCoord } from "@excalidraw/math";
import type { FontFamilyValues, FontString } from "@excalidraw/element/types";
import type {
@@ -86,7 +88,8 @@ export const isWritableElement = (
(target.type === "text" ||
target.type === "number" ||
target.type === "password" ||
target.type === "search"));
target.type === "search")) ||
(target instanceof HTMLElement && target.closest(".cm-editor") !== null);
export const getFontFamilyString = ({
fontFamily,
@@ -148,38 +151,27 @@ export const debounce = <T extends any[]>(
return ret;
};
// throttle callback to execute once per animation frame
export const throttleRAF = <T extends any[]>(
fn: (...args: T) => void,
opts?: { trailing?: boolean },
) => {
// throttle callback to execute once per animation frame using the latest args
export const throttleRAF = <T extends any[]>(fn: (...args: T) => void) => {
let timerId: number | null = null;
let lastArgs: T | null = null;
let lastArgsTrailing: T | null = null;
const scheduleFunc = (args: T) => {
const scheduleFunc = () => {
timerId = window.requestAnimationFrame(() => {
timerId = null;
fn(...args);
const args = lastArgs;
lastArgs = null;
if (lastArgsTrailing) {
lastArgs = lastArgsTrailing;
lastArgsTrailing = null;
scheduleFunc(lastArgs);
if (args) {
fn(...args);
}
});
};
const ret = (...args: T) => {
if (isTestEnv()) {
fn(...args);
return;
}
lastArgs = args;
if (timerId === null) {
scheduleFunc(lastArgs);
} else if (opts?.trailing) {
lastArgsTrailing = args;
scheduleFunc();
}
};
ret.flush = () => {
@@ -188,12 +180,12 @@ export const throttleRAF = <T extends any[]>(
timerId = null;
}
if (lastArgs) {
fn(...(lastArgsTrailing || lastArgs));
lastArgs = lastArgsTrailing = null;
fn(...lastArgs);
lastArgs = null;
}
};
ret.cancel = () => {
lastArgs = lastArgsTrailing = null;
lastArgs = null;
if (timerId !== null) {
cancelAnimationFrame(timerId);
timerId = null;
@@ -441,7 +433,7 @@ export const viewportCoordsToSceneCoords = (
const x = (clientX - offsetLeft) / zoom.value - scrollX;
const y = (clientY - offsetTop) / zoom.value - scrollY;
return { x, y };
return { x, y } as GlobalCoord;
};
export const sceneCoordsToViewportCoords = (
@@ -1330,3 +1322,10 @@ export const setFeatureFlag = <F extends keyof FEATURE_FLAGS>(
console.error("unable to set feature flag", e);
}
};
export const oneOf = <N extends string | number | symbol | null, H extends N>(
needle: N,
haystack: readonly H[],
): needle is H => {
return haystack.includes(needle as any);
};
@@ -0,0 +1,70 @@
export type VersionedSnapshot<T> = Readonly<{
version: number;
value: T;
}>;
export class VersionedSnapshotStore<T> {
private version = 0;
private value: T;
private readonly waiters = new Set<
(snapshot: VersionedSnapshot<T>) => void
>();
private readonly subscribers = new Set<
(snapshot: VersionedSnapshot<T>) => void
>();
constructor(
initialValue: T,
private readonly isEqual: (prev: T, next: T) => boolean = Object.is,
) {
this.value = initialValue;
}
public getSnapshot(): VersionedSnapshot<T> {
return { version: this.version, value: this.value };
}
public set(nextValue: T): boolean {
if (this.isEqual(this.value, nextValue)) {
return false;
}
this.value = nextValue;
this.version += 1;
const snapshot = this.getSnapshot();
for (const subscriber of this.subscribers) {
subscriber(snapshot);
}
for (const waiter of this.waiters) {
waiter(snapshot);
}
this.waiters.clear();
return true;
}
public update(updater: (prev: T) => T): boolean {
return this.set(updater(this.value));
}
public subscribe(
subscriber: (snapshot: VersionedSnapshot<T>) => void,
): () => void {
this.subscribers.add(subscriber);
return () => {
this.subscribers.delete(subscriber);
};
}
public pull(sinceVersion = -1): Promise<VersionedSnapshot<T>> {
if (this.version !== sinceVersion) {
return Promise.resolve(this.getSnapshot());
}
return new Promise((resolve) => {
this.waiters.add(resolve);
});
}
}
+2
View File
@@ -438,6 +438,8 @@ export class Scene {
options: {
informMutation: boolean;
isDragging: boolean;
isBindingEnabled?: boolean;
isMidpointSnappingEnabled?: boolean;
} = {
informMutation: true,
isDragging: false,
+32
View File
@@ -0,0 +1,32 @@
import type { Arrowhead, AnyArrowhead } from "./types";
export const normalizeArrowhead = (
arrowhead: AnyArrowhead | null | undefined,
): Arrowhead | null => {
switch (arrowhead) {
case undefined:
case null:
return null;
case "dot":
return "circle";
case "crowfoot_one":
return "cardinality_one";
case "crowfoot_many":
return "cardinality_many";
case "crowfoot_one_or_many":
return "cardinality_one_or_many";
default:
return arrowhead;
}
};
export const getArrowheadForPicker = (
arrowhead: AnyArrowhead | null | undefined,
): Arrowhead | null => {
const normalizedArrowhead = normalizeArrowhead(arrowhead);
if (normalizedArrowhead === null) {
return null;
}
return normalizedArrowhead;
};
+77 -33
View File
@@ -1,5 +1,4 @@
import {
KEYS,
arrayToMap,
getFeatureFlag,
invariant,
@@ -137,12 +136,6 @@ export const maxBindingDistance_simple = (zoom?: AppState["zoom"]): number => {
);
};
export const shouldEnableBindingForPointerEvent = (
event: React.PointerEvent<HTMLElement>,
) => {
return !event[KEYS.CTRL_OR_CMD];
};
export const isBindingEnabled = (appState: {
isBindingEnabled: AppState["isBindingEnabled"];
}): boolean => {
@@ -177,8 +170,20 @@ export const bindOrUnbindBindingElement = (
},
);
bindOrUnbindBindingElementEdge(arrow, start, "start", scene);
bindOrUnbindBindingElementEdge(arrow, end, "end", scene);
bindOrUnbindBindingElementEdge(
arrow,
start,
"start",
scene,
appState.isBindingEnabled,
);
bindOrUnbindBindingElementEdge(
arrow,
end,
"end",
scene,
appState.isBindingEnabled,
);
if (start.focusPoint || end.focusPoint) {
// If the strategy dictates a focus point override, then
// update the arrow points to point to the focus point.
@@ -221,12 +226,21 @@ const bindOrUnbindBindingElementEdge = (
{ mode, element, focusPoint }: BindingStrategy,
startOrEnd: "start" | "end",
scene: Scene,
shouldSnapToOutline = true,
): void => {
if (mode === null) {
// null means break the binding
unbindBindingElement(arrow, startOrEnd, scene);
} else if (mode !== undefined) {
bindBindingElement(arrow, element, mode, startOrEnd, scene, focusPoint);
bindBindingElement(
arrow,
element,
mode,
startOrEnd,
scene,
focusPoint,
shouldSnapToOutline,
);
}
};
@@ -798,6 +812,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
startDragged ? "start" : "end",
elementsMap,
appState.zoom,
appState.isMidpointSnappingEnabled,
) || globalPoint,
}
: { mode: null };
@@ -842,6 +857,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
startDragged ? "end" : "start",
elementsMap,
appState.zoom,
appState.isMidpointSnappingEnabled,
) || otherEndpoint,
}
: { mode: undefined }
@@ -1005,6 +1021,7 @@ export const bindBindingElement = (
startOrEnd: "start" | "end",
scene: Scene,
focusPoint?: GlobalPoint,
shouldSnapToOutline = true,
): void => {
const elementsMap = scene.getNonDeletedElementsMap();
@@ -1019,6 +1036,7 @@ export const bindBindingElement = (
hoveredElement,
startOrEnd,
elementsMap,
shouldSnapToOutline,
),
};
} else {
@@ -1352,6 +1370,7 @@ export const bindPointToSnapToElementOutline = (
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
customIntersector?: LineSegment<GlobalPoint>,
isMidpointSnappingEnabled = true,
): GlobalPoint => {
const elbowed = isElbowArrow(arrowElement);
const point = LinearElementEditor.getPointAtIndexGlobalCoordinates(
@@ -1391,13 +1410,9 @@ export const bindPointToSnapToElementOutline = (
const isHorizontal = headingIsHorizontal(
headingForPointFromElement(bindableElement, aabb, point),
);
const snapPoint = snapToMid(
bindableElement,
elementsMap,
edgePoint,
0.05,
arrowElement,
);
const snapPoint = isMidpointSnappingEnabled
? snapToMid(bindableElement, elementsMap, edgePoint, 0.05, arrowElement)
: undefined;
const resolved = snapPoint || point;
const otherPoint = pointFrom<GlobalPoint>(
isHorizontal ? bindableCenter[0] : resolved[0],
@@ -1776,10 +1791,13 @@ export const updateBoundPoint = (
);
const otherArrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
startOrEnd === "startBinding" ? -1 : 0,
startOrEnd === "startBinding" ? 1 : -2,
elementsMap,
);
const otherFocusPointOrArrowPoint = otherFocusPoint || otherArrowPoint;
const otherFocusPointOrArrowPoint =
arrow.points.length === 2
? otherFocusPoint || otherArrowPoint
: otherArrowPoint;
const intersector =
otherFocusPointOrArrowPoint &&
lineSegment(focusPoint, otherFocusPointOrArrowPoint);
@@ -1889,6 +1907,8 @@ export const calculateFixedPointForElbowArrowBinding = (
hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
shouldSnapToOutline = true,
isMidpointSnappingEnabled = true,
): { fixedPoint: FixedPoint } => {
const bounds = [
hoveredElement.x,
@@ -1896,12 +1916,20 @@ export const calculateFixedPointForElbowArrowBinding = (
hoveredElement.x + hoveredElement.width,
hoveredElement.y + hoveredElement.height,
] as Bounds;
const snappedPoint = bindPointToSnapToElementOutline(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
);
const snappedPoint = shouldSnapToOutline
? bindPointToSnapToElementOutline(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
undefined,
isMidpointSnappingEnabled,
)
: LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
startOrEnd === "start" ? 0 : -1,
elementsMap,
);
const globalMidPoint = pointFrom(
bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2,
@@ -2447,21 +2475,37 @@ export const getArrowLocalFixedPoints = (
];
};
export const normalizeFixedPoint = <T extends FixedPoint | null>(
export const isFixedPoint = (
fixedPoint: any,
): fixedPoint is FixedPointBinding["fixedPoint"] => {
return (
Array.isArray(fixedPoint) &&
fixedPoint.length === 2 &&
fixedPoint.every((coord) => Number.isFinite(coord))
);
};
export const normalizeFixedPoint = <T extends FixedPoint>(
fixedPoint: T,
): T extends null ? null : FixedPoint => {
): FixedPoint => {
if (!isFixedPoint(fixedPoint)) {
return [0.5001, 0.5001];
}
const EPSILON = 0.0001;
// Do not allow a precise 0.5 for fixed point ratio
// to avoid jumping arrow heading due to floating point imprecision
if (
fixedPoint &&
(Math.abs(fixedPoint[0] - 0.5) < 0.0001 ||
Math.abs(fixedPoint[1] - 0.5) < 0.0001)
Math.abs(fixedPoint[0] - 0.5) < EPSILON ||
Math.abs(fixedPoint[1] - 0.5) < EPSILON
) {
return fixedPoint.map((ratio) =>
Math.abs(ratio - 0.5) < 0.0001 ? 0.5001 : ratio,
) as T extends null ? null : FixedPoint;
Math.abs(ratio - 0.5) < EPSILON ? 0.5001 : ratio,
) as FixedPoint;
}
return fixedPoint as any as T extends null ? null : FixedPoint;
return fixedPoint;
};
type Side =
+39 -26
View File
@@ -709,6 +709,9 @@ const getFreeDrawElementAbsoluteCoords = (
return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
};
const CARDINALITY_MARKER_SIZE = 20;
const CROWFOOT_ARROWHEAD_SIZE = 15;
/** @returns number in pixels */
export const getArrowheadSize = (arrowhead: Arrowhead): number => {
switch (arrowhead) {
@@ -717,10 +720,14 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
case "diamond":
case "diamond_outline":
return 12;
case "crowfoot_many":
case "crowfoot_one":
case "crowfoot_one_or_many":
return 20;
case "cardinality_many":
case "cardinality_one_or_many":
case "cardinality_zero_or_many":
return CROWFOOT_ARROWHEAD_SIZE;
case "cardinality_one":
case "cardinality_exactly_one":
case "cardinality_zero_or_one":
return CARDINALITY_MARKER_SIZE;
default:
return 15;
}
@@ -743,7 +750,12 @@ export const getArrowheadPoints = (
shape: Drawable[],
position: "start" | "end",
arrowhead: Arrowhead,
offsetMultiplier = 0,
) => {
if (arrowhead === null) {
return null;
}
if (shape.length < 1) {
return null;
}
@@ -824,29 +836,30 @@ export const getArrowheadPoints = (
const lengthMultiplier =
arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5;
const minSize = Math.min(size, length * lengthMultiplier);
const xs = x2 - nx * minSize;
const ys = y2 - ny * minSize;
const tx = x2 - nx * minSize * offsetMultiplier;
const ty = y2 - ny * minSize * offsetMultiplier;
const xs = tx - nx * minSize;
const ys = ty - ny * minSize;
if (
arrowhead === "dot" ||
arrowhead === "circle" ||
arrowhead === "circle_outline"
) {
const diameter = Math.hypot(ys - y2, xs - x2) + element.strokeWidth - 2;
return [x2, y2, diameter];
if (arrowhead === "circle" || arrowhead === "circle_outline") {
const diameter = Math.hypot(ys - ty, xs - tx) + element.strokeWidth - 2;
return [tx, ty, diameter];
}
const angle = getArrowheadAngle(arrowhead);
if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") {
if (
arrowhead === "cardinality_many" ||
arrowhead === "cardinality_one_or_many"
) {
// swap (xs, ys) with (x2, y2)
const [x3, y3] = pointRotateRads(
pointFrom(x2, y2),
pointFrom(tx, ty),
pointFrom(xs, ys),
degreesToRadians(-angle as Degrees),
);
const [x4, y4] = pointRotateRads(
pointFrom(x2, y2),
pointFrom(tx, ty),
pointFrom(xs, ys),
degreesToRadians(angle),
);
@@ -856,12 +869,12 @@ export const getArrowheadPoints = (
// Return points
const [x3, y3] = pointRotateRads(
pointFrom(xs, ys),
pointFrom(x2, y2),
pointFrom(tx, ty),
((-angle * Math.PI) / 180) as Radians,
);
const [x4, y4] = pointRotateRads(
pointFrom(xs, ys),
pointFrom(x2, y2),
pointFrom(tx, ty),
degreesToRadians(angle),
);
@@ -874,9 +887,9 @@ export const getArrowheadPoints = (
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
[ox, oy] = pointRotateRads(
pointFrom(x2 + minSize * 2, y2),
pointFrom(x2, y2),
Math.atan2(py - y2, px - x2) as Radians,
pointFrom(tx + minSize * 2, ty),
pointFrom(tx, ty),
Math.atan2(py - ty, px - tx) as Radians,
);
} else {
const [px, py] =
@@ -885,16 +898,16 @@ export const getArrowheadPoints = (
: [0, 0];
[ox, oy] = pointRotateRads(
pointFrom(x2 - minSize * 2, y2),
pointFrom(x2, y2),
Math.atan2(y2 - py, x2 - px) as Radians,
pointFrom(tx - minSize * 2, ty),
pointFrom(tx, ty),
Math.atan2(ty - py, tx - px) as Radians,
);
}
return [x2, y2, x3, y3, ox, oy, x4, y4];
return [tx, ty, x3, y3, ox, oy, x4, y4];
}
return [x2, y2, x3, y3, x4, y4];
return [tx, ty, x3, y3, x4, y4];
};
// TODO reuse shape.ts
+11 -2
View File
@@ -465,7 +465,12 @@ export const intersectElementWithLineSegment = (
case "line":
case "freedraw":
case "arrow":
return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
return intersectLinearOrFreeDrawWithLineSegment(
element,
line,
elementsMap,
onlyFirst,
);
}
};
@@ -532,11 +537,15 @@ const lineIntersections = (
const intersectLinearOrFreeDrawWithLineSegment = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
segment: LineSegment<GlobalPoint>,
elementsMap: ElementsMap,
onlyFirst = false,
): GlobalPoint[] => {
// NOTE: This is the only one which return the decomposed elements
// rotated! This is due to taking advantage of roughjs definitions.
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
const intersections: GlobalPoint[] = [];
for (const l of lines) {
+6 -2
View File
@@ -48,7 +48,7 @@ export const distanceToElement = (
case "line":
case "arrow":
case "freedraw":
return distanceToLinearOrFreeDraElement(element, p);
return distanceToLinearOrFreeDraElement(element, elementsMap, p);
}
};
@@ -133,9 +133,13 @@ const distanceToEllipseElement = (
const distanceToLinearOrFreeDraElement = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
p: GlobalPoint,
) => {
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
return Math.min(
...lines.map((s) => distanceToLineSegment(p, s)),
...curves.map((a) => curvePointDistance(a, p)),
+14 -2
View File
@@ -915,6 +915,8 @@ export const updateElbowArrowPoints = (
},
options?: {
isDragging?: boolean;
isBindingEnabled?: boolean;
isMidpointSnappingEnabled?: boolean;
},
): ElementUpdate<ExcalidrawElbowArrowElement> => {
if (arrow.points.length < 2) {
@@ -1202,6 +1204,8 @@ const getElbowArrowData = (
options?: {
isDragging?: boolean;
zoom?: AppState["zoom"];
isBindingEnabled?: boolean;
isMidpointSnappingEnabled?: boolean;
},
) => {
const origStartGlobalPoint: GlobalPoint = pointTranslate<
@@ -1215,7 +1219,7 @@ const getElbowArrowData = (
let hoveredStartElement = null;
let hoveredEndElement = null;
if (options?.isDragging) {
if (options?.isDragging && options?.isBindingEnabled !== false) {
const elements = Array.from(elementsMap.values());
hoveredStartElement =
getHoveredElement(
@@ -1255,6 +1259,8 @@ const getElbowArrowData = (
hoveredStartElement,
elementsMap,
options?.isDragging,
options?.isBindingEnabled,
options?.isMidpointSnappingEnabled,
);
const endGlobalPoint = getGlobalPoint(
{
@@ -1270,6 +1276,8 @@ const getElbowArrowData = (
hoveredEndElement,
elementsMap,
options?.isDragging,
options?.isBindingEnabled,
options?.isMidpointSnappingEnabled,
);
const startHeading = getBindPointHeading(
startGlobalPoint,
@@ -2213,14 +2221,18 @@ const getGlobalPoint = (
element?: ExcalidrawBindableElement | null,
elementsMap?: ElementsMap,
isDragging?: boolean,
isBindingEnabled = true,
isMidpointSnappingEnabled = true,
): GlobalPoint => {
if (isDragging) {
if (element && elementsMap) {
if (isBindingEnabled && element && elementsMap) {
return bindPointToSnapToElementOutline(
arrow,
element,
startOrEnd,
elementsMap,
undefined,
isMidpointSnappingEnabled,
);
}
+1
View File
@@ -99,3 +99,4 @@ export * from "./typeChecks";
export * from "./utils";
export * from "./zindex";
export * from "./arrows/helpers";
export * from "./arrowheads";
+81 -32
View File
@@ -359,11 +359,20 @@ export class LinearElementEditor {
linearElementEditor,
);
LinearElementEditor.movePoints(element, app.scene, positions, {
startBinding: updates?.startBinding,
endBinding: updates?.endBinding,
moveMidPointsWithElement: updates?.moveMidPointsWithElement,
});
LinearElementEditor.movePoints(
element,
app.scene,
positions,
{
startBinding: updates?.startBinding,
endBinding: updates?.endBinding,
moveMidPointsWithElement: updates?.moveMidPointsWithElement,
},
{
isBindingEnabled: app.state.isBindingEnabled,
isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled,
},
);
// Set the suggested binding from the updates if available
if (isBindingElement(element, false)) {
if (isBindingEnabled(app.state)) {
@@ -418,6 +427,7 @@ export class LinearElementEditor {
"start",
elementsMap,
app.state.zoom,
app.state.isMidpointSnappingEnabled,
)
: linearElementEditor.initialState.altFocusPoint,
},
@@ -466,16 +476,22 @@ export class LinearElementEditor {
});
}
invariant(
lastClickedPoint > -1 &&
selectedPointsIndices.includes(lastClickedPoint) &&
element.points[lastClickedPoint],
`There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify(
selectedPointsIndices,
)}) points(0..${
element.points.length - 1
}) lastClickedPoint(${lastClickedPoint})`,
);
if (
lastClickedPoint < 0 ||
!selectedPointsIndices.includes(lastClickedPoint) ||
!element.points[lastClickedPoint]
) {
console.error(
`There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify(
selectedPointsIndices,
)}) points(0..${
element.points.length - 1
}) lastClickedPoint(${lastClickedPoint})`,
);
// Fall back to the actual last point as a last resort.
lastClickedPoint = element.points.length - 1;
}
// point that's being dragged (out of all selected points)
const draggingPoint = element.points[lastClickedPoint];
@@ -538,11 +554,20 @@ export class LinearElementEditor {
linearElementEditor,
);
LinearElementEditor.movePoints(element, app.scene, positions, {
startBinding: updates?.startBinding,
endBinding: updates?.endBinding,
moveMidPointsWithElement: updates?.moveMidPointsWithElement,
});
LinearElementEditor.movePoints(
element,
app.scene,
positions,
{
startBinding: updates?.startBinding,
endBinding: updates?.endBinding,
moveMidPointsWithElement: updates?.moveMidPointsWithElement,
},
{
isBindingEnabled: app.state.isBindingEnabled,
isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled,
},
);
// Set the suggested binding from the updates if available
if (isBindingElement(element, false)) {
@@ -636,6 +661,7 @@ export class LinearElementEditor {
"start",
elementsMap,
app.state.zoom,
app.state.isMidpointSnappingEnabled,
)
: linearElementEditor.initialState.altFocusPoint,
},
@@ -774,6 +800,7 @@ export class LinearElementEditor {
element.points[index + 1],
index,
appState.zoom,
elementsMap,
)
) {
midpoints.push(null);
@@ -783,6 +810,7 @@ export class LinearElementEditor {
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
element,
index + 1,
elementsMap,
);
midpoints.push(segmentMidPoint);
index++;
@@ -870,6 +898,7 @@ export class LinearElementEditor {
endPoint: P,
index: number,
zoom: Zoom,
elementsMap: ElementsMap,
) {
if (isElbowArrow(element)) {
if (index >= 0 && index < element.points.length) {
@@ -884,7 +913,10 @@ export class LinearElementEditor {
let distance = pointDistance(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) {
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
invariant(
lines.length === 0 && curves.length > 0,
@@ -904,6 +936,7 @@ export class LinearElementEditor {
static getSegmentMidPoint(
element: NonDeleted<ExcalidrawLinearElement>,
index: number,
elementsMap: ElementsMap,
): GlobalPoint {
if (isElbowArrow(element)) {
invariant(
@@ -916,7 +949,10 @@ export class LinearElementEditor {
return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]);
}
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
invariant(
(lines.length === 0 && curves.length > 0) ||
@@ -1524,6 +1560,10 @@ export class LinearElementEditor {
endBinding?: FixedPointBinding | null;
moveMidPointsWithElement?: boolean | null;
},
options?: {
isBindingEnabled?: boolean;
isMidpointSnappingEnabled?: boolean;
},
) {
const { points } = element;
@@ -1592,6 +1632,8 @@ export class LinearElementEditor {
otherUpdates,
{
isDragging: Array.from(pointUpdates.values()).some((t) => t.isDragging),
isBindingEnabled: options?.isBindingEnabled,
isMidpointSnappingEnabled: options?.isMidpointSnappingEnabled,
},
);
}
@@ -1706,6 +1748,8 @@ export class LinearElementEditor {
isDragging?: boolean;
zoom?: AppState["zoom"];
sceneElementsMap?: NonDeletedSceneElementsMap;
isBindingEnabled?: boolean;
isMidpointSnappingEnabled?: boolean;
},
) {
if (isElbowArrow(element)) {
@@ -1726,6 +1770,8 @@ export class LinearElementEditor {
scene.mutateElement(element, updates, {
informMutation: true,
isDragging: options?.isDragging ?? false,
isBindingEnabled: options?.isBindingEnabled,
isMidpointSnappingEnabled: options?.isMidpointSnappingEnabled,
});
} else {
// TODO do we need to get precise coords here just to calc centers?
@@ -1821,6 +1867,7 @@ export class LinearElementEditor {
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
element,
index + 1,
elementsMap,
);
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
@@ -2145,14 +2192,16 @@ const pointDraggingUpdates = (
suggestedBinding: suggestedBindingElement
? {
element: suggestedBindingElement,
midPoint: snapToMid(
suggestedBindingElement,
elementsMap,
pointFrom<GlobalPoint>(
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
),
),
midPoint: app.state.isMidpointSnappingEnabled
? snapToMid(
suggestedBindingElement,
elementsMap,
pointFrom<GlobalPoint>(
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
),
)
: undefined,
}
: null,
},
@@ -2368,7 +2417,7 @@ const pointDraggingUpdates = (
? nextArrow.points[0]
: endBindable
? updateBoundPoint(
element,
nextArrow,
"endBinding",
nextArrow.endBinding,
endBindable,
@@ -2399,7 +2448,7 @@ const pointDraggingUpdates = (
? endLocalPoint
: startBindable
? updateBoundPoint(
element,
nextArrow,
"startBinding",
nextArrow.startBinding,
startBindable,
+2
View File
@@ -40,6 +40,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
updates: ElementUpdate<TElement>,
options?: {
isDragging?: boolean;
isBindingEnabled?: boolean;
isMidpointSnappingEnabled?: boolean;
},
) => {
let didChange = false;
+18
View File
@@ -11,6 +11,7 @@ import {
isBoundToContainer,
isFrameLikeElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
import {
elementOverlapsWithFrame,
@@ -25,6 +26,7 @@ import type {
ElementsMap,
ElementsMapOrArray,
ExcalidrawElement,
NonDeleted,
NonDeletedExcalidrawElement,
} from "./types";
@@ -288,3 +290,19 @@ export const getSelectionStateForElements = (
),
};
};
/**
* Returns editing or single-selected text element, if any.
*/
export const getActiveTextElement = (
selectedElements: readonly NonDeleted<ExcalidrawElement>[],
appState: Pick<AppState, "editingTextElement">,
) => {
const activeTextElement =
appState.editingTextElement ||
(selectedElements.length === 1 &&
isTextElement(selectedElements[0]) &&
selectedElements[0]);
return activeTextElement || null;
};
+230 -102
View File
@@ -57,8 +57,8 @@ import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons";
import {
elementCenterPoint,
getArrowheadPoints,
getCenterForBounds,
getDiamondPoints,
getElementAbsoluteCoords,
} from "./bounds";
@@ -69,10 +69,10 @@ import type {
NonDeletedExcalidrawElement,
ExcalidrawSelectionElement,
ExcalidrawLinearElement,
Arrowhead,
ExcalidrawFreeDrawElement,
ElementsMap,
ExcalidrawLineElement,
Arrowhead,
} from "./types";
import type { Drawable, Options } from "roughjs/bin/core";
@@ -296,6 +296,82 @@ const modifyIframeLikeForRoughOptions = (
return element;
};
const generateArrowheadCardinalityOne = (
generator: RoughGenerator,
arrowheadPoints: number[] | null,
lineOptions: Options,
) => {
if (arrowheadPoints === null) {
return [];
}
const [, , x3, y3, x4, y4] = arrowheadPoints;
return [generator.line(x3, y3, x4, y4, lineOptions)];
};
const generateArrowheadLinesToTip = (
generator: RoughGenerator,
arrowheadPoints: number[] | null,
lineOptions: Options,
) => {
if (arrowheadPoints === null) {
return [];
}
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
return [
generator.line(x3, y3, x2, y2, lineOptions),
generator.line(x4, y4, x2, y2, lineOptions),
];
};
const getArrowheadLineOptions = (
element: ExcalidrawLinearElement,
options: Options,
) => {
const lineOptions = { ...options };
if (element.strokeStyle === "dotted") {
// for dotted arrows caps, reduce gap to make it more legible
const dash = getDashArrayDotted(element.strokeWidth - 1);
lineOptions.strokeLineDash = [dash[0], dash[1] - 1];
} else {
// for solid/dashed, keep solid arrow cap
delete lineOptions.strokeLineDash;
}
lineOptions.roughness = Math.min(1, lineOptions.roughness || 0);
return lineOptions;
};
const generateArrowheadOutlineCircle = (
generator: RoughGenerator,
options: Options,
strokeColor: string,
arrowheadPoints: number[] | null,
fill: string,
diameterScale = 1,
) => {
if (arrowheadPoints === null) {
return [];
}
const [x, y, diameter] = arrowheadPoints;
const circleOptions = {
...options,
fill,
fillStyle: "solid" as const,
stroke: strokeColor,
roughness: Math.min(0.5, options.roughness || 0),
};
delete circleOptions.strokeLineDash;
return [generator.circle(x, y, diameter * diameterScale, circleOptions)];
};
const getArrowheadShapes = (
element: ExcalidrawLinearElement,
shape: Drawable[],
@@ -306,63 +382,54 @@ const getArrowheadShapes = (
canvasBackgroundColor: string,
isDarkMode: boolean,
) => {
const arrowheadPoints = getArrowheadPoints(
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
if (arrowhead === null) {
return [];
}
const generateCrowfootOne = (
arrowheadPoints: number[] | null,
options: Options,
) => {
if (arrowheadPoints === null) {
return [];
}
const [, , x3, y3, x4, y4] = arrowheadPoints;
return [generator.line(x3, y3, x4, y4, options)];
};
const strokeColor = isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
const backgroundFillColor = isDarkMode
? applyDarkModeFilter(canvasBackgroundColor)
: canvasBackgroundColor;
const cardinalityOneOrManyOffset = -0.25;
const cardinalityZeroCircleScale = 0.8;
switch (arrowhead) {
case "dot":
case "circle":
case "circle_outline": {
const [x, y, diameter] = arrowheadPoints;
// always use solid stroke for arrowhead
delete options.strokeLineDash;
return [
generator.circle(x, y, diameter, {
...options,
fill:
arrowhead === "circle_outline"
? canvasBackgroundColor
: strokeColor,
fillStyle: "solid",
stroke: strokeColor,
roughness: Math.min(0.5, options.roughness || 0),
}),
];
return generateArrowheadOutlineCircle(
generator,
options,
strokeColor,
getArrowheadPoints(element, shape, position, arrowhead),
arrowhead === "circle_outline" ? backgroundFillColor : strokeColor,
);
}
case "triangle":
case "triangle_outline": {
const arrowheadPoints = getArrowheadPoints(
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
return [];
}
const [x, y, x2, y2, x3, y3] = arrowheadPoints;
const triangleOptions = {
...options,
fill:
arrowhead === "triangle_outline" ? backgroundFillColor : strokeColor,
fillStyle: "solid" as const,
roughness: Math.min(1, options.roughness || 0),
};
// always use solid stroke for arrowhead
delete options.strokeLineDash;
delete triangleOptions.strokeLineDash;
return [
generator.polygon(
@@ -372,24 +439,34 @@ const getArrowheadShapes = (
[x3, y3],
[x, y],
],
{
...options,
fill:
arrowhead === "triangle_outline"
? canvasBackgroundColor
: strokeColor,
fillStyle: "solid",
roughness: Math.min(1, options.roughness || 0),
},
triangleOptions,
),
];
}
case "diamond":
case "diamond_outline": {
const arrowheadPoints = getArrowheadPoints(
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
return [];
}
const [x, y, x2, y2, x3, y3, x4, y4] = arrowheadPoints;
const diamondOptions = {
...options,
fill:
arrowhead === "diamond_outline" ? backgroundFillColor : strokeColor,
fillStyle: "solid" as const,
roughness: Math.min(1, options.roughness || 0),
};
// always use solid stroke for arrowhead
delete options.strokeLineDash;
delete diamondOptions.strokeLineDash;
return [
generator.polygon(
@@ -400,53 +477,117 @@ const getArrowheadShapes = (
[x4, y4],
[x, y],
],
{
...options,
fill:
arrowhead === "diamond_outline"
? canvasBackgroundColor
: strokeColor,
fillStyle: "solid",
roughness: Math.min(1, options.roughness || 0),
},
diamondOptions,
),
];
}
case "cardinality_one":
return generateArrowheadCardinalityOne(
generator,
getArrowheadPoints(element, shape, position, arrowhead),
getArrowheadLineOptions(element, options),
);
case "cardinality_many":
return generateArrowheadLinesToTip(
generator,
getArrowheadPoints(element, shape, position, arrowhead),
getArrowheadLineOptions(element, options),
);
case "cardinality_one_or_many": {
const lineOptions = getArrowheadLineOptions(element, options);
return [
...generateArrowheadLinesToTip(
generator,
getArrowheadPoints(element, shape, position, "cardinality_many"),
lineOptions,
),
...generateArrowheadCardinalityOne(
generator,
getArrowheadPoints(
element,
shape,
position,
"cardinality_one",
cardinalityOneOrManyOffset,
),
lineOptions,
),
];
}
case "cardinality_exactly_one": {
const lineOptions = getArrowheadLineOptions(element, options);
return [
...generateArrowheadCardinalityOne(
generator,
getArrowheadPoints(element, shape, position, "cardinality_one", -0.5),
lineOptions,
),
...generateArrowheadCardinalityOne(
generator,
getArrowheadPoints(element, shape, position, "cardinality_one"),
lineOptions,
),
];
}
case "cardinality_zero_or_one": {
const lineOptions = getArrowheadLineOptions(element, options);
return [
...generateArrowheadOutlineCircle(
generator,
options,
strokeColor,
getArrowheadPoints(element, shape, position, "circle_outline", 1.5),
backgroundFillColor,
cardinalityZeroCircleScale,
),
...generateArrowheadCardinalityOne(
generator,
getArrowheadPoints(element, shape, position, "cardinality_one", -0.5),
lineOptions,
),
];
}
case "cardinality_zero_or_many": {
const lineOptions = getArrowheadLineOptions(element, options);
return [
...generateArrowheadLinesToTip(
generator,
getArrowheadPoints(element, shape, position, "cardinality_many"),
lineOptions,
),
...generateArrowheadOutlineCircle(
generator,
options,
strokeColor,
getArrowheadPoints(element, shape, position, "circle_outline", 1.5),
backgroundFillColor,
cardinalityZeroCircleScale,
),
];
}
case "crowfoot_one":
return generateCrowfootOne(arrowheadPoints, options);
case "bar":
case "arrow":
case "crowfoot_many":
case "crowfoot_one_or_many":
default: {
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
if (element.strokeStyle === "dotted") {
// for dotted arrows caps, reduce gap to make it more legible
const dash = getDashArrayDotted(element.strokeWidth - 1);
options.strokeLineDash = [dash[0], dash[1] - 1];
} else {
// for solid/dashed, keep solid arrow cap
delete options.strokeLineDash;
}
options.roughness = Math.min(1, options.roughness || 0);
return [
generator.line(x3, y3, x2, y2, options),
generator.line(x4, y4, x2, y2, options),
...(arrowhead === "crowfoot_one_or_many"
? generateCrowfootOne(
getArrowheadPoints(element, shape, position, "crowfoot_one"),
options,
)
: []),
];
return generateArrowheadLinesToTip(
generator,
getArrowheadPoints(element, shape, position, arrowhead),
getArrowheadLineOptions(element, options),
);
}
}
};
export const generateLinearCollisionShape = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
) => {
elementsMap: ElementsMap,
): {
op: string;
data: number[];
}[] => {
const generator = new RoughGenerator();
const options: Options = {
seed: element.seed,
@@ -455,20 +596,7 @@ export const generateLinearCollisionShape = (
roughness: 0,
preserveVertices: true,
};
const center = getCenterForBounds(
// Need a non-rotated center point
element.points.reduce(
(acc, point) => {
return [
Math.min(element.x + point[0], acc[0]),
Math.min(element.y + point[1], acc[1]),
Math.max(element.x + point[0], acc[2]),
Math.max(element.y + point[1], acc[3]),
];
},
[Infinity, Infinity, -Infinity, -Infinity],
),
);
const center = elementCenterPoint(element, elementsMap);
switch (element.type) {
case "line":
+3 -1
View File
@@ -347,6 +347,7 @@ export const getContainerCenter = (
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
container,
index + 1,
elementsMap,
);
}
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
@@ -441,7 +442,8 @@ const VALID_CONTAINER_TYPES = new Set([
export const isValidTextContainer = (element: {
type: ExcalidrawElementType;
}) => VALID_CONTAINER_TYPES.has(element.type);
}): element is ExcalidrawTextContainer =>
VALID_CONTAINER_TYPES.has(element.type);
export const computeContainerDimensionForBoundText = (
dimension: number,
+207 -37
View File
@@ -4,6 +4,22 @@ import { charWidth, getLineWidth } from "./textMeasurements";
import type { FontString } from "./types";
/**
* This module approximates browser-like soft wrapping for Excalidraw text.
*
* The flow is:
* 1. `parseTokens()` splits a hard line into breakable tokens using a unicode-aware regex.
* 2. `getWrappedTextLines()` reflows each hard line into one or more visual lines and
* records where each visual line came from in the source text.
* 3. `wrapLine()` assembles tokens into lines, and `wrapWord()` handles a single token
* that is wider than the available width.
* 4. `trimLine()` / `trimLineEndAtSoftBreak()` mirror browser behavior around trailing
* whitespace so the rendered text stays consistent with what users see on canvas.
*
* Mostly, you'll want to use wrapText(). getWrappedTextLines() is for callers
* that need metadata such as mapping visual lines back to `originalText`
* for caret placement or future editor features.
*/
let cachedCjkRegex: RegExp | undefined;
let cachedLineBreakRegex: RegExp | undefined;
let cachedEmojiRegex: RegExp | undefined;
@@ -358,6 +374,10 @@ const Break = {
/**
* Breaks the line into the tokens based on the found line break opporutnities.
*
* Note: tokenization normalizes to NFC first so decomposed graphemes are treated as
* their composed variants for wrapping. Any code that needs exact source offsets should
* keep in mind that this assumes the input text is already NFC-normalized.
*/
export const parseTokens = (line: string) => {
const breakLineRegex = getLineBreakRegex();
@@ -370,56 +390,120 @@ export const parseTokens = (line: string) => {
/**
* Wraps the original text into the lines based on the given width.
*
* This is a convenience adapter over `getWrappedTextLines()` for call sites
* that only need the rendered wrapped string and not the source offsets.
*/
export const wrapText = (
text: string,
font: FontString,
maxWidth: number,
): string => {
return getWrappedTextLines(text, font, maxWidth)
.map((line) => line.text)
.join("\n");
};
/**
* A single rendered visual line produced from the original text.
*
* `start` and `end` are end-exclusive code-unit offsets into the original text, and do
* not include synthetic soft line breaks inserted by this module. If trailing whitespace
* was trimmed away at a wrap boundary, `end` points to the last rendered character.
*/
export type WrappedTextLine = {
text: string;
start: number;
end: number;
};
/**
* Splits only on existing hard line breaks and preserves original offsets.
*/
const getHardLineBreaks = (text: string): WrappedTextLine[] => {
let offset = 0;
return text.split("\n").map((line) => {
const start = offset;
const end = start + line.length;
offset = end + 1;
return {
text: line,
start,
end,
};
});
};
/**
* Returns the rendered visual lines together with their source offsets.
*
* This is the source-of-truth wrapping pipeline for callers that need more than the
* final wrapped string, for example caret placement or future editor/rich-text mapping.
*/
export const getWrappedTextLines = (
text: string,
font: FontString,
maxWidth: number,
): WrappedTextLine[] => {
// if maxWidth is not finite or NaN which can happen in case of bugs in
// computation, we need to make sure we don't continue as we'll end up
// in an infinite loop
if (!Number.isFinite(maxWidth) || maxWidth < 0) {
return text;
return getHardLineBreaks(text);
}
const lines: Array<string> = [];
const originalLines = text.split("\n");
const lines: WrappedTextLine[] = [];
let offset = 0;
for (const originalLine of originalLines) {
const currentLineWidth = getLineWidth(originalLine, font);
for (const originalLine of text.split("\n")) {
const originalLineWidth = getLineWidth(originalLine, font);
if (currentLineWidth <= maxWidth) {
lines.push(originalLine);
continue;
if (originalLineWidth <= maxWidth) {
lines.push({
text: originalLine,
start: offset,
end: offset + originalLine.length,
});
} else {
lines.push(...wrapLine(originalLine, font, maxWidth, offset));
}
const wrappedLine = wrapLine(originalLine, font, maxWidth);
lines.push(...wrappedLine);
offset += originalLine.length + 1;
}
return lines.join("\n");
return lines;
};
/**
* Wraps the original line into the lines based on the given width.
* Wraps a single hard line into one or more visual lines.
*
* The line-local offsets are tracked in original-text code units so
* we can map the visual line back to the source.
*/
const wrapLine = (
line: string,
font: FontString,
maxWidth: number,
): string[] => {
const lines: Array<string> = [];
lineStart: number,
): WrappedTextLine[] => {
const lines: WrappedTextLine[] = [];
const tokens = parseTokens(line);
const tokenIterator = tokens[Symbol.iterator]();
let currentLine = "";
let currentLineStart = lineStart;
let currentLineEnd = lineStart;
let currentLineWidth = 0;
// Tracks the next token's code-unit position in the original source string.
let tokenOffset = lineStart;
let tokenIndex = 0;
let iterator = tokenIterator.next();
while (!iterator.done) {
const token = iterator.value;
while (tokenIndex < tokens.length) {
const token = tokens[tokenIndex];
const tokenStart = tokenOffset;
const tokenEnd = tokenStart + token.length;
const testLine = currentLine + token;
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
@@ -429,37 +513,59 @@ const wrapLine = (
// build up the current line, skipping length check for possibly trailing whitespaces
if (/\s/.test(token) || testLineWidth <= maxWidth) {
if (!currentLine) {
currentLineStart = tokenStart;
}
currentLine = testLine;
currentLineEnd = tokenEnd;
currentLineWidth = testLineWidth;
iterator = tokenIterator.next();
tokenOffset = tokenEnd;
tokenIndex++;
continue;
}
// current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped
if (!currentLine) {
const wrappedWord = wrapWord(token, font, maxWidth);
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? "";
const wrappedWord = wrapWord(token, font, maxWidth, tokenStart);
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? {
text: "",
start: tokenStart,
end: tokenStart,
};
const precedingLines = wrappedWord.slice(0, -1);
lines.push(...precedingLines);
// trailing line of the wrapped word might still be joined with next token/s
currentLine = trailingLine;
currentLineWidth = getLineWidth(trailingLine, font);
iterator = tokenIterator.next();
currentLine = trailingLine.text;
currentLineStart = trailingLine.start;
currentLineEnd = trailingLine.end;
currentLineWidth = getLineWidth(trailingLine.text, font);
tokenOffset = tokenEnd;
tokenIndex++;
} else {
// push & reset, but don't iterate on the next token, as we didn't use it yet!
lines.push(currentLine.trimEnd());
lines.push(
trimLineEndAtSoftBreak(currentLine, currentLineStart, currentLineEnd),
);
// purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above
currentLine = "";
currentLineStart = tokenStart;
currentLineEnd = tokenStart;
currentLineWidth = 0;
}
}
// iterator done, push the trailing line if exists
if (currentLine) {
const trailingLine = trimLine(currentLine, font, maxWidth);
const trailingLine = trimLine(
currentLine,
currentLineStart,
currentLineEnd,
font,
maxWidth,
);
lines.push(trailingLine);
}
@@ -467,59 +573,100 @@ const wrapLine = (
};
/**
* Wraps the word into the lines based on the given width.
* Wraps a single word that could not be placed on an empty line as-is.
*/
const wrapWord = (
word: string,
font: FontString,
maxWidth: number,
): Array<string> => {
wordStart: number,
): WrappedTextLine[] => {
// multi-codepoint emojis are already broken apart and shouldn't be broken further
if (getEmojiRegex().test(word)) {
return [word];
return [
{
text: word,
start: wordStart,
end: wordStart + word.length,
},
];
}
satisfiesWordInvariant(word);
const lines: Array<string> = [];
const lines: WrappedTextLine[] = [];
const chars = Array.from(word);
let currentLine = "";
let currentLineStart = wordStart;
let currentLineEnd = wordStart;
let currentLineWidth = 0;
let offset = wordStart;
for (const char of chars) {
const charStart = offset;
const charEnd = charStart + char.length;
const _charWidth = charWidth.calculate(char, font);
const testLineWidth = currentLineWidth + _charWidth;
if (testLineWidth <= maxWidth) {
if (!currentLine) {
currentLineStart = charStart;
}
currentLine = currentLine + char;
currentLineEnd = charEnd;
currentLineWidth = testLineWidth;
offset = charEnd;
continue;
}
if (currentLine) {
lines.push(currentLine);
lines.push({
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
}
currentLine = char;
currentLineStart = charStart;
currentLineEnd = charEnd;
currentLineWidth = _charWidth;
offset = charEnd;
}
if (currentLine) {
lines.push(currentLine);
lines.push({
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
}
return lines;
};
/**
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
* Trims trailing whitespace that is exceeding the `maxWidth`.
*
* Used for the trailing visual line of a hard line, where some trailing
* whitespace may still be visible if it fits into the available width.
*/
const trimLine = (line: string, font: FontString, maxWidth: number) => {
const trimLine = (
line: string,
start: number,
end: number,
font: FontString,
maxWidth: number,
): WrappedTextLine => {
const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
if (!shouldTrimWhitespaces) {
return line;
return {
text: line,
start,
end,
};
}
// defensively default to `trimeEnd` in case the regex does not match
@@ -543,7 +690,30 @@ const trimLine = (line: string, font: FontString, maxWidth: number) => {
trimmedLineWidth = testLineWidth;
}
return trimmedLine;
return {
text: trimmedLine,
start,
end: end - (line.length - trimmedLine.length),
};
};
/**
* Used for internal soft-wrap boundaries, where trailing whitespace should not
* survive into the rendered line even though it still exists in the original
* text.
*/
const trimLineEndAtSoftBreak = (
line: string,
start: number,
end: number,
): WrappedTextLine => {
const trimmedLine = line.trimEnd();
return {
text: trimmedLine,
start,
end: end - (line.length - trimmedLine.length),
};
};
/**
+18 -5
View File
@@ -15,7 +15,7 @@ import type {
ValueOf,
} from "@excalidraw/common/utility-types";
export type ChartType = "bar" | "line";
export type ChartType = "bar" | "line" | "radar";
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
export type FontFamilyKeys = keyof typeof FONT_FAMILY;
export type FontFamilyValues = typeof FONT_FAMILY[FontFamilyKeys];
@@ -303,19 +303,32 @@ export type PointsPositionUpdates = Map<
{ point: LocalPoint; isDragging?: boolean }
>;
export type CardinalityArrowhead =
| "cardinality_one"
| "cardinality_many"
| "cardinality_one_or_many"
| "cardinality_exactly_one"
| "cardinality_zero_or_one"
| "cardinality_zero_or_many";
export type ArrowheadLegacy =
| "dot"
| "crowfoot_one"
| "crowfoot_many"
| "crowfoot_one_or_many";
export type Arrowhead =
| "arrow"
| "bar"
| "dot" // legacy. Do not use for new elements.
| "circle"
| "circle_outline"
| "triangle"
| "triangle_outline"
| "diamond"
| "diamond_outline"
| "crowfoot_one"
| "crowfoot_many"
| "crowfoot_one_or_many";
| CardinalityArrowhead;
export type AnyArrowhead = Arrowhead | ArrowheadLegacy;
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{
+13 -12
View File
@@ -124,6 +124,7 @@ const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
*/
export function deconstructLinearOrFreeDrawElement(
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, 0);
@@ -131,10 +132,7 @@ export function deconstructLinearOrFreeDrawElement(
return cachedShape;
}
const ops = generateLinearCollisionShape(element) as {
op: string;
data: number[];
}[];
const ops = generateLinearCollisionShape(element, elementsMap);
const lines = [];
const curves = [];
@@ -659,20 +657,23 @@ export const projectFixedPointOntoDiagonal = (
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
zoom: AppState["zoom"],
isMidpointSnappingEnabled: boolean = true,
): GlobalPoint | null => {
invariant(arrow.points.length >= 2, "Arrow must have at least two points");
if (arrow.width < 3 && arrow.height < 3) {
return null;
}
const sideMidPoint = getSnapOutlineMidPoint(
point,
element,
elementsMap,
zoom,
);
if (sideMidPoint) {
return sideMidPoint;
if (isMidpointSnappingEnabled) {
const sideMidPoint = getSnapOutlineMidPoint(
point,
element,
elementsMap,
zoom,
);
if (sideMidPoint) {
return sideMidPoint;
}
}
// Do the projection onto the diagonals (or center lines
+70 -1
View File
@@ -1,4 +1,8 @@
import { wrapText, parseTokens } from "../src/textWrapping";
import {
getWrappedTextLines,
parseTokens,
wrapText,
} from "../src/textWrapping";
import type { FontString } from "../src/types";
@@ -102,6 +106,71 @@ describe("Test wrapText", () => {
expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`);
});
it("should retain original text offsets for wrapped lines", () => {
expect(getWrappedTextLines("Hello World!", font, 60)).toEqual([
{
text: "Hello",
start: 0,
end: 5,
},
{
text: "World!",
start: 6,
end: 12,
},
]);
});
it("should exclude whitespace trimmed away at soft-wrap boundaries from line offsets", () => {
expect(getWrappedTextLines(" Hello World", font, 90)).toEqual([
{
text: " Hello",
start: 0,
end: 7,
},
{
text: "World",
start: 9,
end: 14,
},
]);
});
it("should retain offsets when wrapping a single long token", () => {
expect(getWrappedTextLines("Excalidraw", font, 50)).toEqual([
{
text: "Excal",
start: 0,
end: 5,
},
{
text: "idraw",
start: 5,
end: 10,
},
]);
});
it("should preserve empty hard lines in metadata", () => {
expect(getWrappedTextLines("A\n\nB", font, 100)).toEqual([
{
text: "A",
start: 0,
end: 1,
},
{
text: "",
start: 2,
end: 2,
},
{
text: "B",
start: 3,
end: 4,
},
]);
});
describe("When text is CJK", () => {
it("should break each CJK character when width is very small", () => {
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
+81
View File
@@ -11,6 +11,87 @@ The change should be grouped under one of the below section and must contain PR
Please add the latest change on the top under the correct section.
-->
## Unreleased
## Excalidraw API
### Breaking changes
- Renamed the `excalidrawAPI` prop to `onExcalidrawAPI`.
- `onExcalidrawAPI` is now called on mount (instead of during constructor), and later on unmount (with `null` value). The API may be removed altogether in the future (you can use `onMount` & `onUmount` to manage the `ExcalidrawAPI` object (e.g. to cache it to a global state), already).
### Features
- Added `ExcalidrawAPI.isDestroyed` flag. Set to `true` once the editor unmounts. Calling any `get*` method, `onStateChange`, or `onEvent` on a destroyed API instance will throw in development and `console.error` in production. The `ExcalidrawAPI` will be reset to `null` on umount, but to be extra safe, you should check `ExcalidrawAPI.isDestroyed` before calling these methods to guard against subtle race conditions in your code.
- Added `onMount`, `onInitialize`, and `onUnmount` props. `onMount` receives `{ excalidrawAPI, container }` once the editor root is mounted. `onInitialize` fires once the initial scene has loaded. `onUnmount` fires just before unmounting.
- Same events are also accessible imperatively through `api.onEvent(...)`.
```tsx
<Excalidraw
onExcalidrawAPI={(api) => {
api.onEvent("editor:mount", ({ excalidrawAPI, container }) => {
console.log(container);
});
api.onEvent("editor:initialize").then((readyApi) => {
readyApi.scrollToContent();
});
}}
/>
```
Note that in future releases, most, if not all, `excalidrawAPI.on*` subscriptions will be removed in favor of `excalidrawAPI.onEvent(name)`.
- Also added `"editor:unmount"` lifecycle event, only accessible via `api.onEvent("editor:unmount")`.
- Exported `<ExcalidrawAPIProvider/>`, `useExcalidrawAPI()`, `useAppStateValue(prop | props | selectorFunction)`, and `useOnExcalidrawStateChange(prop | props | selectorFunction, callback)` from the package. The imperative API also now exposes `onStateChange(prop | props | selectorFunction, callback?)`, and `onEvent(name, callback)`.
```tsx
<ExcalidrawAPIProvider>
<Excalidraw />
<Logger />
</ExcalidrawAPIProvider>;
function Logger() {
// initially null before the ExcalidrawAPIProvider initializes ater
// <Excalidraw/> renders
// When <Excalidraw/> unmounts, is reset back to null
const api = useExcalidrawAPI();
useAppStateValue("viewModeEnabled", (viewModeEnabled) => {
console.log("view mode changed:", viewModeEnabled);
});
React.useEffect(() => {
if (api) {
console.log("editor instance id:", api.id);
}
}, [api]);
return null;
}
```
- Added `onExport` so host apps can delay JSON export until async work completes. The handler receives the export data plus an `AbortSignal`, and may return a `Promise` or an async generator that yields progress updates for the built-in toast UI.
```tsx
<Excalidraw
onExport={async function* (_type, { files }, { signal }) {
yield { type: "progress", message: "Waiting for images..." };
await waitForImagesToLoad(files, signal);
if (signal.aborted) {
return;
}
yield { type: "progress", message: "Export ready", progress: 1 };
}}
/>
```
## Excalidraw Library
## 0.18.0 (2025-03-11)
+111 -14
View File
@@ -1,10 +1,10 @@
# Excalidraw
**Excalidraw** is exported as a component to be directly embedded in your project.
**Excalidraw** is exported as a React component that you can embed directly in your app.
## Installation
Use `npm` or `yarn` to install the package.
Install the package together with its React peer dependencies.
```bash
npm install react react-dom @excalidraw/excalidraw
@@ -12,34 +12,131 @@ npm install react react-dom @excalidraw/excalidraw
yarn add react react-dom @excalidraw/excalidraw
```
> **Note**: If you don't want to wait for the next stable release and try out the unreleased changes, use `@excalidraw/excalidraw@next`.
> **Note**: If you want to try unreleased changes, use `@excalidraw/excalidraw@next`.
#### Self-hosting fonts
## Quick start
By default, Excalidraw will try to download all the used fonts from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
The minimum working setup has two easy-to-miss requirements:
For self-hosting purposes, you'll have to copy the content of the folder `node_modules/@excalidraw/excalidraw/dist/prod/fonts` to the path where your assets should be served from (i.e. `public/` directory in your project). In that case, you should also set `window.EXCALIDRAW_ASSET_PATH` to the very same path, i.e. `/` in case it's in the root:
1. Import the package CSS:
```js
<script>window.EXCALIDRAW_ASSET_PATH = "/";</script>
```ts
import "@excalidraw/excalidraw/index.css";
```
### Dimensions of Excalidraw
2. Render Excalidraw inside a container with a non-zero height.
Excalidraw takes _100%_ of `width` and `height` of the containing block so make sure the container in which you render Excalidraw has non zero dimensions.
```tsx
import { Excalidraw } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
export default function App() {
return (
<div style={{ height: "100vh" }}>
<Excalidraw />
</div>
);
}
```
Excalidraw fills `100%` of the width and height of its parent. If the parent has no height, the canvas will not be visible.
## Next.js / SSR frameworks
Excalidraw should be rendered on the client. In SSR frameworks such as Next.js, use a client component and load it dynamically with SSR disabled.
```tsx
// app/components/ExcalidrawClient.tsx
"use client";
import { Excalidraw } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
export default function ExcalidrawClient() {
return (
<div style={{ height: "100vh" }}>
<Excalidraw />
</div>
);
}
```
```tsx
// app/page.tsx
import dynamic from "next/dynamic";
const ExcalidrawClient = dynamic(
() => import("./components/ExcalidrawClient"),
{ ssr: false },
);
export default function Page() {
return <ExcalidrawClient />;
}
```
See the local examples for complete setups:
- [examples/with-nextjs](https://github.com/excalidraw/excalidraw/tree/master/examples/with-nextjs)
- [examples/with-script-in-browser](https://github.com/excalidraw/excalidraw/tree/master/examples/with-script-in-browser)
## LLM / agent tips
If an LLM or coding agent is setting up Excalidraw, these shortcuts usually save more time than re-prompting:
- Start with a plain `<Excalidraw />` in a `100vh` container. Add refs, `initialData`, persistence, or custom UI only after the base embed works.
- If the canvas is blank, check the CSS import and parent height first. Those are the two most common integration failures.
- In Next.js or other SSR frameworks, assume client-only rendering first. Use `"use client"` and `dynamic(..., { ssr: false })` before debugging hydration or `window is not defined` errors.
- If imports or entrypoints are unclear, inspect `node_modules/@excalidraw/excalidraw/package.json`. The installed package exports are the source of truth.
- Do not set `window.EXCALIDRAW_ASSET_PATH` unless you are intentionally self-hosting fonts/assets.
- When docs and generated code drift, copy the nearest working example from this repo, especially `examples/with-nextjs` or `examples/with-script-in-browser`.
## Migrating to `@excalidraw/excalidraw@0.18.x`
Version `0.18.x` removes the old `types/`-prefixed deep import paths. If you were importing types from `@excalidraw/excalidraw/types/...`, switch to the new type-only subpaths below.
| Old path | New path |
| --- | --- |
| `@excalidraw/excalidraw/types/data/transform.js` | `@excalidraw/excalidraw/element/transform` |
| `@excalidraw/excalidraw/types/data/types.js` | `@excalidraw/excalidraw/data/types` |
| `@excalidraw/excalidraw/types/element/types.js` | `@excalidraw/excalidraw/element/types` |
| `@excalidraw/excalidraw/types/utility-types.js` | `@excalidraw/excalidraw/common/utility-types` |
| `@excalidraw/excalidraw/types/types.js` | `@excalidraw/excalidraw/types` |
Drop the `.js` extension. The new package `exports` map resolves these paths without it.
These deep subpaths are for `import type` only. Runtime imports should come from the package root, plus `@excalidraw/excalidraw/index.css` for styles.
For example:
```ts
import { exportToSvg } from "@excalidraw/excalidraw";
```
## Self-hosting fonts
By default, Excalidraw downloads the fonts it needs from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
For self-hosting, copy the contents of `node_modules/@excalidraw/excalidraw/dist/prod/fonts` into the path where your app serves static assets, for example `public/`. Then set `window.EXCALIDRAW_ASSET_PATH` to that same path:
```html
<script>
window.EXCALIDRAW_ASSET_PATH = "/";
</script>
```
## Demo
Go to [CodeSandbox](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser) example.
Try the [CodeSandbox example](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser).
## Integration
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
Read the [integration docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
## API
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
Read the [API docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
## Contributing
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing).
Read the [contributing docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing).
@@ -118,7 +118,6 @@ export const actionClearCanvas = register({
gridStep: appState.gridStep,
gridModeEnabled: appState.gridModeEnabled,
stats: appState.stats,
pasteDialog: appState.pasteDialog,
activeTool:
appState.activeTool.type === "image"
? {
@@ -30,7 +30,7 @@ import { getSelectedElements, isSomeElementSelected } from "../scene";
import { TrashIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { useStylesPanelMode } from "..";
import { useStylesPanelMode } from "../components/App";
import { register } from "./register";
@@ -27,7 +27,7 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut";
import { useStylesPanelMode } from "..";
import { useStylesPanelMode } from "../components/App";
import { register } from "./register";
+220 -38
View File
@@ -9,18 +9,20 @@ import { getNonDeletedElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { Theme } from "@excalidraw/element/types";
import type { ExcalidrawElement, Theme } from "@excalidraw/element/types";
import { useEditorInterface } from "../components/App";
import { CheckboxItem } from "../components/CheckboxItem";
import { DarkModeToggle } from "../components/DarkModeToggle";
import { ProjectName } from "../components/ProjectName";
import { Toast } from "../components/Toast";
import { ToolButton } from "../components/ToolButton";
import { Tooltip } from "../components/Tooltip";
import { ExportIcon, questionCircle, saveAs } from "../components/icons";
import { loadFromJSON, saveAsJSON } from "../data";
import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n";
@@ -31,7 +33,15 @@ import "../components/ToolIcon.scss";
import { register } from "./register";
import type { AppState } from "../types";
import type { JSONExportData } from "../data/json";
import type {
AppClassProperties,
AppState,
BinaryFiles,
ExcalidrawProps,
OnExportProgress,
} from "../types";
export const actionChangeProjectName = register<AppState["name"]>({
name: "changeProjectName",
@@ -150,6 +160,143 @@ export const actionChangeExportEmbedScene = register<
),
});
// ---------------------------------------------------------------------------
// onExport interception helpers
// ---------------------------------------------------------------------------
let onExportInProgress = false;
const onProgressToast = (
app: AppClassProperties,
progress: {
message?: OnExportProgress["message"];
progress?: number | null;
},
) => {
const message = progress.message ?? t("progressDialog.defaultMessage");
app.setAppState({
toast: {
message:
progress.progress != null ? (
<>
{message}
<Toast.ProgressBar progress={progress.progress} />
</>
) : (
message
),
duration: Infinity,
},
});
};
/** awaits host app's onExport result, and renders progress to the UI */
async function handleOnExportResult(
onExportResult: ReturnType<NonNullable<ExcalidrawProps["onExport"]>>,
opts: {
signal: AbortSignal;
app: AppClassProperties;
},
): Promise<void> {
if (opts.app.state.isLoading) {
onProgressToast(opts.app, { progress: null });
await opts.app.onStateChange({ predicate: (state) => !state.isLoading });
}
if (
onExportResult != null &&
typeof onExportResult === "object" &&
Symbol.asyncIterator in onExportResult
) {
for await (const value of onExportResult) {
if (opts.signal.aborted) {
onExportResult.return();
return;
}
if (value.type === "progress") {
onProgressToast(opts.app, {
message: value.message,
progress: value.progress ?? null,
});
} else if (value.type === "done") {
return;
}
}
// Generator completed without explicit "done" message
return;
}
if (onExportResult instanceof Promise) {
onProgressToast(opts.app, { progress: null });
await onExportResult;
}
}
function prepareDataForJSONExport(
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
app: AppClassProperties,
): { abortController: AbortController; data: Promise<JSONExportData> } {
const abortController = new AbortController();
const signal = abortController.signal;
const dataPromise = new Promise<JSONExportData>(async (resolve) => {
try {
if (app.props.onExport) {
await handleOnExportResult(
app.props.onExport(
"json",
{
elements,
appState,
files,
},
{
signal,
},
),
{
app,
signal,
},
);
}
} catch (error: any) {
if (error?.name === "AbortError") {
// if abort error, assume it's a reaction on the signal being aborted
console.warn(
`onExport() aborted by host app (signal aborted: ${signal.aborted})`,
);
} else {
// non-abort error
//
console.error("Error during props.onExport() handling", error);
}
// either way, we currently don't allow host apps to cancel save actions
// so we resolve to orig data
}
resolve({
elements,
appState,
// return latest files in case they finished loading during onExport
files: app.files,
});
});
return {
abortController,
data: dataPromise,
};
}
// ---------------------------------------------------------------------------
// Save actions
// ---------------------------------------------------------------------------
export const actionSaveToActiveFile = register({
name: "saveToActiveFile",
label: "buttons.save",
@@ -163,42 +310,62 @@ export const actionSaveToActiveFile = register({
);
},
perform: async (elements, appState, value, app) => {
const fileHandleExists = !!appState.fileHandle;
if (onExportInProgress) {
return false;
}
onExportInProgress = true;
const previousFileHandle = appState.fileHandle;
const filename = app.getName();
const { abortController, data: exportedDataPromise } =
prepareDataForJSONExport(elements, appState, app.files, app);
try {
const { fileHandle } = isImageFileHandle(appState.fileHandle)
const { fileHandle } = isImageFileHandle(previousFileHandle)
? await resaveAsImageWithScene(
elements,
appState,
app.files,
app.getName(),
exportedDataPromise,
previousFileHandle,
filename,
)
: await saveAsJSON(elements, appState, app.files, app.getName());
: await saveAsJSON({
data: exportedDataPromise,
filename,
fileHandle: previousFileHandle,
});
return {
captureUpdate: CaptureUpdateAction.EVENTUALLY,
captureUpdate: CaptureUpdateAction.NEVER,
appState: {
...appState,
fileHandle,
toast: fileHandleExists
? {
message: fileHandle?.name
? t("toast.fileSavedToFilename").replace(
"{filename}",
`"${fileHandle.name}"`,
)
: t("toast.fileSaved"),
}
: null,
toast: {
message:
previousFileHandle && fileHandle?.name
? t("toast.fileSavedToFilename").replace(
"{filename}",
`"${fileHandle.name}"`,
)
: t("toast.fileSaved"),
duration: 1500,
},
},
};
} catch (error: any) {
abortController.abort();
if (error?.name !== "AbortError") {
console.error(error);
} else {
console.warn(error);
}
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
return {
captureUpdate: CaptureUpdateAction.NEVER,
appState: {
toast: null,
},
};
} finally {
onExportInProgress = false;
}
},
keyTest: (event) =>
@@ -212,36 +379,50 @@ export const actionSaveFileToDisk = register({
viewMode: true,
trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => {
if (onExportInProgress) {
return false;
}
onExportInProgress = true;
const { abortController, data: exportedDataPromise } =
prepareDataForJSONExport(elements, appState, app.files, app);
try {
const { fileHandle } = await saveAsJSON(
elements,
{
...appState,
fileHandle: null,
},
app.files,
app.getName(),
);
const { fileHandle: savedFileHandle } = await saveAsJSON({
data: exportedDataPromise,
filename: app.getName(),
fileHandle: null,
});
return {
captureUpdate: CaptureUpdateAction.EVENTUALLY,
captureUpdate: CaptureUpdateAction.NEVER,
appState: {
...appState,
openDialog: null,
fileHandle,
toast: { message: t("toast.fileSaved") },
fileHandle: savedFileHandle,
toast: { message: t("toast.fileSaved"), duration: 3000 },
},
};
} catch (error: any) {
abortController.abort();
if (error?.name !== "AbortError") {
console.error(error);
} else {
console.warn(error);
}
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
return {
captureUpdate: CaptureUpdateAction.NEVER,
appState: {
toast: null,
},
};
} finally {
onExportInProgress = false;
}
},
keyTest: (event) =>
event.key === KEYS.S && event.shiftKey && event[KEYS.CTRL_OR_CMD],
event.key.toLowerCase() === KEYS.S &&
event.shiftKey &&
event[KEYS.CTRL_OR_CMD],
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
@@ -300,7 +481,8 @@ export const actionExportWithDarkMode = register<
name: "exportWithDarkMode",
label: "imageExportDialog.label.darkMode",
trackEvent: { category: "export", action: "toggleTheme" },
perform: (_elements, appState, value) => {
perform: (_elements, appState, value, app) => {
app.sessionExportThemeOverride = value ? THEME.DARK : THEME.LIGHT;
return {
appState: { ...appState, exportWithDarkMode: value },
captureUpdate: CaptureUpdateAction.EVENTUALLY,
@@ -18,7 +18,7 @@ import { HistoryChangedEvent } from "../history";
import { useEmitter } from "../hooks/useEmitter";
import { t } from "../i18n";
import { useStylesPanelMode } from "..";
import { useStylesPanelMode } from "../components/App";
import type { History } from "../history";
import type { AppClassProperties, AppState } from "../types";
+159 -90
View File
@@ -36,6 +36,7 @@ import {
import { LinearElementEditor } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element";
import { getArrowheadForPicker } from "@excalidraw/element";
import {
getBoundTextElement,
@@ -124,9 +125,12 @@ import {
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
ArrowheadCrowfootIcon,
ArrowheadCrowfootOneIcon,
ArrowheadCrowfootOneOrManyIcon,
ArrowheadCardinalityExactlyOneIcon,
ArrowheadCardinalityManyIcon,
ArrowheadCardinalityOneIcon,
ArrowheadCardinalityOneOrManyIcon,
ArrowheadCardinalityZeroOrManyIcon,
ArrowheadCardinalityZeroOrOneIcon,
} from "../components/icons";
import { Fonts } from "../fonts";
@@ -187,7 +191,7 @@ export const getFormValue = function <T extends Primitive>(
elements: readonly ExcalidrawElement[],
app: AppClassProperties,
getAttribute: (element: ExcalidrawElement) => T,
isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
elementPredicate: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T {
const editingTextElement = app.state.editingTextElement;
@@ -205,9 +209,9 @@ export const getFormValue = function <T extends Primitive>(
if (hasSelection) {
const selectedElements = app.scene.getSelectedElements(app.state);
const targetElements =
isRelevantElement === true
elementPredicate === true
? selectedElements
: selectedElements.filter((el) => isRelevantElement(el));
: selectedElements.filter((el) => elementPredicate(el));
ret =
reduceToCommonValue(targetElements, getAttribute) ??
@@ -726,9 +730,28 @@ export const actionChangeOpacity = register<ExcalidrawElement["opacity"]>({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ app, updateData }) => (
<Range updateData={updateData} app={app} testId="opacity" />
),
PanelComponent: ({ elements, appState, app, updateData }) => {
const opacity = getFormValue(
elements,
app,
(element) => element.opacity,
true,
(hasSelection) => (hasSelection ? null : appState.currentItemOpacity),
);
return (
<Range
label={t("labels.opacity")}
value={opacity ?? appState.currentItemOpacity}
hasCommonValue={opacity !== null}
onChange={updateData}
min={0}
max={100}
step={10}
testId="opacity"
/>
);
},
});
export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>(
@@ -1550,80 +1573,117 @@ export const actionChangeRoundness = register<"sharp" | "round">({
});
const getArrowheadOptions = (flip: boolean) => {
return [
{
value: null,
text: t("labels.arrowhead_none"),
keyBinding: "q",
icon: <ArrowheadNoneIcon flip={flip} />,
},
{
value: "arrow",
text: t("labels.arrowhead_arrow"),
keyBinding: "w",
icon: <ArrowheadArrowIcon flip={flip} />,
},
{
value: "triangle",
text: t("labels.arrowhead_triangle"),
icon: <ArrowheadTriangleIcon flip={flip} />,
keyBinding: "e",
},
{
value: "triangle_outline",
text: t("labels.arrowhead_triangle_outline"),
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
keyBinding: "r",
},
{
value: "circle",
text: t("labels.arrowhead_circle"),
keyBinding: "a",
icon: <ArrowheadCircleIcon flip={flip} />,
},
{
value: "circle_outline",
text: t("labels.arrowhead_circle_outline"),
keyBinding: "s",
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
},
{
value: "diamond",
text: t("labels.arrowhead_diamond"),
icon: <ArrowheadDiamondIcon flip={flip} />,
keyBinding: "d",
},
{
value: "diamond_outline",
text: t("labels.arrowhead_diamond_outline"),
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
keyBinding: "f",
},
{
value: "bar",
text: t("labels.arrowhead_bar"),
keyBinding: "z",
icon: <ArrowheadBarIcon flip={flip} />,
},
{
value: "crowfoot_one",
text: t("labels.arrowhead_crowfoot_one"),
icon: <ArrowheadCrowfootOneIcon flip={flip} />,
keyBinding: "x",
},
{
value: "crowfoot_many",
text: t("labels.arrowhead_crowfoot_many"),
icon: <ArrowheadCrowfootIcon flip={flip} />,
keyBinding: "c",
},
{
value: "crowfoot_one_or_many",
text: t("labels.arrowhead_crowfoot_one_or_many"),
icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />,
keyBinding: "v",
},
] as const;
return {
visibleSections: [
{
name: "default",
options: [
{
value: null,
text: t("labels.arrowhead_none"),
keyBinding: "q",
icon: <ArrowheadNoneIcon flip={flip} />,
},
{
value: "arrow",
text: t("labels.arrowhead_arrow"),
keyBinding: "w",
icon: <ArrowheadArrowIcon flip={flip} />,
},
{
value: "triangle",
text: t("labels.arrowhead_triangle"),
icon: <ArrowheadTriangleIcon flip={flip} />,
keyBinding: "e",
},
{
value: "triangle_outline",
text: t("labels.arrowhead_triangle_outline"),
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
keyBinding: "r",
},
],
},
],
hiddenSections: [
{
name: "default",
options: [
{
value: "circle",
text: t("labels.arrowhead_circle"),
keyBinding: "a",
icon: <ArrowheadCircleIcon flip={flip} />,
},
{
value: "circle_outline",
text: t("labels.arrowhead_circle_outline"),
keyBinding: "s",
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
},
{
value: "diamond",
text: t("labels.arrowhead_diamond"),
icon: <ArrowheadDiamondIcon flip={flip} />,
keyBinding: "d",
},
{
value: "diamond_outline",
text: t("labels.arrowhead_diamond_outline"),
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
keyBinding: "f",
},
{
value: "bar",
text: t("labels.arrowhead_bar"),
keyBinding: "z",
icon: <ArrowheadBarIcon flip={flip} />,
},
],
},
{
name: t("labels.cardinality"),
options: [
{
value: "cardinality_one",
text: t("labels.arrowhead_cardinality_one"),
icon: <ArrowheadCardinalityOneIcon flip={flip} />,
keyBinding: "x",
},
{
value: "cardinality_many",
text: t("labels.arrowhead_cardinality_many"),
icon: <ArrowheadCardinalityManyIcon flip={flip} />,
keyBinding: "c",
},
{
value: "cardinality_one_or_many",
text: t("labels.arrowhead_cardinality_one_or_many"),
icon: <ArrowheadCardinalityOneOrManyIcon flip={flip} />,
keyBinding: "v",
},
{
value: "cardinality_exactly_one",
text: t("labels.arrowhead_cardinality_exactly_one"),
icon: <ArrowheadCardinalityExactlyOneIcon flip={flip} />,
keyBinding: null,
},
{
value: "cardinality_zero_or_one",
text: t("labels.arrowhead_cardinality_zero_or_one"),
icon: <ArrowheadCardinalityZeroOrOneIcon flip={flip} />,
keyBinding: null,
},
{
value: "cardinality_zero_or_many",
text: t("labels.arrowhead_cardinality_zero_or_many"),
icon: <ArrowheadCardinalityZeroOrManyIcon flip={flip} />,
keyBinding: null,
},
],
},
],
} as const;
};
export const actionChangeArrowhead = register<{
@@ -1667,45 +1727,52 @@ export const actionChangeArrowhead = register<{
},
PanelComponent: ({ elements, appState, updateData, app }) => {
const isRTL = getLanguage().rtl;
const startArrowheadOptions = useMemo(
() => getArrowheadOptions(!isRTL),
[isRTL],
);
const endArrowheadOptions = useMemo(
() => getArrowheadOptions(!!isRTL),
[isRTL],
);
return (
<fieldset>
<legend>{t("labels.arrowheads")}</legend>
<div className="iconSelectList buttonList">
<IconPicker
visibleSections={startArrowheadOptions.visibleSections}
hiddenSections={startArrowheadOptions.hiddenSections}
label="arrowhead_start"
options={getArrowheadOptions(!isRTL)}
value={getFormValue<Arrowhead | null>(
elements,
app,
(element) =>
isLinearElement(element) && canHaveArrowheads(element.type)
? element.startArrowhead
? getArrowheadForPicker(element.startArrowhead)
: appState.currentItemStartArrowhead,
true,
(hasSelection) =>
hasSelection ? null : appState.currentItemStartArrowhead,
)}
onChange={(value) => updateData({ position: "start", type: value })}
numberOfOptionsToAlwaysShow={4}
/>
<IconPicker
visibleSections={endArrowheadOptions.visibleSections}
hiddenSections={endArrowheadOptions.hiddenSections}
label="arrowhead_end"
group="arrowheads"
options={getArrowheadOptions(!!isRTL)}
value={getFormValue<Arrowhead | null>(
elements,
app,
(element) =>
isLinearElement(element) && canHaveArrowheads(element.type)
? element.endArrowhead
? getArrowheadForPicker(element.endArrowhead)
: appState.currentItemEndArrowhead,
true,
(hasSelection) =>
hasSelection ? null : appState.currentItemEndArrowhead,
)}
onChange={(value) => updateData({ position: "end", type: value })}
numberOfOptionsToAlwaysShow={4}
/>
</div>
</fieldset>
@@ -1830,6 +1897,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
startElement,
"start",
elementsMap,
appState.isBindingEnabled,
),
}
: null;
@@ -1843,6 +1911,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
endElement,
"end",
elementsMap,
appState.isBindingEnabled,
),
}
: null;
@@ -1,24 +1,24 @@
import { getFontString } from "@excalidraw/common";
import { newElementWith } from "@excalidraw/element";
import { isExcalidrawElement, newElementWith } from "@excalidraw/element";
import { measureText } from "@excalidraw/element";
import { isTextElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { getSelectedElements } from "../scene";
import { register } from "./register";
import type { AppClassProperties } from "../types";
export const actionTextAutoResize = register({
name: "autoResize",
label: "labels.autoResize",
icon: null,
trackEvent: { category: "element" },
predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
predicate: (elements, appState, _: unknown) => {
const selectedElements = getSelectedElements(elements, appState);
return (
selectedElements.length === 1 &&
@@ -26,13 +26,18 @@ export const actionTextAutoResize = register({
!selectedElements[0].autoResize
);
},
perform: (elements, appState, _, app) => {
perform: (elements, appState, targetElement) => {
const selectedElements = getSelectedElements(elements, appState);
const targetTextElement =
isExcalidrawElement(targetElement) && isTextElement(targetElement)
? targetElement
: (selectedElements[0] as ExcalidrawElement | undefined);
return {
appState,
elements: elements.map((element) => {
if (element.id === selectedElements[0].id && isTextElement(element)) {
if (element.id === targetTextElement?.id && isTextElement(element)) {
const metrics = measureText(
element.originalText,
getFontString(element),
@@ -0,0 +1,26 @@
import { CaptureUpdateAction } from "@excalidraw/element";
import { register } from "./register";
export const actionToggleArrowBinding = register({
name: "arrowBinding",
label: "labels.arrowBinding",
viewMode: false,
trackEvent: {
category: "canvas",
predicate: (appState) => appState.bindingPreference === "disabled",
},
perform(elements, appState) {
const newPreference =
appState.bindingPreference === "enabled" ? "disabled" : "enabled";
return {
appState: {
...appState,
bindingPreference: newPreference,
isBindingEnabled: newPreference === "enabled",
},
captureUpdate: CaptureUpdateAction.NEVER,
};
},
checked: (appState) => appState.bindingPreference === "enabled",
});
@@ -0,0 +1,23 @@
import { CaptureUpdateAction } from "@excalidraw/element";
import { register } from "./register";
export const actionToggleMidpointSnapping = register({
name: "midpointSnapping",
label: "labels.midpointSnapping",
viewMode: false,
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.isMidpointSnappingEnabled,
},
perform(elements, appState) {
return {
appState: {
...appState,
isMidpointSnappingEnabled: !this.checked!(appState),
},
captureUpdate: CaptureUpdateAction.NEVER,
};
},
checked: (appState) => appState.isMidpointSnappingEnabled,
});
+2
View File
@@ -79,6 +79,8 @@ export {
export { actionToggleGridMode } from "./actionToggleGridMode";
export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
export { actionToggleArrowBinding } from "./actionToggleArrowBinding";
export { actionToggleMidpointSnapping } from "./actionToggleMidpointSnapping";
export { actionToggleStats } from "./actionToggleStats";
export { actionUnbindText, actionBindText } from "./actionBoundText";
+2
View File
@@ -59,6 +59,8 @@ export type ActionName =
| "gridMode"
| "zenMode"
| "objectsSnapMode"
| "arrowBinding"
| "midpointSnapping"
| "stats"
| "changeStrokeColor"
| "changeBackgroundColor"
+5 -5
View File
@@ -27,7 +27,6 @@ export const getDefaultAppState = (): Omit<
showWelcomeScreen: false,
theme: THEME.LIGHT,
collaborators: new Map(),
currentChartType: "bar",
currentItemBackgroundColor: DEFAULT_ELEMENT_PROPS.backgroundColor,
currentItemEndArrowhead: "arrow",
currentItemFillStyle: DEFAULT_ELEMENT_PROPS.fillStyle,
@@ -71,6 +70,8 @@ export const getDefaultAppState = (): Omit<
gridStep: DEFAULT_GRID_STEP,
gridModeEnabled: false,
isBindingEnabled: true,
bindingPreference: "enabled",
isMidpointSnappingEnabled: true,
defaultSidebarDockedPreference: false,
isLoading: false,
isResizing: false,
@@ -83,7 +84,6 @@ export const getDefaultAppState = (): Omit<
openPopup: null,
openSidebar: null,
openDialog: null,
pasteDialog: { shown: false, data: null },
previousSelectedElementIds: {},
resizingElement: null,
scrolledOutside: false,
@@ -150,7 +150,6 @@ const APP_STATE_STORAGE_CONF = (<
showWelcomeScreen: { browser: true, export: false, server: false },
theme: { browser: true, export: false, server: false },
collaborators: { browser: false, export: false, server: false },
currentChartType: { browser: true, export: false, server: false },
currentItemBackgroundColor: { browser: true, export: false, server: false },
currentItemEndArrowhead: { browser: true, export: false, server: false },
currentItemFillStyle: { browser: true, export: false, server: false },
@@ -193,7 +192,9 @@ const APP_STATE_STORAGE_CONF = (<
gridStep: { browser: true, export: true, server: true },
gridModeEnabled: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: false, export: false, server: false },
isBindingEnabled: { browser: true, export: false, server: false },
bindingPreference: { browser: true, export: false, server: false },
isMidpointSnappingEnabled: { browser: true, export: false, server: false },
defaultSidebarDockedPreference: {
browser: true,
export: false,
@@ -212,7 +213,6 @@ const APP_STATE_STORAGE_CONF = (<
openPopup: { browser: false, export: false, server: false },
openSidebar: { browser: true, export: false, server: false },
openDialog: { browser: false, export: false, server: false },
pasteDialog: { browser: false, export: false, server: false },
previousSelectedElementIds: { browser: true, export: false, server: false },
resizingElement: { browser: false, export: false, server: false },
scrolledOutside: { browser: true, export: false, server: false },
File diff suppressed because it is too large Load Diff
-481
View File
@@ -1,481 +0,0 @@
import { pointFrom } from "@excalidraw/math";
import {
COLOR_PALETTE,
DEFAULT_CHART_COLOR_INDEX,
getAllColorsSpecificShade,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
VERTICAL_ALIGN,
randomId,
isDevEnv,
FONT_SIZES,
} from "@excalidraw/common";
import {
newTextElement,
newLinearElement,
newElement,
} from "@excalidraw/element";
import type { Radians } from "@excalidraw/math";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
export type ChartElements = readonly NonDeletedExcalidrawElement[];
const BAR_WIDTH = 32;
const BAR_GAP = 12;
const BAR_HEIGHT = 256;
const GRID_OPACITY = 50;
export interface Spreadsheet {
title: string | null;
labels: string[] | null;
values: number[];
}
export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
export const VALID_SPREADSHEET = "VALID_SPREADSHEET";
type ParseSpreadsheetResult =
| { type: typeof NOT_SPREADSHEET; reason: string }
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
/**
* @private exported for testing
*/
export const tryParseNumber = (s: string): number | null => {
const match = /^([-+]?)[$€£¥₩]?([-+]?)([\d.,]+)[%]?$/.exec(s);
if (!match) {
return null;
}
return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, ""));
};
const isNumericColumn = (lines: string[][], columnIndex: number) =>
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
/**
* @private exported for testing
*/
export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
const numCols = cells[0].length;
if (numCols > 2) {
return { type: NOT_SPREADSHEET, reason: "More than 2 columns" };
}
if (numCols === 1) {
if (!isNumericColumn(cells, 0)) {
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
}
const hasHeader = tryParseNumber(cells[0][0]) === null;
const values = (hasHeader ? cells.slice(1) : cells).map((line) =>
tryParseNumber(line[0]),
);
if (values.length < 2) {
return { type: NOT_SPREADSHEET, reason: "Less than two rows" };
}
return {
type: VALID_SPREADSHEET,
spreadsheet: {
title: hasHeader ? cells[0][0] : null,
labels: null,
values: values as number[],
},
};
}
const labelColumnNumeric = isNumericColumn(cells, 0);
const valueColumnNumeric = isNumericColumn(cells, 1);
if (!labelColumnNumeric && !valueColumnNumeric) {
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
}
const [labelColumnIndex, valueColumnIndex] = valueColumnNumeric
? [0, 1]
: [1, 0];
const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
const rows = hasHeader ? cells.slice(1) : cells;
if (rows.length < 2) {
return { type: NOT_SPREADSHEET, reason: "Less than 2 rows" };
}
return {
type: VALID_SPREADSHEET,
spreadsheet: {
title: hasHeader ? cells[0][valueColumnIndex] : null,
labels: rows.map((row) => row[labelColumnIndex]),
values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!),
},
};
};
const transposeCells = (cells: string[][]) => {
const nextCells: string[][] = [];
for (let col = 0; col < cells[0].length; col++) {
const nextCellRow: string[] = [];
for (let row = 0; row < cells.length; row++) {
nextCellRow.push(cells[row][col]);
}
nextCells.push(nextCellRow);
}
return nextCells;
};
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
// Copy/paste from excel, spreadsheets, tsv, csv.
// For now we only accept 2 columns with an optional header
// Check for tab separated values
let lines = text
.trim()
.split("\n")
.map((line) => line.trim().split("\t"));
// Check for comma separated files
if (lines.length && lines[0].length !== 2) {
lines = text
.trim()
.split("\n")
.map((line) => line.trim().split(","));
}
if (lines.length === 0) {
return { type: NOT_SPREADSHEET, reason: "No values" };
}
const numColsFirstLine = lines[0].length;
const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
if (!isSpreadsheet) {
return {
type: NOT_SPREADSHEET,
reason: "All rows don't have same number of columns",
};
}
const result = tryParseCells(lines);
if (result.type !== VALID_SPREADSHEET) {
const transposedResults = tryParseCells(transposeCells(lines));
if (transposedResults.type === VALID_SPREADSHEET) {
return transposedResults;
}
}
return result;
};
const bgColors = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX);
// Put all the common properties here so when the whole chart is selected
// the properties dialog shows the correct selected values
const commonProps = {
fillStyle: "hachure",
fontFamily: DEFAULT_FONT_FAMILY,
fontSize: DEFAULT_FONT_SIZE,
opacity: 100,
roughness: 1,
strokeColor: COLOR_PALETTE.black,
roundness: null,
strokeStyle: "solid",
strokeWidth: 1,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
locked: false,
} as const;
const getChartDimensions = (spreadsheet: Spreadsheet) => {
const chartWidth =
(BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP;
const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
return { chartWidth, chartHeight };
};
const chartXLabels = (
spreadsheet: Spreadsheet,
x: number,
y: number,
groupId: string,
backgroundColor: string,
): ChartElements => {
return (
spreadsheet.labels?.map((label, index) => {
return newTextElement({
groupIds: [groupId],
backgroundColor,
...commonProps,
text: label.length > 8 ? `${label.slice(0, 5)}...` : label,
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
y: y + BAR_GAP / 2,
width: BAR_WIDTH,
angle: 5.87 as Radians,
fontSize: FONT_SIZES.sm,
textAlign: "center",
verticalAlign: "top",
});
}) || []
);
};
const chartYLabels = (
spreadsheet: Spreadsheet,
x: number,
y: number,
groupId: string,
backgroundColor: string,
): ChartElements => {
const minYLabel = newTextElement({
groupIds: [groupId],
backgroundColor,
...commonProps,
x: x - BAR_GAP,
y: y - BAR_GAP,
text: "0",
textAlign: "right",
});
const maxYLabel = newTextElement({
groupIds: [groupId],
backgroundColor,
...commonProps,
x: x - BAR_GAP,
y: y - BAR_HEIGHT - minYLabel.height / 2,
text: Math.max(...spreadsheet.values).toLocaleString(),
textAlign: "right",
});
return [minYLabel, maxYLabel];
};
const chartLines = (
spreadsheet: Spreadsheet,
x: number,
y: number,
groupId: string,
backgroundColor: string,
): ChartElements => {
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
const xLine = newLinearElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
type: "line",
x,
y,
width: chartWidth,
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
});
const yLine = newLinearElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
type: "line",
x,
y,
height: chartHeight,
points: [pointFrom(0, 0), pointFrom(0, -chartHeight)],
});
const maxLine = newLinearElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
type: "line",
x,
y: y - BAR_HEIGHT - BAR_GAP,
strokeStyle: "dotted",
width: chartWidth,
opacity: GRID_OPACITY,
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
});
return [xLine, yLine, maxLine];
};
// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
const chartBaseElements = (
spreadsheet: Spreadsheet,
x: number,
y: number,
groupId: string,
backgroundColor: string,
debug?: boolean,
): ChartElements => {
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
const title = spreadsheet.title
? newTextElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
text: spreadsheet.title,
x: x + chartWidth / 2,
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
roundness: null,
textAlign: "center",
})
: null;
const debugRect = debug
? newElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
type: "rectangle",
x,
y: y - chartHeight,
width: chartWidth,
height: chartHeight,
strokeColor: COLOR_PALETTE.black,
fillStyle: "solid",
opacity: 6,
})
: null;
return [
...(debugRect ? [debugRect] : []),
...(title ? [title] : []),
...chartXLabels(spreadsheet, x, y, groupId, backgroundColor),
...chartYLabels(spreadsheet, x, y, groupId, backgroundColor),
...chartLines(spreadsheet, x, y, groupId, backgroundColor),
];
};
const chartTypeBar = (
spreadsheet: Spreadsheet,
x: number,
y: number,
): ChartElements => {
const max = Math.max(...spreadsheet.values);
const groupId = randomId();
const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
const bars = spreadsheet.values.map((value, index) => {
const barHeight = (value / max) * BAR_HEIGHT;
return newElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
type: "rectangle",
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP,
y: y - barHeight - BAR_GAP,
width: BAR_WIDTH,
height: barHeight,
});
});
return [
...bars,
...chartBaseElements(
spreadsheet,
x,
y,
groupId,
backgroundColor,
isDevEnv(),
),
];
};
const chartTypeLine = (
spreadsheet: Spreadsheet,
x: number,
y: number,
): ChartElements => {
const max = Math.max(...spreadsheet.values);
const groupId = randomId();
const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
let index = 0;
const points = [];
for (const value of spreadsheet.values) {
const cx = index * (BAR_WIDTH + BAR_GAP);
const cy = -(value / max) * BAR_HEIGHT;
points.push([cx, cy]);
index++;
}
const maxX = Math.max(...points.map((element) => element[0]));
const maxY = Math.max(...points.map((element) => element[1]));
const minX = Math.min(...points.map((element) => element[0]));
const minY = Math.min(...points.map((element) => element[1]));
const line = newLinearElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
type: "line",
x: x + BAR_GAP + BAR_WIDTH / 2,
y: y - BAR_GAP,
height: maxY - minY,
width: maxX - minX,
strokeWidth: 2,
points: points as any,
});
const dots = spreadsheet.values.map((value, index) => {
const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
const cy = -(value / max) * BAR_HEIGHT + BAR_GAP / 2;
return newElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
fillStyle: "solid",
strokeWidth: 2,
type: "ellipse",
x: x + cx + BAR_WIDTH / 2,
y: y + cy - BAR_GAP * 2,
width: BAR_GAP,
height: BAR_GAP,
});
});
const lines = spreadsheet.values.map((value, index) => {
const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
const cy = (value / max) * BAR_HEIGHT + BAR_GAP / 2 + BAR_GAP;
return newLinearElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
type: "line",
x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
y: y - cy,
height: cy,
strokeStyle: "dotted",
opacity: GRID_OPACITY,
points: [pointFrom(0, 0), pointFrom(0, cy)],
});
});
return [
...chartBaseElements(
spreadsheet,
x,
y,
groupId,
backgroundColor,
isDevEnv(),
),
line,
...lines,
...dots,
];
};
export const renderSpreadsheet = (
chartType: string,
spreadsheet: Spreadsheet,
x: number,
y: number,
): ChartElements => {
if (chartType === "line") {
return chartTypeLine(spreadsheet, x, y);
}
return chartTypeBar(spreadsheet, x, y);
};
+103
View File
@@ -0,0 +1,103 @@
import { isDevEnv } from "@excalidraw/common";
import { newElement } from "@excalidraw/element";
import { commonProps } from "./charts.constants";
import {
chartBaseElements,
chartXLabels,
createSeriesLegend,
getBackgroundColor,
getCartesianChartLayout,
getChartDimensions,
getColorOffset,
getRotatedTextElementBottom,
getSeriesColors,
} from "./charts.helpers";
import type { ChartElements, Spreadsheet } from "./charts.types";
export const renderBarChart = (
spreadsheet: Spreadsheet,
x: number,
y: number,
colorSeed?: number,
): ChartElements => {
const series = spreadsheet.series;
const layout = getCartesianChartLayout("bar", series.length);
const max = Math.max(
1,
...series.flatMap((seriesData) =>
seriesData.values.map((value) => Math.max(0, value)),
),
);
const colorOffset = getColorOffset(colorSeed);
const backgroundColor = getBackgroundColor(colorOffset);
const seriesColors = getSeriesColors(series.length, colorOffset);
const interBarGap =
series.length > 1
? Math.max(1, Math.floor(layout.gap / (series.length + 1)))
: 0;
const barWidth =
series.length > 1
? Math.max(
2,
(layout.slotWidth - interBarGap * (series.length - 1)) /
series.length,
)
: layout.slotWidth;
const clusterWidth =
series.length * barWidth + interBarGap * (series.length - 1);
const clusterOffset = (layout.slotWidth - clusterWidth) / 2;
const bars = series[0].values.flatMap((_, categoryIndex) =>
series.map((seriesData, seriesIndex) => {
const value = Math.max(0, seriesData.values[categoryIndex] ?? 0);
const barHeight = (value / max) * layout.chartHeight;
const barColor =
series.length > 1 ? seriesColors[seriesIndex] : backgroundColor;
return newElement({
backgroundColor: barColor,
...commonProps,
type: "rectangle",
fillStyle: series.length > 1 ? "solid" : commonProps.fillStyle,
strokeColor: series.length > 1 ? barColor : commonProps.strokeColor,
x:
x +
categoryIndex * (layout.slotWidth + layout.gap) +
layout.gap +
clusterOffset +
seriesIndex * (barWidth + interBarGap),
y: y - barHeight - layout.gap,
width: barWidth,
height: barHeight,
});
}),
);
const baseElements = chartBaseElements(
spreadsheet,
x,
y,
backgroundColor,
layout,
max,
isDevEnv(),
);
const xLabels = chartXLabels(spreadsheet, x, y, backgroundColor, layout);
const xLabelsBottomY = Math.max(
y + layout.gap / 2,
...xLabels.map((label) => getRotatedTextElementBottom(label)),
);
const { chartWidth } = getChartDimensions(spreadsheet, layout);
const seriesLegend = createSeriesLegend(
series,
seriesColors,
x + chartWidth / 2,
xLabelsBottomY,
y + layout.gap * 5,
backgroundColor,
);
return [...baseElements, ...bars, ...seriesLegend];
};
@@ -0,0 +1,63 @@
import {
COLOR_PALETTE,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
VERTICAL_ALIGN,
} from "@excalidraw/common";
import type { Radians } from "@excalidraw/math";
export const CARTESIAN_BASE_SLOT_WIDTH = 44;
export const CARTESIAN_BAR_SLOT_EXTRA_PER_SERIES = 22;
export const CARTESIAN_BAR_SLOT_EXTRA_MAX = 66;
export const CARTESIAN_LINE_SLOT_WIDTH = 48;
export const CARTESIAN_GAP = 14;
export const CARTESIAN_BAR_HEIGHT = 304;
export const CARTESIAN_LINE_HEIGHT = 320;
export const CARTESIAN_LABEL_ROTATION = 5.87 as Radians;
export const CARTESIAN_LABEL_MIN_WIDTH = 28;
export const CARTESIAN_LABEL_SLOT_PADDING = 4;
export const CARTESIAN_LABEL_AXIS_CLEARANCE = 2;
export const CARTESIAN_LABEL_MAX_WIDTH_BUFFER = 10;
export const CARTESIAN_LABEL_ROTATED_WIDTH_BUFFER = 10;
export const CARTESIAN_LABEL_OVERFLOW_PREFERENCE_BUFFER = 8;
export const BAR_GAP = 12;
export const BAR_HEIGHT = 256;
export const GRID_OPACITY = 10;
export const RADAR_GRID_LEVELS = 4;
export const RADAR_LABEL_OFFSET = BAR_GAP * 2;
export const RADAR_PADDING = BAR_GAP * 2;
export const RADAR_SINGLE_SERIES_LOG_SCALE_THRESHOLD = 100;
export const RADAR_AXIS_LABEL_MAX_WIDTH = 140;
export const RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD = 0.35;
export const RADAR_AXIS_LABEL_CLEARANCE = BAR_GAP / 2;
export const RADAR_LEGEND_SWATCH_SIZE = 20;
export const RADAR_LEGEND_ITEM_GAP = BAR_GAP * 2;
export const RADAR_LEGEND_TEXT_GAP = BAR_GAP;
// Put all common chart element properties here so properties dialog
// shows stable values when selecting chart groups.
export const commonProps = {
fillStyle: "hachure",
fontFamily: DEFAULT_FONT_FAMILY,
fontSize: DEFAULT_FONT_SIZE,
opacity: 100,
roughness: 1,
strokeColor: COLOR_PALETTE.black,
roundness: null,
strokeStyle: "solid",
strokeWidth: 1,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
locked: false,
} as const;
export type CartesianChartType = "bar" | "line";
export type CartesianChartLayout = {
slotWidth: number;
gap: number;
chartHeight: number;
xLabelMaxWidth: number;
};
@@ -0,0 +1,865 @@
import { pointFrom } from "@excalidraw/math";
import {
COLOR_PALETTE,
DEFAULT_CHART_COLOR_INDEX,
FONT_FAMILY,
FONT_SIZES,
ROUNDNESS,
DEFAULT_FONT_SIZE,
getAllColorsSpecificShade,
getFontString,
getLineHeight,
ROUGHNESS,
} from "@excalidraw/common";
import {
getApproxMinLineWidth,
measureText,
newElement,
newLinearElement,
newTextElement,
wrapText,
} from "@excalidraw/element";
import type {
ChartType,
ExcalidrawTextElement,
} from "@excalidraw/element/types";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import {
BAR_GAP,
CARTESIAN_BAR_HEIGHT,
CARTESIAN_BASE_SLOT_WIDTH,
CARTESIAN_BAR_SLOT_EXTRA_MAX,
CARTESIAN_BAR_SLOT_EXTRA_PER_SERIES,
CARTESIAN_GAP,
CARTESIAN_LABEL_AXIS_CLEARANCE,
CARTESIAN_LABEL_MAX_WIDTH_BUFFER,
CARTESIAN_LABEL_MIN_WIDTH,
CARTESIAN_LABEL_OVERFLOW_PREFERENCE_BUFFER,
CARTESIAN_LABEL_ROTATED_WIDTH_BUFFER,
CARTESIAN_LABEL_ROTATION,
CARTESIAN_LABEL_SLOT_PADDING,
CARTESIAN_LINE_HEIGHT,
CARTESIAN_LINE_SLOT_WIDTH,
GRID_OPACITY,
RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD,
RADAR_AXIS_LABEL_CLEARANCE,
RADAR_AXIS_LABEL_MAX_WIDTH,
RADAR_LABEL_OFFSET,
RADAR_LEGEND_ITEM_GAP,
RADAR_LEGEND_SWATCH_SIZE,
RADAR_LEGEND_TEXT_GAP,
RADAR_PADDING,
RADAR_SINGLE_SERIES_LOG_SCALE_THRESHOLD,
BAR_HEIGHT,
commonProps,
type CartesianChartLayout,
type CartesianChartType,
} from "./charts.constants";
import type {
ChartElements,
Spreadsheet,
SpreadsheetSeries,
} from "./charts.types";
const bgColors = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX);
const getSpreadsheetDimensionCount = (spreadsheet: Spreadsheet) =>
spreadsheet.labels?.length ?? spreadsheet.series[0]?.values.length ?? 0;
export const isSpreadsheetValidForChartType = (
spreadsheet: Spreadsheet | null,
chartType: ChartType,
) => {
if (!spreadsheet) {
return false;
}
const dimensionCount = getSpreadsheetDimensionCount(spreadsheet);
if (dimensionCount < 2) {
return false;
}
if (chartType === "radar") {
return dimensionCount >= 3;
}
return true;
};
const getSeriesAwareSlotWidth = (
baseSlotWidth: number,
seriesCount: number,
) => {
const extraSlotWidth =
seriesCount <= 1
? 0
: Math.min(
CARTESIAN_BAR_SLOT_EXTRA_MAX,
(seriesCount - 1) * CARTESIAN_BAR_SLOT_EXTRA_PER_SERIES,
);
return baseSlotWidth + extraSlotWidth;
};
export const getCartesianChartLayout = (
chartType: CartesianChartType,
seriesCount: number,
): CartesianChartLayout => {
if (chartType === "line") {
const slotWidth = getSeriesAwareSlotWidth(
CARTESIAN_LINE_SLOT_WIDTH,
seriesCount,
);
return {
slotWidth,
gap: CARTESIAN_GAP,
chartHeight: CARTESIAN_LINE_HEIGHT,
xLabelMaxWidth:
slotWidth + CARTESIAN_GAP * 3 + CARTESIAN_LABEL_MAX_WIDTH_BUFFER,
};
}
const slotWidth = getSeriesAwareSlotWidth(
CARTESIAN_BASE_SLOT_WIDTH,
seriesCount,
);
return {
slotWidth,
gap: CARTESIAN_GAP,
chartHeight: CARTESIAN_BAR_HEIGHT,
xLabelMaxWidth:
slotWidth + CARTESIAN_GAP * 3 + CARTESIAN_LABEL_MAX_WIDTH_BUFFER,
};
};
export const getChartDimensions = (
spreadsheet: Spreadsheet,
layout: CartesianChartLayout,
) => {
const chartWidth =
(layout.slotWidth + layout.gap) * spreadsheet.series[0].values.length +
layout.gap;
const chartHeight = layout.chartHeight + layout.gap * 2;
return { chartWidth, chartHeight };
};
export const getRadarDimensions = () => {
const chartWidth = BAR_HEIGHT + RADAR_PADDING * 2;
const chartHeight = BAR_HEIGHT + RADAR_PADDING * 2;
return { chartWidth, chartHeight };
};
const getCircularDistance = (
firstIndex: number,
secondIndex: number,
paletteSize: number,
) => {
const absoluteDistance = Math.abs(firstIndex - secondIndex);
return Math.min(absoluteDistance, paletteSize - absoluteDistance);
};
export const getSeriesColors = (
seriesCount: number,
colorOffset: number,
): readonly string[] => {
if (seriesCount <= 0 || bgColors.length === 0) {
return [];
}
const paletteSize = bgColors.length;
const startIndex = ((colorOffset % paletteSize) + paletteSize) % paletteSize;
const selectedIndices = [startIndex];
const maxUniqueColors = Math.min(seriesCount, paletteSize);
const availableIndices = new Set(
Array.from({ length: paletteSize }, (_, index) => index).filter(
(index) => index !== startIndex,
),
);
while (selectedIndices.length < maxUniqueColors) {
let bestIndex = -1;
let bestMinDistance = -1;
let bestAverageDistance = -1;
for (const candidateIndex of availableIndices) {
const distances = selectedIndices.map((selectedIndex) =>
getCircularDistance(candidateIndex, selectedIndex, paletteSize),
);
const minDistance = Math.min(...distances);
const averageDistance =
distances.reduce((total, distance) => total + distance, 0) /
distances.length;
if (
minDistance > bestMinDistance ||
(minDistance === bestMinDistance &&
averageDistance > bestAverageDistance)
) {
bestIndex = candidateIndex;
bestMinDistance = minDistance;
bestAverageDistance = averageDistance;
}
}
selectedIndices.push(bestIndex);
availableIndices.delete(bestIndex);
}
return Array.from(
{ length: seriesCount },
(_, index) => bgColors[selectedIndices[index % selectedIndices.length]],
);
};
export const getColorOffset = (colorSeed?: number) => {
if (bgColors.length === 0) {
return 0;
}
if (typeof colorSeed !== "number" || !Number.isFinite(colorSeed)) {
return Math.floor(Math.random() * bgColors.length);
}
const seedText = colorSeed.toString();
let hash = 0;
for (let index = 0; index < seedText.length; index++) {
hash = (hash * 31 + seedText.charCodeAt(index)) | 0;
}
return Math.abs(hash) % bgColors.length;
};
export const getBackgroundColor = (colorOffset: number) =>
bgColors[colorOffset];
export const getRadarValueScale = (
series: SpreadsheetSeries[],
_labelsLength: number,
) => {
const allValues = series.flatMap((s) =>
s.values.map((value) => Math.max(0, value)),
);
const positiveValues = allValues.filter((value) => value > 0);
const max = Math.max(1, ...allValues);
const minPositive =
positiveValues.length > 0 ? Math.min(...positiveValues) : 1;
const useLogScale =
series.length === 1 &&
minPositive > 0 &&
max / minPositive >= RADAR_SINGLE_SERIES_LOG_SCALE_THRESHOLD;
return {
renderSteps: false,
normalize: (value: number, _axisIndex: number) => {
const safeValue = Math.max(0, value);
return useLogScale
? Math.log10(safeValue + 1) / Math.log10(max + 1)
: safeValue / max;
},
};
};
const shouldWrapRadarText = (text: string) => /\s/.test(text.trim());
export const getRadarDisplayText = (
text: string,
fontString: ReturnType<typeof getFontString>,
maxWidth: number,
) => {
return shouldWrapRadarText(text)
? wrapText(text, fontString, maxWidth)
: text;
};
export const createRadarAxisLabels = (
labels: readonly string[],
angles: readonly number[],
centerX: number,
centerY: number,
radius: number,
backgroundColor: string,
): {
axisLabels: ChartElements;
axisLabelTopY: number;
axisLabelBottomY: number;
} => {
const fontFamily = FONT_FAMILY.Excalifont;
const fontSize = FONT_SIZES.sm;
const lineHeight = getLineHeight(fontFamily);
const fontString = getFontString({ fontFamily, fontSize });
const baseLabelWidth = Math.min(
RADAR_AXIS_LABEL_MAX_WIDTH,
radius * (labels.length > 8 ? 0.56 : 0.72),
);
const minLabelWidth = getApproxMinLineWidth(fontString, lineHeight);
const axisLabels = labels.map((label, index) => {
const angle = angles[index];
const longestWordWidth = Math.max(
0,
...label
.trim()
.split(/\s+/)
.filter(Boolean)
.map((word) => measureText(word, fontString, lineHeight).width),
);
const maxLabelWidth = Math.max(
minLabelWidth,
baseLabelWidth,
longestWordWidth,
);
const displayLabel = getRadarDisplayText(label, fontString, maxLabelWidth);
const metrics = measureText(displayLabel, fontString, lineHeight);
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const textAlign: "left" | "center" | "right" =
cos > RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD
? "left"
: cos < -RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD
? "right"
: "center";
// Keep labels outside the radar ring by projecting text extents
// onto the axis direction.
const centerAlignedXExtent = textAlign === "center" ? metrics.width / 2 : 0;
const projectedExtent =
Math.abs(cos) * centerAlignedXExtent +
Math.abs(sin) * (metrics.height / 2);
const radialOffset =
RADAR_LABEL_OFFSET + projectedExtent + RADAR_AXIS_LABEL_CLEARANCE;
const anchorX = centerX + cos * (radius + radialOffset);
const anchorY = centerY + sin * (radius + radialOffset);
const yNudge =
sin > RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD
? BAR_GAP / 3
: sin < -RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD
? -BAR_GAP / 3
: 0;
return newTextElement({
backgroundColor,
...commonProps,
text: displayLabel,
originalText: label,
x: anchorX,
y: anchorY + yNudge,
fontFamily,
fontSize,
lineHeight,
textAlign,
verticalAlign: "middle",
});
});
const axisLabelTopY = Math.min(...axisLabels.map((axisLabel) => axisLabel.y));
const axisLabelBottomY = Math.max(
...axisLabels.map((axisLabel) => axisLabel.y + axisLabel.height),
);
return { axisLabels, axisLabelTopY, axisLabelBottomY };
};
export const createSeriesLegend = (
series: SpreadsheetSeries[],
seriesColors: readonly string[],
centerX: number,
minLegendTopY: number,
fallbackLegendY: number,
backgroundColor: string,
): ChartElements => {
if (series.length <= 1) {
return [];
}
const fontFamily = FONT_FAMILY["Lilita One"];
const fontSize = FONT_SIZES.lg;
const lineHeight = getLineHeight(fontFamily);
const fontString = getFontString({ fontFamily, fontSize });
const legendItems = series.map((seriesItem, index) => {
const label = seriesItem.title?.trim() || `Series ${index + 1}`;
const displayLabel = getRadarDisplayText(label, fontString, BAR_HEIGHT);
const metrics = measureText(displayLabel, fontString, lineHeight);
const itemWidth =
RADAR_LEGEND_SWATCH_SIZE + RADAR_LEGEND_TEXT_GAP + metrics.width;
return {
label,
displayLabel,
color: seriesColors[index],
width: itemWidth,
height: metrics.height,
};
});
const maxLegendHalfHeight = Math.max(
RADAR_LEGEND_SWATCH_SIZE / 2,
...legendItems.map((item) => item.height / 2),
);
const legendY = Math.max(
fallbackLegendY,
minLegendTopY + maxLegendHalfHeight + RADAR_LABEL_OFFSET,
);
const pillPaddingX = RADAR_LEGEND_ITEM_GAP;
const pillPaddingY = RADAR_LEGEND_SWATCH_SIZE * 0.6;
const totalLegendWidth =
legendItems.reduce((total, item) => total + item.width, 0) +
RADAR_LEGEND_ITEM_GAP * Math.max(0, legendItems.length - 1);
const pillWidth = totalLegendWidth + pillPaddingX * 2;
const pillHeight = maxLegendHalfHeight * 2 + pillPaddingY * 2;
const legendElements: NonDeletedExcalidrawElement[] = [];
// rounded pill background
legendElements.push(
newElement({
...commonProps,
backgroundColor: "transparent",
type: "rectangle",
fillStyle: "solid",
strokeColor: COLOR_PALETTE.black,
x: centerX - pillWidth / 2,
y: legendY - pillHeight / 2,
width: pillWidth,
height: pillHeight,
roughness: ROUGHNESS.architect,
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
}),
);
let cursorX = centerX - totalLegendWidth / 2;
legendItems.forEach((item) => {
// solid filled swatch
legendElements.push(
newElement({
...commonProps,
backgroundColor: item.color,
type: "rectangle",
x: cursorX,
y: legendY - RADAR_LEGEND_SWATCH_SIZE / 2,
width: RADAR_LEGEND_SWATCH_SIZE,
height: RADAR_LEGEND_SWATCH_SIZE,
fillStyle: "solid",
strokeColor: item.color,
roughness: ROUGHNESS.architect,
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
}),
);
// label in default (black) color
legendElements.push(
newTextElement({
...commonProps,
text: item.displayLabel,
originalText: item.label,
autoResize: false,
x: cursorX + RADAR_LEGEND_SWATCH_SIZE + RADAR_LEGEND_TEXT_GAP,
y: legendY,
fontFamily,
fontSize,
lineHeight,
textAlign: "left",
verticalAlign: "middle",
}),
);
cursorX += item.width + RADAR_LEGEND_ITEM_GAP;
});
return legendElements;
};
const ellipsifyTextToWidth = (
text: string,
maxWidth: number,
fontString: ReturnType<typeof getFontString>,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
if (measureText(text, fontString, lineHeight).width <= maxWidth) {
return text;
}
let end = text.length;
while (end > 1) {
const candidate = `${text.slice(0, end)}...`;
if (measureText(candidate, fontString, lineHeight).width <= maxWidth) {
return candidate;
}
end--;
}
return text[0] ? `${text[0]}...` : text;
};
const wrapOrEllipsifyTextToWidth = (
text: string,
maxWidth: number,
fontString: ReturnType<typeof getFontString>,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
if (measureText(text, fontString, lineHeight).width <= maxWidth) {
return { wrapped: false, text };
}
const words = text.trim().split(/\s+/).filter(Boolean);
if (words.length > 1) {
const hasLongWord = words.some((word) => {
return measureText(word, fontString, lineHeight).width > maxWidth;
});
if (
!hasLongWord &&
maxWidth >= getApproxMinLineWidth(fontString, lineHeight)
) {
return { wrapped: true, text: wrapText(text, fontString, maxWidth) };
}
}
return {
wrapped: false,
text: ellipsifyTextToWidth(text, maxWidth, fontString, lineHeight),
};
};
const getRotatedBoundingBox = (
width: number,
height: number,
angle: number,
) => {
const cos = Math.abs(Math.cos(angle));
const sin = Math.abs(Math.sin(angle));
return {
width: width * cos + height * sin,
height: width * sin + height * cos,
};
};
type CartesianAxisLabelSpec = {
originalText: string;
text: string;
wrapped: boolean;
metrics: ReturnType<typeof measureText>;
rotatedWidth: number;
rotatedHeight: number;
};
const isEllipsifiedLabel = (text: string) => text.includes("...");
const getCartesianAxisLabelSpec = (
label: string,
maxLabelWidth: number,
maxRotatedWidth: number,
fontString: ReturnType<typeof getFontString>,
lineHeight: ExcalidrawTextElement["lineHeight"],
): CartesianAxisLabelSpec => {
const minWidth = Math.max(
CARTESIAN_LABEL_MIN_WIDTH,
Math.ceil(getApproxMinLineWidth(fontString, lineHeight)),
);
const maxWidth = Math.max(minWidth, Math.floor(maxLabelWidth));
const candidateWidths: number[] = [];
for (let width = maxWidth; width >= minWidth; width -= 4) {
candidateWidths.push(width);
}
if (candidateWidths[candidateWidths.length - 1] !== minWidth) {
candidateWidths.push(minWidth);
}
const getRank = (spec: CartesianAxisLabelSpec) => {
const ellipsified = isEllipsifiedLabel(spec.text);
const visibleChars = spec.text
.replace(/\.\.\./g, "")
.replace(/\n/g, "").length;
const lineCount = spec.text.split("\n").length;
return {
ellipsified,
visibleChars,
lineCount,
};
};
const shouldPrefer = (
candidate: CartesianAxisLabelSpec,
current: CartesianAxisLabelSpec,
) => {
const candidateRank = getRank(candidate);
const currentRank = getRank(current);
if (candidateRank.ellipsified !== currentRank.ellipsified) {
return !candidateRank.ellipsified;
}
if (candidateRank.visibleChars !== currentRank.visibleChars) {
return candidateRank.visibleChars > currentRank.visibleChars;
}
if (candidateRank.lineCount !== currentRank.lineCount) {
return candidateRank.lineCount < currentRank.lineCount;
}
return candidate.rotatedHeight < current.rotatedHeight;
};
let bestFit: CartesianAxisLabelSpec | null = null;
let bestOverflowAny: {
overflow: number;
spec: CartesianAxisLabelSpec;
} | null = null;
let bestOverflowNonEllipsified: {
overflow: number;
spec: CartesianAxisLabelSpec;
} | null = null;
for (const width of candidateWidths) {
const { wrapped, text } = wrapOrEllipsifyTextToWidth(
label,
width,
fontString,
lineHeight,
);
const metrics = measureText(text, fontString, lineHeight);
const rotated = getRotatedBoundingBox(
metrics.width,
metrics.height,
CARTESIAN_LABEL_ROTATION,
);
const spec = {
originalText: label,
text,
metrics,
rotatedWidth: rotated.width,
rotatedHeight: rotated.height,
wrapped,
};
const overflow = rotated.width - maxRotatedWidth;
if (overflow <= 0) {
if (!bestFit || shouldPrefer(spec, bestFit)) {
bestFit = spec;
}
continue;
}
if (
!bestOverflowAny ||
overflow < bestOverflowAny.overflow ||
(overflow === bestOverflowAny.overflow &&
shouldPrefer(spec, bestOverflowAny.spec))
) {
bestOverflowAny = { overflow, spec };
}
if (
!isEllipsifiedLabel(spec.text) &&
(!bestOverflowNonEllipsified ||
overflow < bestOverflowNonEllipsified.overflow ||
(overflow === bestOverflowNonEllipsified.overflow &&
shouldPrefer(spec, bestOverflowNonEllipsified.spec)))
) {
bestOverflowNonEllipsified = { overflow, spec };
}
}
if (bestFit) {
return bestFit;
}
if (
bestOverflowNonEllipsified &&
bestOverflowAny &&
bestOverflowNonEllipsified.overflow <=
bestOverflowAny.overflow + CARTESIAN_LABEL_OVERFLOW_PREFERENCE_BUFFER
) {
return bestOverflowNonEllipsified.spec;
}
return bestOverflowAny!.spec;
};
export const getRotatedTextElementBottom = (
element: NonDeletedExcalidrawElement,
) => {
if (element.type !== "text") {
return element.y + element.height;
}
const rotated = getRotatedBoundingBox(
element.width,
element.height,
element.angle,
);
return element.y + element.height / 2 + rotated.height / 2;
};
export const chartXLabels = (
spreadsheet: Spreadsheet,
x: number,
y: number,
backgroundColor: string,
layout: CartesianChartLayout,
): ChartElements => {
const fontFamily = commonProps.fontFamily;
const fontSize = FONT_SIZES.sm;
const lineHeight = getLineHeight(fontFamily);
const fontString = getFontString({ fontFamily, fontSize });
const maxRotatedWidth = Math.max(
1,
layout.slotWidth +
layout.gap -
CARTESIAN_LABEL_SLOT_PADDING * 2 +
CARTESIAN_LABEL_ROTATED_WIDTH_BUFFER,
);
const axisY = y;
return (
spreadsheet.labels?.map((label, index) => {
const labelSpec = getCartesianAxisLabelSpec(
label,
layout.xLabelMaxWidth,
maxRotatedWidth,
fontString,
lineHeight,
);
const centerX =
x +
index * (layout.slotWidth + layout.gap) +
layout.gap +
layout.slotWidth / 2;
const labelY =
axisY +
CARTESIAN_LABEL_AXIS_CLEARANCE +
(labelSpec.rotatedHeight - labelSpec.metrics.height) / 2;
return newTextElement({
backgroundColor,
...commonProps,
text: labelSpec.text,
originalText: labelSpec.wrapped ? label : labelSpec.text,
autoResize: !labelSpec.wrapped,
x: centerX,
y: labelY,
angle: CARTESIAN_LABEL_ROTATION,
fontSize,
lineHeight,
textAlign: "center",
verticalAlign: "top",
});
}) || []
);
};
const chartYLabels = (
spreadsheet: Spreadsheet,
x: number,
y: number,
backgroundColor: string,
layout: CartesianChartLayout,
maxValue = Math.max(...spreadsheet.series[0].values),
): ChartElements => {
const minYLabel = newTextElement({
backgroundColor,
...commonProps,
x: x - layout.gap,
y: y - layout.gap,
text: "0",
textAlign: "right",
});
const maxYLabel = newTextElement({
backgroundColor,
...commonProps,
x: x - layout.gap,
y: y - layout.chartHeight - minYLabel.height / 2,
text: maxValue.toLocaleString(),
textAlign: "right",
});
return [minYLabel, maxYLabel];
};
const chartLines = (
spreadsheet: Spreadsheet,
x: number,
y: number,
backgroundColor: string,
layout: CartesianChartLayout,
): ChartElements => {
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet, layout);
const xLine = newLinearElement({
backgroundColor,
...commonProps,
type: "line",
x,
y,
width: chartWidth,
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
});
const yLine = newLinearElement({
backgroundColor,
...commonProps,
type: "line",
x,
y,
height: chartHeight,
points: [pointFrom(0, 0), pointFrom(0, -chartHeight)],
});
const maxLine = newLinearElement({
backgroundColor,
...commonProps,
type: "line",
x,
y: y - layout.chartHeight - layout.gap,
strokeStyle: "dotted",
width: chartWidth,
opacity: GRID_OPACITY,
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
});
return [xLine, yLine, maxLine];
};
// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
export const chartBaseElements = (
spreadsheet: Spreadsheet,
x: number,
y: number,
backgroundColor: string,
layout: CartesianChartLayout,
maxValue = Math.max(...spreadsheet.series[0].values),
debug?: boolean,
): ChartElements => {
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet, layout);
const title = spreadsheet.title
? newTextElement({
backgroundColor,
...commonProps,
text: spreadsheet.title,
x: x + chartWidth / 2,
y: y - layout.chartHeight - layout.gap * 2 - DEFAULT_FONT_SIZE,
roundness: null,
textAlign: "center",
fontSize: FONT_SIZES.xl,
fontFamily: FONT_FAMILY["Lilita One"],
})
: null;
const debugRect = debug
? newElement({
backgroundColor,
...commonProps,
type: "rectangle",
x,
y: y - chartHeight,
width: chartWidth,
height: chartHeight,
strokeColor: COLOR_PALETTE.black,
fillStyle: "solid",
opacity: 6,
})
: null;
return [
...(debugRect ? [debugRect] : []),
...(title ? [title] : []),
...chartXLabels(spreadsheet, x, y, backgroundColor, layout),
...chartYLabels(spreadsheet, x, y, backgroundColor, layout, maxValue),
...chartLines(spreadsheet, x, y, backgroundColor, layout),
];
};
+130
View File
@@ -0,0 +1,130 @@
import { pointFrom } from "@excalidraw/math";
import { isDevEnv } from "@excalidraw/common";
import { newElement, newLinearElement } from "@excalidraw/element";
import type { LocalPoint } from "@excalidraw/math";
import { GRID_OPACITY, commonProps } from "./charts.constants";
import {
chartBaseElements,
chartXLabels,
createSeriesLegend,
getBackgroundColor,
getCartesianChartLayout,
getChartDimensions,
getColorOffset,
getRotatedTextElementBottom,
getSeriesColors,
} from "./charts.helpers";
import type { ChartElements, Spreadsheet } from "./charts.types";
export const renderLineChart = (
spreadsheet: Spreadsheet,
x: number,
y: number,
colorSeed?: number,
): ChartElements => {
const series = spreadsheet.series;
const layout = getCartesianChartLayout("line", series.length);
const max = Math.max(1, ...series.flatMap((seriesData) => seriesData.values));
const colorOffset = getColorOffset(colorSeed);
const backgroundColor = getBackgroundColor(colorOffset);
const seriesColors = getSeriesColors(series.length, colorOffset);
const lines = series.map((seriesData, seriesIndex) => {
const points = seriesData.values.map((value, valueIndex) =>
pointFrom<LocalPoint>(
valueIndex * (layout.slotWidth + layout.gap),
-(value / max) * layout.chartHeight,
),
);
const maxX = Math.max(...points.map((point) => point[0]));
const maxY = Math.max(...points.map((point) => point[1]));
const minX = Math.min(...points.map((point) => point[0]));
const minY = Math.min(...points.map((point) => point[1]));
return newLinearElement({
backgroundColor: "transparent",
...commonProps,
type: "line",
x: x + layout.gap + layout.slotWidth / 2,
y: y - layout.gap,
height: maxY - minY,
width: maxX - minX,
strokeColor: seriesColors[seriesIndex],
strokeWidth: 2,
points,
});
});
const dots = series.flatMap((seriesData, seriesIndex) =>
seriesData.values.map((value, valueIndex) => {
const cx = valueIndex * (layout.slotWidth + layout.gap) + layout.gap / 2;
const cy = -(value / max) * layout.chartHeight + layout.gap / 2;
return newElement({
backgroundColor: seriesColors[seriesIndex],
...commonProps,
fillStyle: "solid",
strokeColor: seriesColors[seriesIndex],
strokeWidth: 2,
type: "ellipse",
x: x + cx + layout.slotWidth / 2,
y: y + cy - layout.gap * 2,
width: layout.gap,
height: layout.gap,
});
}),
);
const guideValues = series[0].values.map((_, valueIndex) =>
Math.max(
0,
...series.map((seriesData) => seriesData.values[valueIndex] ?? 0),
),
);
const guides = guideValues.map((value, valueIndex) => {
const cx = valueIndex * (layout.slotWidth + layout.gap) + layout.gap / 2;
const cy = (value / max) * layout.chartHeight + layout.gap / 2 + layout.gap;
return newLinearElement({
backgroundColor,
...commonProps,
type: "line",
x: x + cx + layout.slotWidth / 2 + layout.gap / 2,
y: y - cy,
height: cy,
strokeStyle: "dotted",
opacity: GRID_OPACITY,
points: [pointFrom(0, 0), pointFrom(0, cy)],
});
});
const baseElements = chartBaseElements(
spreadsheet,
x,
y,
backgroundColor,
layout,
max,
isDevEnv(),
);
const xLabels = chartXLabels(spreadsheet, x, y, backgroundColor, layout);
const xLabelsBottomY = Math.max(
y + layout.gap / 2,
...xLabels.map((label) => getRotatedTextElementBottom(label)),
);
const { chartWidth } = getChartDimensions(spreadsheet, layout);
const seriesLegend = createSeriesLegend(
series,
seriesColors,
x + chartWidth / 2,
xLabelsBottomY,
y + layout.gap * 5,
backgroundColor,
);
return [...baseElements, ...lines, ...guides, ...dots, ...seriesLegend];
};
+174
View File
@@ -0,0 +1,174 @@
import { type ParseSpreadsheetResult } from "./charts.types";
/**
* @private exported for testing
*/
export const tryParseNumber = (s: string): number | null => {
const match =
/^([-+]?)[$\u20AC\u00A3\u00A5\u20A9]?([-+]?)([\d.,]+)[%]?$/.exec(s);
if (!match) {
return null;
}
return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, ""));
};
const isNumericColumn = (lines: string[][], columnIndex: number) =>
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
/**
* @private exported for testing
*/
export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
const numCols = cells[0].length;
if (numCols > 2) {
const hasHeader = cells[0].every((cell) => tryParseNumber(cell) === null);
const rows = hasHeader ? cells.slice(1) : cells;
if (rows.length < 1) {
return { ok: false, reason: "No data rows" };
}
const invalidNumericColumn = rows.some((row) =>
row.slice(1).some((value) => tryParseNumber(value) === null),
);
if (invalidNumericColumn) {
return { ok: false, reason: "Value is not numeric" };
}
// When there are more value columns than data rows, the data is in
// "wide" format — transpose so columns become labels (dimensions)
// and rows become series. This enables e.g. radar charts for wide data.
const numValueCols = numCols - 1;
if (numValueCols > rows.length) {
const labels = hasHeader ? cells[0].slice(1).map((h) => h.trim()) : null;
const series = rows.map((row) => ({
title: row[0]?.trim() || null,
values: row.slice(1).map((v) => tryParseNumber(v)!),
}));
const title =
series.length === 1
? series[0].title
: hasHeader
? cells[0][0].trim() || null
: null;
return {
ok: true,
data: { title, labels, series },
};
}
const series = cells[0].slice(1).map((seriesTitle, index) => {
const valueColumnIndex = index + 1;
const fallbackTitle = `Series ${valueColumnIndex}`;
return {
title: hasHeader ? seriesTitle.trim() || fallbackTitle : fallbackTitle,
values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!),
};
});
return {
ok: true,
data: {
title: hasHeader ? cells[0][0].trim() || null : null,
labels: rows.map((row) => row[0]),
series,
},
};
}
if (numCols === 1) {
if (!isNumericColumn(cells, 0)) {
return { ok: false, reason: "Value is not numeric" };
}
const hasHeader = tryParseNumber(cells[0][0]) === null;
const title = hasHeader ? cells[0][0] : null;
const values = (hasHeader ? cells.slice(1) : cells).map((line) =>
tryParseNumber(line[0]),
);
if (values.length < 2) {
return { ok: false, reason: "Less than two rows" };
}
return {
ok: true,
data: {
title,
labels: null,
series: [{ title, values: values as number[] }],
},
};
}
const hasHeader = tryParseNumber(cells[0][1]) === null;
const rows = hasHeader ? cells.slice(1) : cells;
if (rows.length < 2) {
return { ok: false, reason: "Less than 2 rows" };
}
const invalidNumericColumn = rows.some(
(row) => tryParseNumber(row[1]) === null,
);
if (invalidNumericColumn) {
return { ok: false, reason: "Value is not numeric" };
}
const title = hasHeader ? cells[0][1] : null;
return {
ok: true,
data: {
title,
labels: rows.map((row) => row[0]),
series: [{ title, values: rows.map((row) => tryParseNumber(row[1])!) }],
},
};
};
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
// Copy/paste from excel, spreadsheets, TSV, CSV, semicolon-separated.
const parseDelimitedLines = (delimiter: "\t" | "," | ";") =>
text
.replace(/\r\n?/g, "\n")
.split("\n")
.filter((line) => line.trim().length > 0)
.map((line) => line.split(delimiter).map((cell) => cell.trim()));
// Score each delimiter: prefer consistent column counts with the most columns.
// A delimiter that produces all single-column rows likely isn't the right one.
const candidates = (["\t", ",", ";"] as const).map((delimiter) => {
const parsed = parseDelimitedLines(delimiter);
const numCols = parsed[0]?.length ?? 0;
const isConsistent =
parsed.length > 0 && parsed.every((line) => line.length === numCols);
return { delimiter, parsed, numCols, isConsistent };
});
// Prefer: consistent + most columns. Among ties, tab > comma > semicolon
// (the array order already encodes this priority).
const best =
candidates.find((c) => c.isConsistent && c.numCols > 1) ??
candidates.find((c) => c.isConsistent) ??
candidates[0];
const lines = best.parsed;
if (lines.length === 0) {
return { ok: false, reason: "No values" };
}
const numColsFirstLine = lines[0].length;
const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
if (!isSpreadsheet) {
return {
ok: false,
reason: "All rows don't have same number of columns",
};
}
return tryParseCells(lines);
};
+199
View File
@@ -0,0 +1,199 @@
import { pointFrom } from "@excalidraw/math";
import {
FONT_FAMILY,
FONT_SIZES,
getFontString,
getLineHeight,
ROUGHNESS,
} from "@excalidraw/common";
import {
measureText,
newLinearElement,
newTextElement,
} from "@excalidraw/element";
import type { LocalPoint } from "@excalidraw/math";
import {
BAR_GAP,
BAR_HEIGHT,
GRID_OPACITY,
RADAR_GRID_LEVELS,
RADAR_LABEL_OFFSET,
commonProps,
} from "./charts.constants";
import {
createRadarAxisLabels,
createSeriesLegend,
getBackgroundColor,
getColorOffset,
getRadarDimensions,
getRadarDisplayText,
getRadarValueScale,
getSeriesColors,
isSpreadsheetValidForChartType,
} from "./charts.helpers";
import type { ChartElements, Spreadsheet } from "./charts.types";
export const renderRadarChart = (
spreadsheet: Spreadsheet,
x: number,
y: number,
colorSeed?: number,
): ChartElements | null => {
if (!isSpreadsheetValidForChartType(spreadsheet, "radar")) {
return null;
}
const labels =
spreadsheet.labels ??
spreadsheet.series[0].values.map((_, index) => `Value ${index + 1}`);
const series = spreadsheet.series;
const { normalize, renderSteps } = getRadarValueScale(series, labels.length);
const colorOffset = getColorOffset(colorSeed);
const backgroundColor = getBackgroundColor(colorOffset);
const seriesColors = getSeriesColors(series.length, colorOffset);
const { chartWidth, chartHeight } = getRadarDimensions();
const centerX = x + chartWidth / 2;
const centerY = y - chartHeight / 2;
const radius = BAR_HEIGHT / 2;
const angles = labels.map(
(_, index) => -Math.PI / 2 + (Math.PI * 2 * index) / labels.length,
);
const { axisLabels, axisLabelTopY, axisLabelBottomY } = createRadarAxisLabels(
labels,
angles,
centerX,
centerY,
radius,
backgroundColor,
);
const titleFontFamily = FONT_FAMILY["Lilita One"];
const titleFontSize = FONT_SIZES.xl;
const titleLineHeight = getLineHeight(titleFontFamily);
const titleFontString = getFontString({
fontFamily: titleFontFamily,
fontSize: titleFontSize,
});
const titleText = spreadsheet.title
? getRadarDisplayText(
spreadsheet.title,
titleFontString,
chartWidth + RADAR_LABEL_OFFSET * 2,
)
: null;
const titleTextMetrics = titleText
? measureText(titleText, titleFontString, titleLineHeight)
: null;
const title = titleText
? newTextElement({
backgroundColor,
...commonProps,
text: titleText,
originalText: spreadsheet.title ?? titleText,
x: x + chartWidth / 2,
y: axisLabelTopY - RADAR_LABEL_OFFSET - titleTextMetrics!.height / 2,
fontFamily: titleFontFamily,
fontSize: titleFontSize,
lineHeight: titleLineHeight,
textAlign: "center",
})
: null;
const radarGridLines = renderSteps
? Array.from({ length: RADAR_GRID_LEVELS }, (_, levelIndex) => {
const levelRatio = (levelIndex + 1) / RADAR_GRID_LEVELS;
const levelRadius = radius * levelRatio;
const points = angles.map((angle) =>
pointFrom<LocalPoint>(
Math.cos(angle) * levelRadius,
Math.sin(angle) * levelRadius,
),
);
points.push(pointFrom(points[0][0], points[0][1]));
return newLinearElement({
backgroundColor: "transparent",
...commonProps,
type: "line",
x: centerX,
y: centerY,
width: levelRadius * 2,
height: levelRadius * 2,
strokeStyle: "solid",
roughness: ROUGHNESS.architect,
opacity: GRID_OPACITY,
polygon: true,
points,
});
})
: [];
const spokes = angles.map((angle) => {
const px = Math.cos(angle) * radius;
const py = Math.sin(angle) * radius;
return newLinearElement({
backgroundColor: "transparent",
...commonProps,
type: "line",
x: centerX,
y: centerY,
width: Math.abs(px),
height: Math.abs(py),
strokeStyle: "solid",
roughness: ROUGHNESS.architect,
opacity: GRID_OPACITY,
points: [pointFrom(0, 0), pointFrom(px, py)],
});
});
const seriesPolygons = series.map((seriesData, index) => {
const points = angles.map((angle, axisIndex) => {
const value = seriesData.values[axisIndex] ?? 0;
const pointRadius = normalize(value, axisIndex) * radius;
return pointFrom<LocalPoint>(
Math.cos(angle) * pointRadius,
Math.sin(angle) * pointRadius,
);
});
points.push(pointFrom(points[0][0], points[0][1]));
return newLinearElement({
backgroundColor: "transparent",
...commonProps,
type: "line",
x: centerX,
y: centerY,
width: radius * 2,
height: radius * 2,
strokeColor: seriesColors[index],
strokeWidth: 2,
polygon: true,
points,
});
});
const seriesLegend = createSeriesLegend(
series,
seriesColors,
centerX,
axisLabelBottomY,
y + BAR_GAP * 5,
backgroundColor,
);
return [
...(title ? [title] : []),
...axisLabels,
...radarGridLines,
...spokes,
...seriesPolygons,
...seriesLegend,
];
};
@@ -0,0 +1,18 @@
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
export type ChartElements = readonly NonDeletedExcalidrawElement[];
export interface Spreadsheet {
title: string | null;
labels: string[] | null;
series: SpreadsheetSeries[];
}
export interface SpreadsheetSeries {
title: string | null;
values: number[];
}
export type ParseSpreadsheetResult =
| { ok: false; reason: string }
| { ok: true; data: Spreadsheet };
+38
View File
@@ -0,0 +1,38 @@
import type { ChartType } from "@excalidraw/element/types";
import { renderBarChart } from "./charts.bar";
import { renderLineChart } from "./charts.line";
import {
tryParseCells,
tryParseNumber,
tryParseSpreadsheet,
} from "./charts.parse";
import { renderRadarChart } from "./charts.radar";
import type { ChartElements, Spreadsheet } from "./charts.types";
export {
type ParseSpreadsheetResult,
type Spreadsheet,
type SpreadsheetSeries,
type ChartElements,
} from "./charts.types";
export { isSpreadsheetValidForChartType } from "./charts.helpers";
export { tryParseCells, tryParseNumber, tryParseSpreadsheet };
export const renderSpreadsheet = (
chartType: ChartType,
spreadsheet: Spreadsheet,
x: number,
y: number,
colorSeed?: number,
): ChartElements | null => {
if (chartType === "line") {
return renderLineChart(spreadsheet, x, y, colorSeed);
}
if (chartType === "radar") {
return renderRadarChart(spreadsheet, x, y, colorSeed);
}
return renderBarChart(spreadsheet, x, y, colorSeed);
};
-63
View File
@@ -155,67 +155,4 @@ describe("parseClipboard()", () => {
},
]);
});
it("should parse spreadsheet from either text/plain and text/html", async () => {
let clipboardData;
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/plain": `a b
1 2
4 5
7 10`,
},
}),
),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",
labels: ["1", "4", "7"],
values: [2, 5, 10],
});
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/html": `a b
1 2
4 5
7 10`,
},
}),
),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",
labels: ["1", "4", "7"],
values: [2, 5, 10],
});
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/html": `<html>
<body>
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;a&quot;}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;b&quot;}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:10}">10</td></tr></tbody></table><!--EndFragment-->
</body>
</html>`,
"text/plain": `a b
1 2
4 5
7 10`,
},
}),
),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",
labels: ["1", "4", "7"],
values: [2, 5, 10],
});
});
});
+2 -32
View File
@@ -33,12 +33,6 @@ import {
normalizeFile,
} from "./data/blob";
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
import type { FileSystemHandle } from "./data/filesystem";
import type { Spreadsheet } from "./charts";
import type { BinaryFiles } from "./types";
type ElementsClipboard = {
@@ -50,7 +44,6 @@ type ElementsClipboard = {
export type PastedMixedContent = { type: "text" | "imageUrl"; value: string }[];
export interface ClipboardData {
spreadsheet?: Spreadsheet;
elements?: readonly ExcalidrawElement[];
files?: BinaryFiles;
text?: string;
@@ -215,16 +208,6 @@ export const copyToClipboard = async (
);
};
const parsePotentialSpreadsheet = (
text: string,
): { spreadsheet: Spreadsheet } | { errorMessage: string } | null => {
const result = tryParseSpreadsheet(text);
if (result.type === VALID_SPREADSHEET) {
return { spreadsheet: result.spreadsheet };
}
return null;
};
/** internal, specific to parsing paste events. Do not reuse. */
function parseHTMLTree(el: ChildNode) {
let result: PastedMixedContent = [];
@@ -384,7 +367,7 @@ type AllowedParsedDataTransferItem =
type: ValueOf<typeof IMAGE_MIME_TYPES>;
kind: "file";
file: File;
fileHandle: FileSystemHandle | null;
fileHandle: FileSystemFileHandle | null;
}
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
@@ -393,7 +376,7 @@ type ParsedDataTransferItem =
type: string;
kind: "file";
file: File;
fileHandle: FileSystemHandle | null;
fileHandle: FileSystemFileHandle | null;
}
| { type: string; kind: "string"; value: string };
@@ -551,19 +534,6 @@ export const parseClipboard = async (
};
}
try {
// if system clipboard contains spreadsheet, use it even though it's
// technically possible it's staler than in-app clipboard
const spreadsheetResult =
!isPlainPaste && parsePotentialSpreadsheet(parsedEventData.value);
if (spreadsheetResult) {
return spreadsheetResult;
}
} catch (error: any) {
console.error(error);
}
try {
const systemClipboardData = JSON.parse(parsedEventData.value);
const programmaticAPI =
+7 -3
View File
@@ -226,7 +226,7 @@ export const SelectedShapeActions = ({
{(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) && (
<>
{renderAction("changeFontFamily")}
<fieldset>{renderAction("changeFontFamily")}</fieldset>
{renderAction("changeFontSize")}
{(appState.activeTool.type === "text" ||
suppportsHorizontalAlign(targetElements, elementsMap)) &&
@@ -1081,8 +1081,9 @@ export const ShapesSwitcher = ({
return (
<>
{getToolbarTools(app).map(
({ value, icon, key, numericKey, fillable }, index) => {
({ value, icon, key, numericKey, fillable, toolbar }) => {
if (
toolbar === false ||
UIOptions.tools?.[
value as Extract<
typeof value,
@@ -1099,6 +1100,9 @@ export const ShapesSwitcher = ({
const shortcut = letter
? `${letter} ${t("helpDialog.or")} ${numericKey}`
: `${numericKey}`;
const keybindingLabel =
value === "hand" ? undefined : numericKey || letter;
// when in compact styles panel mode (tablet)
// use a ToolPopover for selection/lasso toggle as well
if (
@@ -1143,7 +1147,7 @@ export const ShapesSwitcher = ({
checked={activeTool.type === value}
name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={numericKey || letter}
keyBindingLabel={keybindingLabel}
aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,208 @@
import type { AppState, UnsubscribeCallback } from "../types";
type StateChangeSelector =
| keyof AppState
| (keyof AppState)[]
| ((appState: AppState) => unknown);
type StateChangePredicateOptions = {
predicate: (appState: AppState) => boolean;
callback?: (appState: AppState) => void;
once?: boolean;
};
type StateChangeArg = StateChangeSelector | StateChangePredicateOptions;
type StateChangeListener = {
predicate: (appState: AppState, prevState: AppState) => boolean;
getValue: (appState: AppState) => unknown;
callback: (value: any, appState: AppState) => void;
once: boolean;
};
type NormalizedStateChange = {
predicate: StateChangeListener["predicate"];
getValue: StateChangeListener["getValue"];
callback?: StateChangeListener["callback"];
once: boolean;
matchesImmediately: boolean;
};
export type OnStateChange = {
<K extends keyof AppState>(
prop: K,
callback: (value: AppState[K], appState: AppState) => void,
opts?: { once: boolean },
): UnsubscribeCallback;
<K extends keyof AppState>(prop: K): Promise<AppState[K]>;
(
prop: (keyof AppState)[],
callback: (appState: AppState, appState2: AppState) => void,
opts?: { once: boolean },
): UnsubscribeCallback;
(prop: (keyof AppState)[]): Promise<AppState>;
<T>(
prop: (appState: AppState) => T,
callback: (value: T, appState: AppState) => void,
opts?: { once: boolean },
): UnsubscribeCallback;
<T>(prop: (appState: AppState) => T): Promise<T>;
(opts: {
predicate: (appState: AppState) => boolean;
callback: (appState: AppState) => void;
once?: boolean;
}): UnsubscribeCallback;
(opts: { predicate: (appState: AppState) => boolean }): Promise<AppState>;
(
selector: StateChangeSelector,
callback: (value: any, appState: AppState) => void,
): any;
};
export class AppStateObserver {
private listeners: StateChangeListener[] = [];
constructor(private readonly getState: () => AppState) {}
private isStateChangePredicateOptions(
propOrOpts: StateChangeArg,
): propOrOpts is StateChangePredicateOptions {
return (
typeof propOrOpts === "object" &&
!Array.isArray(propOrOpts) &&
"predicate" in propOrOpts
);
}
private subscribe(listener: StateChangeListener): UnsubscribeCallback {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(
(existingListener) => existingListener !== listener,
);
};
}
private normalize(
propOrOpts: StateChangeArg,
callback?: (value: any, appState: AppState) => void,
opts?: { once: boolean },
): NormalizedStateChange {
let predicate: StateChangeListener["predicate"];
let getValue: StateChangeListener["getValue"];
let normalizedCallback = callback;
let once = opts?.once ?? false;
let matchesImmediately = false;
if (this.isStateChangePredicateOptions(propOrOpts)) {
const {
predicate: predicateFn,
callback: callbackFromOpts,
once: onceFromOpts,
} = propOrOpts;
predicate = predicateFn;
getValue = (appState: AppState) => appState;
normalizedCallback = callbackFromOpts
? (_value: AppState, appState: AppState) => callbackFromOpts(appState)
: undefined;
once = onceFromOpts ?? false;
matchesImmediately = predicateFn(this.getState());
} else if (typeof propOrOpts === "function") {
const selector = propOrOpts;
predicate = (appState: AppState, prevState: AppState) =>
selector(appState) !== selector(prevState);
getValue = (appState: AppState) => selector(appState);
} else if (Array.isArray(propOrOpts)) {
const keys = propOrOpts;
predicate = (appState: AppState, prevState: AppState) =>
keys.some((key) => appState[key] !== prevState[key]);
getValue = (appState: AppState) => appState;
} else {
const key = propOrOpts;
predicate = (appState: AppState, prevState: AppState) =>
appState[key] !== prevState[key];
getValue = (appState: AppState) => appState[key];
}
return {
predicate,
getValue,
callback: normalizedCallback,
once,
matchesImmediately,
};
}
public onStateChange: OnStateChange = ((
propOrOpts: StateChangeArg,
callback?: any,
opts?: { once: boolean },
) => {
const {
predicate,
getValue,
callback: stateChangeCallback,
once,
matchesImmediately,
} = this.normalize(propOrOpts, callback, opts);
if (stateChangeCallback) {
if (matchesImmediately) {
queueMicrotask(() => {
const state = this.getState();
stateChangeCallback(getValue(state), state);
});
if (once) {
return () => {};
}
}
return this.subscribe({
predicate,
getValue,
callback: stateChangeCallback,
once,
});
}
if (matchesImmediately) {
return Promise.resolve(getValue(this.getState()));
}
return new Promise<any>((resolve) => {
this.subscribe({
predicate,
getValue,
callback: (value) => resolve(value),
once: true,
});
});
}) as OnStateChange;
public flush(prevState: AppState) {
if (!this.listeners.length) {
return;
}
const state = this.getState();
const listenersToKeep: StateChangeListener[] = [];
for (const listener of this.listeners) {
if (listener.predicate(state, prevState)) {
listener.callback(listener.getValue(state), state);
if (!listener.once) {
listenersToKeep.push(listener);
}
} else {
listenersToKeep.push(listener);
}
}
this.listeners = listenersToKeep;
}
public clear() {
this.listeners = [];
}
}
@@ -10,12 +10,11 @@ import {
isWritableElement,
} from "@excalidraw/common";
import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch";
import { getShortcutKey } from "@excalidraw/excalidraw/shortcut";
import type { MarkRequired } from "@excalidraw/common/utility-types";
import { actionToggleShapeSwitch } from "../../actions/actionToggleShapeSwitch";
import { getShortcutKey } from "../../shortcut";
import {
actionClearCanvas,
actionLink,
@@ -15,7 +15,7 @@ export type CommandPaletteItem = {
category: string;
order?: number;
predicate?: boolean | Action["predicate"];
shortcut?: string;
shortcut?: string | null;
/** if false, command will not show while in view mode */
viewMode?: boolean;
perform: (data: {
@@ -1,7 +1,9 @@
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import {
bumpVersion,
getLinearElementSubType,
mutateElement,
updateElbowArrowPoints,
} from "@excalidraw/element";
@@ -37,6 +39,8 @@ import {
isProdEnv,
mapFind,
reduceToCommonValue,
ROUNDNESS,
sceneCoordsToViewportCoords,
updateActiveTool,
} from "@excalidraw/common";
@@ -71,12 +75,6 @@ import type {
import type { Scene } from "@excalidraw/element";
import {
bumpVersion,
mutateElement,
ROUNDNESS,
sceneCoordsToViewportCoords,
} from "..";
import { trackEvent } from "../analytics";
import { atom } from "../editor-jotai";
+15 -3
View File
@@ -6,14 +6,20 @@
padding: 0.5rem;
background: var(--popup-bg-color);
border: 0 solid color.adjust(#fff, $alpha: -0.75);
box-shadow: var(--shadow-island);
box-shadow: var(--shadow-island-stronger);
border-radius: 4px;
position: absolute;
:root[dir="rtl"] & {
padding: 0.4rem;
}
}
.picker-sections,
.picker-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.picker-container button,
.picker button {
position: relative;
@@ -62,7 +68,13 @@
.picker-collapsible {
font-size: 0.75rem;
padding: 0.5rem 0;
padding: 0;
color: var(--text-primary-color);
}
.picker-section-label {
font-size: 0.75rem;
color: var(--text-primary-color);
}
.picker-keybinding {
+157 -67
View File
@@ -1,6 +1,6 @@
import { Popover } from "radix-ui";
import clsx from "clsx";
import React, { useEffect } from "react";
import React, { useEffect, useMemo } from "react";
import { isArrowKey, KEYS } from "@excalidraw/common";
@@ -8,13 +8,15 @@ import { atom, useAtom } from "../editor-jotai";
import { getLanguage, t } from "../i18n";
import Collapsible from "./Stats/Collapsible";
import { useEditorInterface, useExcalidrawContainer } from "./App";
import { useExcalidrawContainer } from "./App";
import "./IconPicker.scss";
import type { JSX } from "react";
const moreOptionsAtom = atom(false);
const PICKER_COLUMNS = 4;
const DEFAULT_SECTION_NAME = "default";
type Option<T> = {
value: T;
@@ -23,28 +25,73 @@ type Option<T> = {
keyBinding: string | null;
};
type PickerSection<T> = {
name: string;
options: readonly Option<T>[];
};
const flattenOptions = <T,>(sections: readonly PickerSection<T>[]) =>
sections.flatMap((section) => section.options);
const findOption = <T,>(
sections: readonly PickerSection<T>[],
predicate: (option: Option<T>) => boolean,
) => {
for (const section of sections) {
const option = section.options.find(predicate);
if (option) {
return option;
}
}
return null;
};
const hasOption = <T,>(
sections: readonly PickerSection<T>[],
predicate: (option: Option<T>) => boolean,
) => sections.some((section) => section.options.some(predicate));
const getNavigationRows = <T,>(sections: readonly PickerSection<T>[]) =>
sections.flatMap((section) =>
Array.from(
{ length: Math.ceil(section.options.length / PICKER_COLUMNS) },
(_, index) =>
section.options.slice(
index * PICKER_COLUMNS,
index * PICKER_COLUMNS + PICKER_COLUMNS,
),
),
);
function Picker<T>({
options,
visibleSections,
hiddenSections = [],
value,
label,
onChange,
onClose,
numberOfOptionsToAlwaysShow = options.length,
}: {
label: string;
value: T;
options: readonly Option<T>[];
visibleSections: readonly PickerSection<T>[];
hiddenSections?: readonly PickerSection<T>[];
onChange: (value: T) => void;
onClose: () => void;
numberOfOptionsToAlwaysShow?: number;
}) {
const editorInterface = useEditorInterface();
const { container } = useExcalidrawContainer();
const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
const allSections = [...visibleSections, ...hiddenSections];
const allOptions = flattenOptions(allSections);
const navigationRows = getNavigationRows([
...visibleSections,
...(showMoreOptions ? hiddenSections : []),
]);
const handleKeyDown = (event: React.KeyboardEvent) => {
const pressedOption = options.find(
const pressedOption = allOptions.find(
(option) => option.keyBinding === event.key.toLowerCase(),
)!;
);
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
// Keybinding navigation
@@ -52,17 +99,17 @@ function Picker<T>({
event.preventDefault();
} else if (event.key === KEYS.TAB) {
const index = options.findIndex((option) => option.value === value);
const index = allOptions.findIndex((option) => option.value === value);
const nextIndex = event.shiftKey
? (options.length + index - 1) % options.length
: (index + 1) % options.length;
onChange(options[nextIndex].value);
? (allOptions.length + index - 1) % allOptions.length
: (index + 1) % allOptions.length;
onChange(allOptions[nextIndex].value);
} else if (isArrowKey(event.key)) {
// Arrow navigation
const isRTL = getLanguage().rtl;
const index = options.findIndex((option) => option.value === value);
const index = allOptions.findIndex((option) => option.value === value);
if (index !== -1) {
const length = options.length;
const length = allOptions.length;
let nextIndex = index;
switch (event.key) {
@@ -76,18 +123,60 @@ function Picker<T>({
break;
// Go the next row
case KEYS.ARROW_DOWN: {
nextIndex = (index + (numberOfOptionsToAlwaysShow ?? 1)) % length;
const currentRowIndex = navigationRows.findIndex((row) =>
row.some((option) => option.value === value),
);
const currentRow = navigationRows[currentRowIndex];
if (currentRowIndex !== -1 && currentRow) {
const column = currentRow.findIndex(
(option) => option.value === value,
);
const nextRow =
navigationRows[(currentRowIndex + 1) % navigationRows.length];
const nextOption =
nextRow[Math.min(column, nextRow.length - 1)] ??
allOptions[index];
onChange(nextOption.value);
event.preventDefault();
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
return;
}
break;
}
// Go the previous row
case KEYS.ARROW_UP: {
nextIndex =
(length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length;
const currentRowIndex = navigationRows.findIndex((row) =>
row.some((option) => option.value === value),
);
const currentRow = navigationRows[currentRowIndex];
if (currentRowIndex !== -1 && currentRow) {
const column = currentRow.findIndex(
(option) => option.value === value,
);
const previousRow =
navigationRows[
(navigationRows.length + currentRowIndex - 1) %
navigationRows.length
];
const previousOption =
previousRow[Math.min(column, previousRow.length - 1)] ??
allOptions[index];
onChange(previousOption.value);
event.preventDefault();
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
return;
}
break;
}
}
onChange(options[nextIndex].value);
onChange(allOptions[nextIndex].value);
}
event.preventDefault();
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
@@ -99,38 +188,29 @@ function Picker<T>({
event.stopPropagation();
};
const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
const alwaysVisibleOptions = React.useMemo(
() => options.slice(0, numberOfOptionsToAlwaysShow),
[options, numberOfOptionsToAlwaysShow],
);
const moreOptions = React.useMemo(
() => options.slice(numberOfOptionsToAlwaysShow),
[options, numberOfOptionsToAlwaysShow],
);
useEffect(() => {
if (!alwaysVisibleOptions.some((option) => option.value === value)) {
if (hasOption(hiddenSections, (option) => option.value === value)) {
setShowMoreOptions(true);
}
}, [value, alwaysVisibleOptions, setShowMoreOptions]);
}, [value, hiddenSections, setShowMoreOptions]);
const renderOptions = (options: Option<T>[]) => {
const renderOptions = (options: readonly Option<T>[]) => {
return (
<div className="picker-content">
{options.map((option, i) => (
{options.map((option) => (
<button
type="button"
className={clsx("picker-option", {
active: value === option.value,
})}
onClick={(event) => {
onClick={() => {
onChange(option.value);
}}
title={`${option.text} ${
option.keyBinding && `${option.keyBinding.toUpperCase()}`
}`}
title={
option.keyBinding
? `${option.text}${option.keyBinding.toUpperCase()}`
: option.text
}
aria-label={option.text || "none"}
aria-keyshortcuts={option.keyBinding || undefined}
key={option.text}
@@ -153,26 +233,38 @@ function Picker<T>({
);
};
const isMobile = editorInterface.formFactor === "phone";
const renderSections = (sections: readonly PickerSection<T>[]) =>
sections.map((section, index) =>
section.name === DEFAULT_SECTION_NAME ? (
<React.Fragment key={`${section.name}-${index}`}>
{renderOptions(section.options)}
</React.Fragment>
) : (
<div className="picker-section" key={`${section.name}-${index}`}>
<div className="picker-section-label">{section.name}</div>
{renderOptions(section.options)}
</div>
),
);
return (
<Popover.Content
side={isMobile ? "right" : "bottom"}
className="picker"
role="dialog"
aria-modal="true"
aria-label={label}
side={"bottom"}
align="start"
sideOffset={isMobile ? 8 : 12}
sideOffset={12}
alignOffset={12}
style={{ zIndex: "var(--zIndex-ui-styles-popup)" }}
onKeyDown={handleKeyDown}
collisionBoundary={container ?? undefined}
>
<div
className={`picker`}
role="dialog"
aria-modal="true"
aria-label={label}
>
{renderOptions(alwaysVisibleOptions)}
<div className="picker-sections">
{renderSections(visibleSections)}
{moreOptions.length > 0 && (
{hiddenSections.length > 0 && (
<Collapsible
label={t("labels.more_options")}
open={showMoreOptions}
@@ -181,7 +273,9 @@ function Picker<T>({
}}
className="picker-collapsible"
>
{renderOptions(moreOptions)}
<div className="picker-sections">
{renderSections(hiddenSections)}
</div>
</Collapsible>
)}
</div>
@@ -192,49 +286,45 @@ function Picker<T>({
export function IconPicker<T>({
value,
label,
options,
visibleSections,
hiddenSections,
onChange,
group = "",
numberOfOptionsToAlwaysShow,
}: {
label: string;
value: T;
options: readonly {
value: T;
text: string;
icon: JSX.Element;
keyBinding: string | null;
}[];
visibleSections: readonly PickerSection<T>[];
hiddenSections?: readonly PickerSection<T>[];
onChange: (value: T) => void;
numberOfOptionsToAlwaysShow?: number;
group?: string;
}) {
const [isActive, setActive] = React.useState(false);
const rPickerButton = React.useRef<any>(null);
const selectedOption = useMemo(
() =>
findOption(visibleSections, (option) => option.value === value) ??
findOption(hiddenSections ?? [], (option) => option.value === value),
[visibleSections, hiddenSections, value],
);
return (
<div>
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
<Popover.Trigger
name={group}
type="button"
aria-label={label}
onClick={() => setActive(!isActive)}
ref={rPickerButton}
className={isActive ? "active" : ""}
>
{options.find((option) => option.value === value)?.icon}
{selectedOption?.icon}
</Popover.Trigger>
{isActive && (
<Picker
options={options}
visibleSections={visibleSections}
hiddenSections={hiddenSections}
value={value}
label={label}
onChange={onChange}
onClose={() => {
setActive(false);
}}
numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow}
/>
)}
</Popover.Root>
@@ -59,6 +59,7 @@ type ImageExportModalProps = {
actionManager: ActionManager;
onExportImage: AppClassProperties["onExportImage"];
name: string;
exportWithDarkMode: boolean;
};
const ImageExportModal = ({
@@ -68,6 +69,7 @@ const ImageExportModal = ({
actionManager,
onExportImage,
name,
exportWithDarkMode,
}: ImageExportModalProps) => {
const hasSelection = isSomeElementSelected(
elementsSnapshot,
@@ -79,15 +81,13 @@ const ImageExportModal = ({
const [exportWithBackground, setExportWithBackground] = useState(
appStateSnapshot.exportBackground,
);
const [exportDarkMode, setExportDarkMode] = useState(
appStateSnapshot.exportWithDarkMode,
);
const [embedScene, setEmbedScene] = useState(
appStateSnapshot.exportEmbedScene,
);
const [exportScale, setExportScale] = useState(appStateSnapshot.exportScale);
const previewRef = useRef<HTMLDivElement>(null);
const previewRenderRequestIdRef = useRef(0);
const [renderError, setRenderError] = useState<Error | null>(null);
const { onCopy, copyStatus, resetCopyStatus } = useCopyStatus();
@@ -99,7 +99,7 @@ const ImageExportModal = ({
}, [
projectName,
exportWithBackground,
exportDarkMode,
exportWithDarkMode,
exportScale,
embedScene,
resetCopyStatus,
@@ -122,13 +122,18 @@ const ImageExportModal = ({
return;
}
const requestId = ++previewRenderRequestIdRef.current;
const isStaleRequest = () => {
return requestId !== previewRenderRequestIdRef.current;
};
exportToCanvas({
elements: exportedElements,
appState: {
...appStateSnapshot,
name: projectName,
exportBackground: exportWithBackground,
exportWithDarkMode: exportDarkMode,
exportWithDarkMode,
exportScale,
exportEmbedScene: embedScene,
},
@@ -137,25 +142,41 @@ const ImageExportModal = ({
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
})
.then((canvas) => {
.then(async (canvas) => {
if (isStaleRequest()) {
return;
}
// If converting to blob fails, there's some problem that will likely
// prevent preview and export (e.g. canvas too big).
try {
await canvasToBlob(canvas);
} catch (error: any) {
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
throw new Error(t("canvasError.canvasTooBig"));
}
throw error;
}
if (isStaleRequest()) {
return;
}
setRenderError(null);
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
return canvasToBlob(canvas)
.then(() => {
previewNode.replaceChildren(canvas);
})
.catch((e) => {
if (e.name === "CANVAS_POSSIBLY_TOO_BIG") {
throw new Error(t("canvasError.canvasTooBig"));
}
throw e;
});
previewNode.replaceChildren(canvas);
})
.catch((error) => {
if (isStaleRequest()) {
return;
}
console.error(error);
setRenderError(error);
});
return () => {
previewRenderRequestIdRef.current += 1;
};
}, [
appStateSnapshot,
files,
@@ -163,7 +184,7 @@ const ImageExportModal = ({
exportingFrame,
projectName,
exportWithBackground,
exportDarkMode,
exportWithDarkMode,
exportScale,
embedScene,
]);
@@ -233,9 +254,8 @@ const ImageExportModal = ({
>
<Switch
name="exportDarkModeSwitch"
checked={exportDarkMode}
checked={exportWithDarkMode}
onChange={(checked) => {
setExportDarkMode(checked);
actionManager.executeAction(
actionExportWithDarkMode,
"ui",
@@ -399,6 +419,7 @@ export const ImageExportDialog = ({
actionManager={actionManager}
onExportImage={onExportImage}
name={name}
exportWithDarkMode={appState.exportWithDarkMode}
/>
</Dialog>
);
+29 -25
View File
@@ -20,7 +20,6 @@ import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { actionToggleStats } from "../actions";
import { trackEvent } from "../analytics";
import { isHandToolActive } from "../appState";
import { TunnelsContext, useInitializeTunnels } from "../context/tunnels";
import { UIAppStateContext } from "../context/ui-appState";
import { useAtom, useAtomValue } from "../editor-jotai";
@@ -55,13 +54,13 @@ import ElementLinkDialog from "./ElementLinkDialog";
import { ErrorDialog } from "./ErrorDialog";
import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
import { FixedSideContainer } from "./FixedSideContainer";
import { HandButton } from "./HandButton";
import { HelpDialog } from "./HelpDialog";
import { HintViewer } from "./HintViewer";
import { ImageExportDialog } from "./ImageExportDialog";
import { Island } from "./Island";
import { JSONExportDialog } from "./JSONExportDialog";
import { LaserPointerButton } from "./LaserPointerButton";
import { Toast } from "./Toast";
import "./LayerUI.scss";
import "./Toolbar.scss";
@@ -359,13 +358,6 @@ const LayerUI = ({
<div className="App-toolbar__divider" />
<HandButton
checked={isHandToolActive(appState)}
onChange={() => onHandToolToggle()}
title={t("toolBar.hand")}
isMobile
/>
<ShapesSwitcher
setAppState={setAppState}
activeTool={appState.activeTool}
@@ -565,13 +557,13 @@ const LayerUI = ({
<tunnels.OverwriteConfirmDialogTunnel.Out />
{renderImageExportDialog()}
{renderJSONExportDialog()}
{appState.pasteDialog.shown && (
{appState.openDialog?.name === "charts" && (
<PasteChartDialog
setAppState={setAppState}
appState={appState}
data={appState.openDialog.data}
rawText={appState.openDialog.rawText}
onClose={() =>
setAppState({
pasteDialog: { shown: false, data: null },
openDialog: null,
})
}
/>
@@ -614,18 +606,30 @@ const LayerUI = ({
showExitZenModeBtn={showExitZenModeBtn}
renderWelcomeScreen={renderWelcomeScreen}
/>
{appState.scrolledOutside && (
<button
type="button"
className="scroll-back-to-content"
onClick={() => {
setAppState((appState) => ({
...calculateScrollCenter(elements, appState),
}));
}}
>
{t("buttons.scrollBackToContent")}
</button>
{(appState.toast || appState.scrolledOutside) && (
<div className="floating-status-stack">
{appState.toast && (
<Toast
message={appState.toast.message}
onClose={() => setAppState({ toast: null })}
duration={appState.toast.duration}
closable={appState.toast.closable}
/>
)}
{!appState.toast && appState.scrolledOutside && (
<button
type="button"
className="scroll-back-to-content"
onClick={() => {
setAppState((appState) => ({
...calculateScrollCenter(elements, appState),
}));
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</div>
)}
</div>
{renderSidebars()}
@@ -472,9 +472,9 @@ export const MobileToolBar = ({
onSelect={() => app.onMagicframeToolSelect()}
icon={MagicIcon}
data-testid="toolbar-magicframe"
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
>
{t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item>
</>
)}
@@ -2,6 +2,40 @@
.excalidraw {
.PasteChartDialog {
.PasteChartDialog__title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.PasteChartDialog__titleText {
min-width: 0;
}
.PasteChartDialog__reshuffleBtn {
margin-left: auto;
flex: 0 0 auto;
width: 1rem;
height: 1rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
cursor: pointer;
color: var(--text-primary-color);
transition: transform 120ms ease, background-color 120ms ease,
color 120ms ease;
user-select: none;
&:hover {
color: $color-blue-6;
}
&:active {
transform: scale(0.94);
}
}
@include isMobile {
.Island {
display: flex;
@@ -11,35 +45,61 @@
.container {
display: flex;
align-items: center;
justify-content: space-around;
justify-content: center;
flex-wrap: wrap;
gap: 1rem;
@include isMobile {
flex-direction: column;
justify-content: center;
align-items: stretch;
}
}
.ChartPreview {
margin: 8px;
text-align: center;
width: 192px;
height: 128px;
border-radius: 2px;
padding: 1px;
width: 260px;
min-height: 190px;
border-radius: 8px;
padding: 10px;
border: 1px solid $color-gray-4;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
gap: 10px;
background: transparent;
div {
display: inline-block;
.ChartPreview__canvas {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
overflow: hidden;
}
.ChartPreview__label {
font-size: 0.875rem;
font-weight: 600;
line-height: 1;
text-align: center;
color: var(--text-primary-color);
}
svg {
max-height: 120px;
max-width: 186px;
max-height: 144px;
max-width: 100%;
}
&:hover {
padding: 0;
border: 2px solid $color-blue-5;
border-color: $color-blue-5;
}
&:active {
border-color: $color-blue-5;
box-shadow: 0 0 0 1px $color-blue-5;
transform: scale(0.98);
}
&:focus-visible {
border-color: $color-blue-5;
box-shadow: 0 0 0 1px $color-blue-5;
}
@include isMobile {
width: 100%;
min-height: 200px;
}
}
}
@@ -1,35 +1,57 @@
import React, { useLayoutEffect, useRef, useState } from "react";
import { newTextElement } from "@excalidraw/element";
import type { ChartType } from "@excalidraw/element/types";
import { trackEvent } from "../analytics";
import { renderSpreadsheet } from "../charts";
import { isSpreadsheetValidForChartType, renderSpreadsheet } from "../charts";
import { t } from "../i18n";
import { exportToSvg } from "../scene/export";
import { useUIAppState } from "../context/ui-appState";
import { useApp } from "./App";
import { Dialog } from "./Dialog";
import "./PasteChartDialog.scss";
import { bucketFillIcon } from "./icons";
import type { ChartElements, Spreadsheet } from "../charts";
import type { UIAppState } from "../types";
type OnPlainTextPaste = (rawText: string) => void;
type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void;
const getChartTypeLabel = (chartType: ChartType) => {
switch (chartType) {
case "bar":
return t("labels.chartType_bar");
case "line":
return t("labels.chartType_line");
case "radar":
return t("labels.chartType_radar");
default:
return chartType;
}
};
const ChartPreviewBtn = (props: {
spreadsheet: Spreadsheet | null;
chartType: ChartType;
selected: boolean;
colorSeed: number;
onClick: OnInsertChart;
}) => {
const previewRef = useRef<HTMLDivElement | null>(null);
const [chartElements, setChartElements] = useState<ChartElements | null>(
null,
);
const { theme } = useUIAppState();
useLayoutEffect(() => {
if (!props.spreadsheet) {
setChartElements(null);
return;
}
@@ -38,7 +60,13 @@ const ChartPreviewBtn = (props: {
props.spreadsheet,
0,
0,
props.colorSeed,
);
if (!elements) {
setChartElements(null);
previewRef.current?.replaceChildren();
return;
}
setChartElements(elements);
let svg: SVGSVGElement;
const previewNode = previewRef.current!;
@@ -49,6 +77,7 @@ const ChartPreviewBtn = (props: {
{
exportBackground: false,
viewBackgroundColor: "#fff",
exportWithDarkMode: theme === "dark",
},
null, // files
{
@@ -58,42 +87,108 @@ const ChartPreviewBtn = (props: {
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren();
previewNode.appendChild(svg);
if (props.selected) {
(previewNode.parentNode as HTMLDivElement).focus();
}
})();
return () => {
previewNode.replaceChildren();
};
}, [props.spreadsheet, props.chartType, props.selected]);
}, [props.spreadsheet, props.chartType, props.colorSeed, theme]);
const chartTypeLabel = getChartTypeLabel(props.chartType);
return (
<button
type="button"
className="ChartPreview"
aria-label={chartTypeLabel}
onClick={() => {
if (chartElements) {
props.onClick(props.chartType, chartElements);
}
}}
>
<div ref={previewRef} />
<div className="ChartPreview__canvas" ref={previewRef} />
<div className="ChartPreview__label">{chartTypeLabel}</div>
</button>
);
};
const PlainTextPreviewBtn = (props: {
rawText: string;
onClick: OnPlainTextPaste;
}) => {
const previewRef = useRef<HTMLDivElement | null>(null);
const { theme } = useUIAppState();
useLayoutEffect(() => {
if (!props.rawText) {
return;
}
const textElement = newTextElement({
text: props.rawText,
x: 0,
y: 0,
});
const previewNode = previewRef.current!;
(async () => {
const svg = await exportToSvg(
[textElement],
{
exportBackground: false,
viewBackgroundColor: "#fff",
exportWithDarkMode: theme === "dark",
},
null,
{
skipInliningFonts: true,
},
);
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren();
previewNode.appendChild(svg);
})();
return () => {
previewNode.replaceChildren();
};
}, [props.rawText, theme]);
return (
<button
type="button"
className="ChartPreview"
aria-label={t("labels.chartType_plaintext")}
onClick={() => {
props.onClick(props.rawText);
}}
>
<div className="ChartPreview__canvas" ref={previewRef} />
<div className="ChartPreview__label">
{t("labels.chartType_plaintext")}
</div>
</button>
);
};
export const PasteChartDialog = ({
setAppState,
appState,
data,
rawText,
onClose,
}: {
appState: UIAppState;
data: Spreadsheet;
rawText: string;
onClose: () => void;
setAppState: React.Component<any, UIAppState>["setState"];
}) => {
const { onInsertElements } = useApp();
const { onInsertElements, focusContainer } = useApp();
const [colorSeed, setColorSeed] = useState(Math.random());
const handleReshuffleColors = React.useCallback(() => {
setColorSeed(Math.random());
}, []);
const handleClose = React.useCallback(() => {
if (onClose) {
onClose();
@@ -103,36 +198,72 @@ export const PasteChartDialog = ({
const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
onInsertElements(elements);
trackEvent("paste", "chart", chartType);
setAppState({
currentChartType: chartType,
pasteDialog: {
shown: false,
data: null,
},
onClose();
focusContainer();
};
const handlePlainTextClick = (rawText: string) => {
const textElement = newTextElement({
text: rawText,
x: 0,
y: 0,
});
onInsertElements([textElement]);
trackEvent("paste", "chart", "plaintext");
onClose();
focusContainer();
};
return (
<Dialog
size="small"
size="regular"
onCloseRequest={handleClose}
title={t("labels.pasteCharts")}
title={
<div className="PasteChartDialog__title">
<div className="PasteChartDialog__titleText">
{t("labels.pasteCharts")}
</div>
<div
className="PasteChartDialog__reshuffleBtn"
onClick={handleReshuffleColors}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleReshuffleColors();
}
}}
>
{bucketFillIcon}
</div>
</div>
}
className={"PasteChartDialog"}
autofocus={false}
>
<div className={"container"}>
<ChartPreviewBtn
chartType="bar"
spreadsheet={appState.pasteDialog.data}
selected={appState.currentChartType === "bar"}
onClick={handleChartClick}
/>
<ChartPreviewBtn
chartType="line"
spreadsheet={appState.pasteDialog.data}
selected={appState.currentChartType === "line"}
onClick={handleChartClick}
/>
{(["bar", "line", "radar"] as const).map((chartType) => {
if (!isSpreadsheetValidForChartType(data, chartType)) {
return null;
}
return (
<ChartPreviewBtn
key={chartType}
chartType={chartType}
spreadsheet={data}
colorSeed={colorSeed}
onClick={handleChartClick}
/>
);
})}
{rawText && (
<PlainTextPreviewBtn
rawText={rawText}
onClick={handlePlainTextClick}
/>
)}
</div>
</Dialog>
);
+37 -33
View File
@@ -1,74 +1,78 @@
import React, { useEffect } from "react";
import { t } from "../i18n";
import "./Range.scss";
import type { AppClassProperties } from "../types";
export type RangeProps = {
updateData: (value: number) => void;
app: AppClassProperties;
label: React.ReactNode;
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
minLabel?: React.ReactNode;
hasCommonValue?: boolean;
testId?: string;
};
export const Range = ({ updateData, app, testId }: RangeProps) => {
export const Range = ({
label,
value,
onChange,
min = 0,
max = 100,
step = 10,
minLabel = min,
hasCommonValue = true,
testId,
}: RangeProps) => {
const rangeRef = React.useRef<HTMLInputElement>(null);
const valueRef = React.useRef<HTMLDivElement>(null);
const selectedElements = app.scene.getSelectedElements(app.state);
let hasCommonOpacity = true;
const firstElement = selectedElements.at(0);
const leastCommonOpacity = selectedElements.reduce((acc, element) => {
if (acc != null && acc !== element.opacity) {
hasCommonOpacity = false;
}
if (acc == null || acc > element.opacity) {
return element.opacity;
}
return acc;
}, firstElement?.opacity ?? null);
const value = leastCommonOpacity ?? app.state.currentItemOpacity;
useEffect(() => {
if (rangeRef.current && valueRef.current) {
const rangeElement = rangeRef.current;
const valueElement = valueRef.current;
const inputWidth = rangeElement.offsetWidth;
const thumbWidth = 15; // 15 is the width of the thumb
const thumbWidth =
parseFloat(
getComputedStyle(rangeElement).getPropertyValue(
"--slider-thumb-size",
),
) || 16;
const progress = ((value - min) / (max - min || 1)) * 100;
const position =
(value / 100) * (inputWidth - thumbWidth) + thumbWidth / 2;
(progress / 100) * (inputWidth - thumbWidth) + thumbWidth / 2;
valueElement.style.left = `${position}px`;
rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${value}%, var(--button-bg) ${value}%, var(--button-bg) 100%)`;
rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${progress}%, var(--button-bg) ${progress}%, var(--button-bg) 100%)`;
}
}, [value]);
}, [max, min, value]);
return (
<label className="control-label">
{t("labels.opacity")}
{label}
<div className="range-wrapper">
<input
style={{
["--color-slider-track" as string]: hasCommonOpacity
["--color-slider-track" as string]: hasCommonValue
? undefined
: "var(--button-bg)",
}}
ref={rangeRef}
type="range"
min="0"
max="100"
step="10"
min={min}
max={max}
step={step}
onChange={(event) => {
updateData(+event.target.value);
onChange(+event.target.value);
}}
value={value}
className="range-input"
data-testid={testId}
/>
<div className="value-bubble" ref={valueRef}>
{value !== 0 ? value : null}
{value !== min ? value : null}
</div>
<div className="zero-label">0</div>
<div className="zero-label">{minLabel}</div>
</div>
</label>
);
@@ -361,12 +361,10 @@ describe("stats for a non-generic element", () => {
mouse.clickAt(20, 30);
const editor = await getTextEditor();
updateTextEditor(editor, "Hello!");
act(() => {
editor.blur();
});
Keyboard.exitTextEditor(editor);
const text = h.elements[0] as ExcalidrawTextElement;
mouse.clickOn(text);
API.setSelectedElements([text]);
elementStats = stats?.querySelector("#elementStats");
@@ -3,7 +3,25 @@
$verticalBreakpoint: 861px;
.excalidraw {
--ttd-mermaid-token-keyword: #0000ff;
--ttd-mermaid-token-string: #a31515;
--ttd-mermaid-token-comment: #008000;
--ttd-mermaid-token-number: #098658;
--ttd-mermaid-token-operator: #1e1e1e;
--ttd-mermaid-token-punctuation: #1e1e1e;
--ttd-mermaid-token-variable-name: #001080;
--ttd-mermaid-token-bracket: #af00db;
&.theme--dark {
--ttd-mermaid-token-keyword: #569cd6;
--ttd-mermaid-token-string: #ce9178;
--ttd-mermaid-token-comment: #6a9955;
--ttd-mermaid-token-number: #b5cea8;
--ttd-mermaid-token-operator: #d4d4d4;
--ttd-mermaid-token-punctuation: #d4d4d4;
--ttd-mermaid-token-variable-name: #9cdcfe;
--ttd-mermaid-token-bracket: #ffd700;
.chat-message {
&--assistant {
.chat-message__content {
@@ -194,7 +212,7 @@ $verticalBreakpoint: 861px;
align-items: flex-start;
.chat-message__content {
background: var(--color-surface-low);
background: #f7f7f7;
color: var(--color-on-surface);
border-radius: var(--border-radius-md);
min-width: 6rem;
@@ -292,6 +310,51 @@ $verticalBreakpoint: 861px;
word-wrap: break-word;
}
&__text--error {
color: inherit;
}
&__text--mermaid {
overflow-x: auto;
font-weight: 400;
}
&__token {
white-space: inherit;
}
&__token--keyword {
color: var(--ttd-mermaid-token-keyword);
}
&__token--string {
color: var(--ttd-mermaid-token-string);
}
&__token--comment {
color: var(--ttd-mermaid-token-comment);
}
&__token--number {
color: var(--ttd-mermaid-token-number);
}
&__token--operator {
color: var(--ttd-mermaid-token-operator);
}
&__token--punctuation {
color: var(--ttd-mermaid-token-punctuation);
}
&__token--variableName {
color: var(--ttd-mermaid-token-variable-name);
}
&__token--bracket {
color: var(--ttd-mermaid-token-bracket);
}
&__cursor {
display: inline-block;
margin-left: 2px;
@@ -332,11 +395,13 @@ $verticalBreakpoint: 861px;
&__error {
color: var(--color-danger);
font-weight: 500;
white-space: pre-wrap;
word-wrap: break-word;
display: flex;
flex-direction: column;
gap: 0.5rem;
.chat-message__text--mermaid {
color: var(--color-on-surface);
}
}
&__error_message {
@@ -5,8 +5,49 @@ import { t } from "../../../i18n";
import { FilledButton } from "../../FilledButton";
import { TrashIcon, codeIcon, stackPushIcon, RetryIcon } from "../../icons";
import { tokenizeMermaid } from "../mermaid-highlighting";
import type { TChat, TTTDDialog } from "../types";
const isMermaidMessage = (message: TChat.ChatMessage) =>
message.contentFormat === "mermaid";
const renderMessageContent = (
message: TChat.ChatMessage,
className: string,
) => {
const content = message.content ?? "";
console.log("@", message);
if (!isMermaidMessage(message)) {
return (
<div className={className}>
{content}
{message.isGenerating && (
<span className="chat-message__cursor"></span>
)}
</div>
);
}
return (
<div className={clsx(className, "chat-message__text--mermaid")}>
{tokenizeMermaid(content).map((token, index) => (
<span
key={`${index}-${token.type ?? "text"}-${token.value}`}
className={clsx("chat-message__token", {
[`chat-message__token--${token.type}`]: token.type,
})}
>
{token.value}
</span>
))}
{message.isGenerating && <span className="chat-message__cursor"></span>}
</div>
);
};
export const ChatMessage: React.FC<{
message: TChat.ChatMessage;
onMermaidTabClick?: (message: TChat.ChatMessage) => void;
@@ -122,7 +163,14 @@ export const ChatMessage: React.FC<{
<div className="chat-message__body">
{message.error ? (
<>
<div className="chat-message__error">{message.content}</div>
<div className="chat-message__error">
{renderMessageContent(
message,
clsx("chat-message__text", {
"chat-message__text--error": !isMermaidMessage(message),
}),
)}
</div>
{message.errorType !== "parse" && (
<div className="chat-message__error_message">
Error: {message.error || t("chat.errors.generationFailed")}
@@ -132,7 +180,7 @@ export const ChatMessage: React.FC<{
<div className="chat-message__error_message">
<p>{t("chat.errors.invalidDiagram")}</p>
<div className="chat-message__error-actions">
{onMermaidTabClick && (
{onMermaidTabClick && isMermaidMessage(message) && (
<button
className="chat-message__error-link"
onClick={() => onMermaidTabClick(message)}
@@ -156,18 +204,13 @@ export const ChatMessage: React.FC<{
)}
</>
) : (
<div className="chat-message__text">
{message.content}
{message.isGenerating && (
<span className="chat-message__cursor"></span>
)}
</div>
renderMessageContent(message, "chat-message__text")
)}
</div>
</div>
{message.type === "assistant" && !message.isGenerating && (
<div className="chat-message__actions">
{!message.error && onInsertMessage && (
{!message.error && onInsertMessage && isMermaidMessage(message) && (
<button
className="chat-message__action"
onClick={() => onInsertMessage(message)}
@@ -178,7 +221,7 @@ export const ChatMessage: React.FC<{
{stackPushIcon}
</button>
)}
{onMermaidTabClick && message.content && (
{onMermaidTabClick && isMermaidMessage(message) && message.content && (
<button
className="chat-message__action"
onClick={() => onMermaidTabClick(message)}
@@ -11,7 +11,7 @@ import { rateLimitsAtom } from "../TTDContext";
import { ChatHistoryMenu } from "./ChatHistoryMenu";
import { ChatInterface } from ".";
import { ChatInterface } from "./ChatInterface";
import type { TTDPanelAction } from "../TTDDialogPanel";
@@ -25,6 +25,7 @@ export const TTDChatPanel = ({
onGenerate,
isGenerating,
generatedResponse,
generatedResponseFormat,
isMenuOpen,
onMenuToggle,
onMenuClose,
@@ -50,6 +51,7 @@ export const TTDChatPanel = ({
onGenerate: TTTDDialog.OnGenerate;
isGenerating: boolean;
generatedResponse: string | null | undefined;
generatedResponseFormat?: TChat.ChatMessage["contentFormat"];
isMenuOpen: boolean;
onMenuToggle: () => void;
@@ -89,7 +91,7 @@ export const TTDChatPanel = ({
});
}
if (generatedResponse) {
if (generatedResponse && generatedResponseFormat === "mermaid") {
actions.push({
action: onViewAsMermaid,
label: t("chat.viewAsMermaid"),
@@ -25,6 +25,7 @@ export const useChatAgent = () => {
{
type: "assistant",
content: "",
contentFormat: "mermaid",
isGenerating: true,
},
]),
@@ -0,0 +1,336 @@
import { useEffect, useRef } from "react";
import {
Decoration,
type DecorationSet,
EditorView,
ViewPlugin,
type ViewUpdate,
keymap,
lineNumbers,
placeholder as cmPlaceholder,
drawSelection,
} from "@codemirror/view";
import {
Compartment,
EditorState,
type Extension,
type Range,
} from "@codemirror/state";
import {
defaultKeymap,
history,
historyKeymap,
redo,
} from "@codemirror/commands";
import { syntaxHighlighting, HighlightStyle } from "@codemirror/language";
import { tags } from "@lezer/highlight";
import type { Theme } from "@excalidraw/element/types";
import { mermaidLite } from "./mermaid-lang-lite";
export interface CodeMirrorEditorProps {
value: string;
onChange: (value: string) => void;
onKeyboardSubmit?: () => void;
placeholder?: string;
theme: Theme;
errorLine?: number | null;
}
// ---- Dark theme ----
const darkTheme = EditorView.theme(
{
"&": {
backgroundColor: "#1e1e1e",
color: "#d4d4d4",
},
".cm-content": { caretColor: "#fff" },
".cm-cursor": { borderLeftColor: "#fff" },
".cm-selectionBackground": {
backgroundColor: "rgba(86, 156, 214, 0.3)",
},
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground":
{
backgroundColor: "rgba(86, 156, 214, 0.42)",
},
".cm-gutters": {
backgroundColor: "#1e1e1e",
color: "#858585",
border: "none",
},
".cm-activeLineGutter": { backgroundColor: "#2a2a2a" },
".cm-activeLine": { backgroundColor: "#2a2a2a" },
".cm-errorLine": { backgroundColor: "rgba(255, 0, 0, 0.15)" },
".cm-selectedWordMatch": {
backgroundColor: "rgba(255, 209, 102, 0.22)",
borderRadius: "2px",
},
},
{ dark: true },
);
const darkHighlight = HighlightStyle.define([
{ tag: tags.keyword, color: "#569cd6" },
{ tag: tags.string, color: "#ce9178" },
{ tag: tags.comment, color: "#6a9955" },
{ tag: tags.number, color: "#b5cea8" },
{ tag: tags.operator, color: "#d4d4d4" },
{ tag: tags.punctuation, color: "#d4d4d4" },
{ tag: tags.variableName, color: "#9cdcfe" },
{ tag: tags.bracket, color: "#ffd700" },
]);
// ---- Light theme ----
const lightTheme = EditorView.theme({
"&": {
backgroundColor: "#ffffff",
color: "#1e1e1e",
},
".cm-content": { caretColor: "#000" },
".cm-cursor": { borderLeftColor: "#000" },
".cm-gutters": {
backgroundColor: "#fff",
color: "#999",
border: "none",
},
".cm-activeLineGutter": { backgroundColor: "#e8e8e8" },
".cm-activeLine": { backgroundColor: "#e8e8e8" },
".cm-errorLine": { backgroundColor: "rgba(255, 0, 0, 0.1)" },
".cm-selectedWordMatch": {
backgroundColor: "rgba(255, 209, 102, 0.35)",
borderRadius: "2px",
},
});
const lightHighlight = HighlightStyle.define([
{ tag: tags.keyword, color: "#0000ff" },
{ tag: tags.string, color: "#a31515" },
{ tag: tags.comment, color: "#008000" },
{ tag: tags.number, color: "#098658" },
{ tag: tags.operator, color: "#1e1e1e" },
{ tag: tags.punctuation, color: "#1e1e1e" },
{ tag: tags.variableName, color: "#001080" },
{ tag: tags.bracket, color: "#af00db" },
]);
// ---- Error line decoration ----
const errorLineDeco = Decoration.line({ class: "cm-errorLine" });
const selectedWordMatchDeco = Decoration.mark({
class: "cm-selectedWordMatch",
});
const getSelectedWordMatchText = (state: EditorState) => {
const mainSelection = state.selection.main;
if (state.selection.ranges.length !== 1 || mainSelection.empty) {
return null;
}
const selectedWord = state.wordAt(mainSelection.from);
if (
!selectedWord ||
selectedWord.from !== mainSelection.from ||
selectedWord.to !== mainSelection.to
) {
return null;
}
return state.sliceDoc(mainSelection.from, mainSelection.to);
};
const getSelectedWordMatchDecorations = (view: EditorView): DecorationSet => {
const selectedWord = getSelectedWordMatchText(view.state);
if (!selectedWord) {
return Decoration.none;
}
const selection = view.state.selection.main;
const ranges: Range<Decoration>[] = [];
const doc = view.state.doc.toString();
let searchFrom = 0;
while (searchFrom <= doc.length - selectedWord.length) {
const matchFrom = doc.indexOf(selectedWord, searchFrom);
if (matchFrom === -1) {
break;
}
const matchTo = matchFrom + selectedWord.length;
const matchWord = view.state.wordAt(matchFrom);
if (
matchWord?.from === matchFrom &&
matchWord.to === matchTo &&
(matchFrom !== selection.from || matchTo !== selection.to)
) {
ranges.push(selectedWordMatchDeco.range(matchFrom, matchTo));
}
searchFrom = matchTo;
}
return ranges.length ? Decoration.set(ranges) : Decoration.none;
};
const selectedWordMatchExtension = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = getSelectedWordMatchDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.selectionSet) {
this.decorations = getSelectedWordMatchDecorations(update.view);
}
}
},
{
decorations: (value) => value.decorations,
},
);
const getErrorLineExtension = (
errorLine: number | null | undefined,
doc: { line(n: number): { from: number }; lines: number },
): Extension => {
if (!errorLine || errorLine < 1 || errorLine > doc.lines) {
return EditorView.decorations.of(Decoration.none);
}
const line = doc.line(errorLine);
return EditorView.decorations.of(
Decoration.set([errorLineDeco.range(line.from)]),
);
};
// ---- Helpers ----
const getThemeExtensions = (theme: Theme) => {
if (theme === "dark") {
return [darkTheme, syntaxHighlighting(darkHighlight)];
}
return [lightTheme, syntaxHighlighting(lightHighlight)];
};
const CodeMirrorEditor = ({
value,
onChange,
onKeyboardSubmit,
placeholder,
theme,
errorLine,
}: CodeMirrorEditorProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const onChangeRef = useRef(onChange);
const onKeyboardSubmitRef = useRef(onKeyboardSubmit);
const themeCompartmentRef = useRef(new Compartment());
const errorLineCompartmentRef = useRef(new Compartment());
onChangeRef.current = onChange;
onKeyboardSubmitRef.current = onKeyboardSubmit;
useEffect(() => {
if (!containerRef.current) {
return;
}
const themeCompartment = themeCompartmentRef.current;
const view = new EditorView({
state: EditorState.create({
doc: value,
extensions: [
keymap.of([
{
key: "Mod-Enter",
run: () => {
onKeyboardSubmitRef.current?.();
return true;
},
},
// historyKeymap binds Mod-Shift-z only on Mac; add it for all platforms
{ key: "Mod-Shift-z", run: redo, preventDefault: true },
]),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChangeRef.current(update.state.doc.toString());
}
}),
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
lineNumbers(),
EditorView.lineWrapping,
themeCompartment.of(getThemeExtensions(theme)),
errorLineCompartmentRef.current.of([]),
mermaidLite(),
drawSelection({ drawRangeCursor: true }),
selectedWordMatchExtension,
...(placeholder ? [cmPlaceholder(placeholder)] : []),
],
}),
parent: containerRef.current,
});
viewRef.current = view;
view.focus();
return () => {
view.destroy();
viewRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Swap theme dynamically via compartment
useEffect(() => {
const view = viewRef.current;
if (!view) {
return;
}
view.dispatch({
effects: themeCompartmentRef.current.reconfigure(
getThemeExtensions(theme),
),
});
}, [theme]);
// Update error line highlight
useEffect(() => {
const view = viewRef.current;
if (!view) {
return;
}
view.dispatch({
effects: errorLineCompartmentRef.current.reconfigure(
getErrorLineExtension(errorLine, view.state.doc),
),
});
}, [errorLine]);
// Sync external value changes into EditorView
useEffect(() => {
const view = viewRef.current;
if (!view) {
return;
}
const currentDoc = view.state.doc.toString();
if (value !== currentDoc) {
view.dispatch({
changes: { from: 0, to: currentDoc.length, insert: value },
});
}
}, [value]);
return (
<div
ref={containerRef}
className="ttd-dialog-input ttd-dialog-input--codemirror"
/>
);
};
export default CodeMirrorEditor;
@@ -17,6 +17,11 @@ import { TTDDialogOutput } from "./TTDDialogOutput";
import { TTDDialogPanel } from "./TTDDialogPanel";
import { TTDDialogPanels } from "./TTDDialogPanels";
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
import {
getMermaidErrorLineNumber,
isMermaidAutoFixableError,
} from "./utils/mermaidError";
import { getMermaidAutoFixCandidates } from "./utils/mermaidAutoFix";
import {
convertMermaidToExcalidraw,
insertToEditor,
@@ -33,6 +38,27 @@ const MERMAID_EXAMPLE =
"flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[Car]";
const debouncedSaveMermaidDefinition = debounce(saveMermaidDataToStorage, 300);
const AUTO_FIX_DEBOUNCE_MS = 500;
const AUTO_FIX_MAX_DEPTH = 4;
const AUTO_FIX_MAX_CANDIDATES = 30;
const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
if (
error &&
typeof error === "object" &&
"message" in error &&
typeof (error as { message?: unknown }).message === "string"
) {
return (error as { message: string }).message;
}
return "";
};
const MermaidToExcalidraw = ({
mermaidToExcalidrawLib,
@@ -46,8 +72,16 @@ const MermaidToExcalidraw = ({
EditorLocalStorage.get<string>(EDITOR_LS_KEYS.MERMAID_TO_EXCALIDRAW) ||
MERMAID_EXAMPLE,
);
const deferredText = useDeferredValue(text.trim());
const deferredText = useDeferredValue(text);
const [error, setError] = useState<Error | null>(null);
const [autoFixCandidate, setAutoFixCandidate] = useState<string | null>(null);
const errorLine = (() => {
if (!error?.message) {
return null;
}
return getMermaidErrorLineNumber(error.message, deferredText);
})();
const canvasRef = useRef<HTMLDivElement>(null);
const data = useRef<{
@@ -61,7 +95,7 @@ const MermaidToExcalidraw = ({
useEffect(() => {
const doRender = async () => {
try {
if (!deferredText) {
if (!deferredText.trim()) {
resetPreview({ canvasRef, setError });
return;
}
@@ -98,6 +132,88 @@ const MermaidToExcalidraw = ({
[],
);
useEffect(() => {
const errorMessage = error?.message ?? "";
const sourceText = deferredText;
const shouldTryAutoFix =
isActive &&
isMermaidAutoFixableError(errorMessage) &&
!!sourceText.trim() &&
mermaidToExcalidrawLib.loaded;
if (!shouldTryAutoFix) {
setAutoFixCandidate(null);
return;
}
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
if (!candidates.length) {
setAutoFixCandidate(null);
return;
}
let cancelled = false;
const timer = setTimeout(async () => {
try {
const api = await mermaidToExcalidrawLib.api;
const seen = new Set<string>([sourceText]);
const queue = candidates.map((candidate) => ({
text: candidate,
depth: 1,
}));
let triedCandidates = 0;
while (queue.length > 0 && triedCandidates < AUTO_FIX_MAX_CANDIDATES) {
const current = queue.shift();
if (!current || seen.has(current.text)) {
continue;
}
seen.add(current.text);
triedCandidates += 1;
try {
await api.parseMermaidToExcalidraw(current.text);
if (!cancelled) {
setAutoFixCandidate(current.text);
}
return;
} catch (candidateError) {
if (current.depth >= AUTO_FIX_MAX_DEPTH) {
continue;
}
const nextErrorMessage = getErrorMessage(candidateError);
if (!nextErrorMessage) {
continue;
}
const nextCandidates = getMermaidAutoFixCandidates(
current.text,
nextErrorMessage,
);
for (const nextCandidate of nextCandidates) {
if (!seen.has(nextCandidate)) {
queue.push({
text: nextCandidate,
depth: current.depth + 1,
});
}
}
}
}
} catch {
// ignore auto-fix probe errors
}
if (!cancelled) {
setAutoFixCandidate(null);
}
}, AUTO_FIX_DEBOUNCE_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [deferredText, error?.message, isActive, mermaidToExcalidrawLib]);
const onInsertToEditor = () => {
insertToEditor({
app,
@@ -107,21 +223,53 @@ const MermaidToExcalidraw = ({
});
};
const onApplyAutoFix = () => {
if (!autoFixCandidate) {
return;
}
setText(autoFixCandidate);
};
return (
<>
<div className="ttd-dialog-desc">
<Trans
i18nKey="mermaid.description"
flowchartLink={(el) => (
<a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a>
<a
href="https://mermaid.js.org/syntax/flowchart.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)}
sequenceLink={(el) => (
<a href="https://mermaid.js.org/syntax/sequenceDiagram.html">
<a
href="https://mermaid.js.org/syntax/sequenceDiagram.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)}
classLink={(el) => (
<a href="https://mermaid.js.org/syntax/classDiagram.html">{el}</a>
<a
href="https://mermaid.js.org/syntax/classDiagram.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)}
erdLink={(el) => (
<a
href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)}
/>
</div>
@@ -130,7 +278,8 @@ const MermaidToExcalidraw = ({
<TTDDialogInput
input={text}
placeholder={t("mermaid.inputPlaceholder")}
onChange={(event) => setText(event.target.value)}
onChange={(value) => setText(value)}
errorLine={errorLine}
onKeyboardSubmit={() => {
onInsertToEditor();
}}
@@ -153,6 +302,9 @@ const MermaidToExcalidraw = ({
canvasRef={canvasRef}
loaded={mermaidToExcalidrawLib.loaded}
error={error}
sourceText={text}
autoFixAvailable={!!autoFixCandidate}
onApplyAutoFix={onApplyAutoFix}
/>
</TTDDialogPanel>
</TTDDialogPanels>
@@ -219,6 +219,49 @@ $fullScreenModalBreakpoint: 600px;
}
}
.ttd-dialog-input--loading {
display: flex;
align-items: center;
justify-content: center;
}
.ttd-dialog-input--codemirror {
padding: 0;
overflow: hidden;
// Override height:100% from .ttd-dialog-input use flex sizing
// so the editor fills remaining space without overflowing the panel
height: 0;
flex: 1 1 0;
min-height: 0;
.cm-editor {
height: 100%;
font-family: monospace;
&.cm-focused {
outline: none;
}
}
.cm-scroller {
padding: 0.85rem 0;
overflow: auto;
}
.cm-gutters {
padding-left: 0.25rem;
}
.cm-content {
padding: 0;
}
.cm-placeholder {
color: var(--color-gray-40);
font-style: italic;
}
}
.ttd-dialog-output-wrapper {
display: flex;
flex-direction: column;
@@ -331,14 +374,55 @@ $fullScreenModalBreakpoint: 600px;
margin-top: 0.25rem;
}
.ttd-dialog-output-error-summary {
width: 100%;
max-width: 640px;
color: var(--color-gray-50);
font-size: 0.9rem;
text-align: left;
&__headline {
font-weight: 600;
color: var(--color-gray-60);
}
&__label {
margin-top: 0.35rem;
font-weight: 500;
}
&__causes {
margin: 0.35rem 0 0;
padding-left: 2rem;
}
}
.ttd-dialog-output-error-message {
text-align: left;
font-weight: 400;
color: var(--color-gray-50);
word-break: break-word;
white-space: pre-wrap;
max-width: 100%;
max-width: 640px;
width: 100%;
font-family: monospace;
&__caret {
color: var(--color-danger);
}
}
.ttd-dialog-output-error-autofix-slot {
align-self: flex-start;
margin-top: 0.35rem;
min-height: 2.5rem;
display: flex;
align-items: flex-start;
}
.ttd-dialog-output-error-autofix {
margin-top: 0;
white-space: nowrap;
}
}
@@ -1,28 +1,84 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { EVENT, KEYS } from "@excalidraw/common";
import type { ChangeEventHandler } from "react";
import Spinner from "../Spinner";
import { useUIAppState } from "../../context/ui-appState";
import type { ComponentType } from "react";
import type { CodeMirrorEditorProps } from "./CodeMirrorEditor";
interface TTDDialogInputProps {
input: string;
placeholder: string;
onChange: ChangeEventHandler<HTMLTextAreaElement>;
onChange: (value: string) => void;
onKeyboardSubmit?: () => void;
errorLine?: number | null;
}
type EditorState =
| { type: "loading" }
| { type: "ready"; component: ComponentType<CodeMirrorEditorProps> }
| { type: "fallback" };
const SPINNER_DELAY_MS = 300;
export const TTDDialogInput = ({
input,
placeholder,
onChange,
onKeyboardSubmit,
errorLine,
}: TTDDialogInputProps) => {
const ref = useRef<HTMLTextAreaElement>(null);
const callbackRef = useRef(onKeyboardSubmit);
callbackRef.current = onKeyboardSubmit;
const [editorState, setEditorState] = useState<EditorState>({
type: "loading",
});
const [showSpinner, setShowSpinner] = useState(false);
const { theme } = useUIAppState();
// Lazy-load CodeMirror editor
useEffect(() => {
let cancelled = false;
const spinnerTimer = setTimeout(() => {
if (!cancelled) {
setShowSpinner(true);
}
}, SPINNER_DELAY_MS);
import("./CodeMirrorEditor")
.then((mod) => {
if (!cancelled) {
setEditorState({ type: "ready", component: mod.default });
}
})
.catch(() => {
if (!cancelled) {
setEditorState({ type: "fallback" });
}
})
.finally(() => {
clearTimeout(spinnerTimer);
});
return () => {
cancelled = true;
clearTimeout(spinnerTimer);
};
}, []);
// Keyboard shortcut + focus for textarea fallback
useEffect(() => {
if (editorState.type !== "fallback") {
return;
}
if (!callbackRef.current) {
return;
}
@@ -40,15 +96,42 @@ export const TTDDialogInput = ({
textarea.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
};
}
}, []);
}, [editorState.type]);
return (
<textarea
className="ttd-dialog-input"
onChange={onChange}
value={input}
placeholder={placeholder}
ref={ref}
/>
);
if (editorState.type === "ready") {
const CodeMirrorEditor = editorState.component;
return (
<CodeMirrorEditor
value={input}
onChange={onChange}
onKeyboardSubmit={onKeyboardSubmit}
placeholder={placeholder}
theme={theme}
errorLine={errorLine}
/>
);
}
if (editorState.type === "fallback") {
return (
<textarea
className="ttd-dialog-input"
onChange={(e) => onChange(e.target.value)}
value={input}
placeholder={placeholder}
ref={ref}
/>
);
}
// Loading state
if (showSpinner) {
return (
<div className="ttd-dialog-input ttd-dialog-input--loading">
<Spinner />
</div>
);
}
return null;
};
@@ -1,14 +1,24 @@
import clsx from "clsx";
import { Button } from "../Button";
import Spinner from "../Spinner";
import { t } from "../../i18n";
import { alertTriangleIcon } from "../icons";
import {
formatMermaidParseErrorMessage,
getMermaidSyntaxErrorGuidance,
isMermaidCaretLine,
} from "./utils/mermaidError";
interface TTDDialogOutputProps {
error: Error | null;
canvasRef: React.RefObject<HTMLDivElement | null>;
loaded: boolean;
hideErrorDetails?: boolean;
sourceText?: string;
autoFixAvailable?: boolean;
onApplyAutoFix?: () => void;
}
export const TTDDialogOutput = ({
@@ -16,7 +26,24 @@ export const TTDDialogOutput = ({
canvasRef,
loaded,
hideErrorDetails,
sourceText,
autoFixAvailable,
onApplyAutoFix,
}: TTDDialogOutputProps) => {
const errorMessage = error
? hideErrorDetails
? t("chat.errors.mermaidParseError")
: formatMermaidParseErrorMessage(error.message)
: null;
const syntaxGuidance =
error && !hideErrorDetails
? getMermaidSyntaxErrorGuidance(error.message, sourceText)
: null;
const showAutoFixButton =
!!autoFixAvailable && !!onApplyAutoFix && !hideErrorDetails;
const errorMessageLines = errorMessage?.split(/\r?\n/) ?? [];
return (
<div
className={`ttd-dialog-output-wrapper ${
@@ -33,14 +60,48 @@ export const TTDDialogOutput = ({
<div className="ttd-dialog-output-error-icon">
{alertTriangleIcon}
</div>
<div className="ttd-dialog-output-error-title">
{t("ttd.error")}
</div>
{syntaxGuidance && (
<div className="ttd-dialog-output-error-summary">
<div className="ttd-dialog-output-error-summary__headline">
{syntaxGuidance.summary}
</div>
<div className="ttd-dialog-output-error-summary__label">
Likely causes:
</div>
<ul className="ttd-dialog-output-error-summary__causes">
{syntaxGuidance.likelyCauses.map((cause) => (
<li key={cause}>{cause}</li>
))}
</ul>
</div>
)}
<div className="ttd-dialog-output-error-message">
{hideErrorDetails
? t("chat.errors.mermaidParseError")
: error.message}
{errorMessageLines.map((line, index) => (
<span
key={`error-line-${index}`}
className={
isMermaidCaretLine(line)
? "ttd-dialog-output-error-message__caret"
: undefined
}
>
{line}
{index < errorMessageLines.length - 1 ? "\n" : ""}
</span>
))}
</div>
{!hideErrorDetails && (
<div className="ttd-dialog-output-error-autofix-slot">
{showAutoFixButton ? (
<Button
className="ttd-dialog-panel-button ttd-dialog-output-error-autofix"
onSelect={onApplyAutoFix}
>
{t("mermaid.autoFixAvailable")}
</Button>
) : null}
</div>
)}
</div>
</div>
)}
@@ -1,4 +1,4 @@
import { getShortcutKey } from "@excalidraw/excalidraw/shortcut";
import { getShortcutKey } from "../../shortcut";
export const TTDDialogSubmitShortcut = () => {
return (
@@ -80,7 +80,10 @@ const TextToDiagramContent = ({
} = useChatManagement({ persistenceAdapter });
const onViewAsMermaid = () => {
if (typeof lastAssistantMessage?.content === "string") {
if (
lastAssistantMessage?.contentFormat === "mermaid" &&
typeof lastAssistantMessage.content === "string"
) {
saveMermaidDataToStorage(lastAssistantMessage.content);
setAppState({
openDialog: { name: "ttd", tab: "mermaid" },
@@ -206,6 +209,7 @@ const TextToDiagramContent = ({
onGenerate={onGenerate}
isGenerating={lastAssistantMessage?.isGenerating ?? false}
generatedResponse={lastAssistantMessage?.content}
generatedResponseFormat={lastAssistantMessage?.contentFormat}
isMenuOpen={isMenuOpen}
onMenuToggle={handleMenuToggle}
onMenuClose={handleMenuClose}
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from "vitest";
import { convertMermaidToExcalidraw } from "./common";
type ConvertMermaidArgs = Parameters<typeof convertMermaidToExcalidraw>[0];
type ParseMermaidToExcalidraw = Awaited<
ConvertMermaidArgs["mermaidToExcalidrawLib"]["api"]
>["parseMermaidToExcalidraw"];
const createConvertArgs = (
mermaidDefinition: string,
parseMermaidToExcalidraw: ParseMermaidToExcalidraw,
): ConvertMermaidArgs => {
const parent = document.createElement("div");
const canvas = document.createElement("div");
parent.appendChild(canvas);
return {
canvasRef: { current: canvas },
mermaidToExcalidrawLib: {
loaded: true,
api: Promise.resolve({ parseMermaidToExcalidraw }),
},
mermaidDefinition,
setError: vi.fn(),
data: {
current: {
elements: [],
files: null,
},
},
theme: "light",
};
};
describe("convertMermaidToExcalidraw", () => {
it("returns the original parse error when quote-normalized fallback also fails", async () => {
const originalError = new Error("Parse error on line 9: ...");
const fallbackError = new Error("Parse error on line 6: ...");
const parseMermaidToExcalidraw = vi
.fn<ParseMermaidToExcalidraw>()
.mockRejectedValueOnce(originalError)
.mockRejectedValueOnce(fallbackError);
const mermaidDefinition =
'graph TD\nA["One"]\nB["Two"]x\nC["Three"]\nD["Four"]';
const result = await convertMermaidToExcalidraw(
createConvertArgs(mermaidDefinition, parseMermaidToExcalidraw),
);
expect(parseMermaidToExcalidraw).toHaveBeenCalledTimes(2);
expect(parseMermaidToExcalidraw).toHaveBeenNthCalledWith(
1,
mermaidDefinition,
);
expect(parseMermaidToExcalidraw).toHaveBeenNthCalledWith(
2,
mermaidDefinition.replace(/"/g, "'"),
);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe(originalError);
}
});
it("does not retry quote normalization when the input has no double quotes", async () => {
const originalError = new Error("Parse error on line 9: ...");
const parseMermaidToExcalidraw = vi
.fn<ParseMermaidToExcalidraw>()
.mockRejectedValueOnce(originalError);
const mermaidDefinition = "graph TD\nA[One]\nB[Two]x";
const result = await convertMermaidToExcalidraw(
createConvertArgs(mermaidDefinition, parseMermaidToExcalidraw),
);
expect(parseMermaidToExcalidraw).toHaveBeenCalledTimes(1);
expect(parseMermaidToExcalidraw).toHaveBeenCalledWith(mermaidDefinition);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe(originalError);
}
});
});
@@ -1,4 +1,12 @@
import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "@excalidraw/common";
import {
DEFAULT_EXPORT_PADDING,
EDITOR_LS_KEYS,
THEME,
} from "@excalidraw/common";
import { convertToExcalidrawElements } from "@excalidraw/element";
import { exportToCanvas } from "@excalidraw/utils";
import type {
NonDeletedExcalidrawElement,
@@ -6,11 +14,6 @@ import type {
} from "@excalidraw/element/types";
import { EditorLocalStorage } from "../../data/EditorLocalStorage";
import {
convertToExcalidrawElements,
exportToCanvas,
THEME,
} from "../../index";
import type { MermaidToExcalidrawLibProps } from "./types";
@@ -72,18 +75,26 @@ export const convertMermaidToExcalidraw = async ({
const api = await mermaidToExcalidrawLib.api;
try {
ret = await api.parseMermaidToExcalidraw(mermaidDefinition);
} catch (err: unknown) {
const originalParseError = err as Error;
if (!mermaidDefinition.includes('"')) {
return { success: false, error: originalParseError };
}
try {
ret = await api.parseMermaidToExcalidraw(mermaidDefinition);
} catch (err: unknown) {
ret = await api.parseMermaidToExcalidraw(
mermaidDefinition.replace(/"/g, "'"),
);
} catch {
// Keep the original error so line/column references stay aligned with
// the user's unmodified input.
return { success: false, error: originalParseError };
}
} catch (err: unknown) {
return { success: false, error: err as Error };
}
const { elements, files } = ret;
const { elements, files = {} } = ret;
setError(null);
data.current = {
@@ -88,6 +88,7 @@ export const useTextGeneration = ({
updateAssistantContent(prev, {
isGenerating: true,
content: "",
contentFormat: "mermaid",
error: undefined,
errorType: undefined,
errorDetails: undefined,
@@ -0,0 +1,40 @@
import {
getMermaidHighlightToken,
tokenizeMermaid,
} from "./mermaid-highlighting";
describe("mermaid highlighting", () => {
it("tokenizes mermaid syntax with shared token types", () => {
const tokens = tokenizeMermaid('flowchart LR\nA["Hello"] --> B');
expect(tokens).toEqual([
{ type: "keyword", value: "flowchart" },
{ type: null, value: " " },
{ type: "keyword", value: "LR" },
{ type: null, value: "\n" },
{ type: "variableName", value: "A" },
{ type: "bracket", value: "[" },
{ type: "string", value: '"Hello"' },
{ type: "bracket", value: "]" },
{ type: null, value: " " },
{ type: "operator", value: "-->" },
{ type: null, value: " " },
{ type: "variableName", value: "B" },
]);
});
it("limits comment tokens to a single line", () => {
const tokens = tokenizeMermaid("%% comment\nflowchart TD");
expect(tokens[0]).toEqual({ type: "comment", value: "%% comment" });
expect(tokens[1]).toEqual({ type: null, value: "\n" });
expect(tokens[2]).toEqual({ type: "keyword", value: "flowchart" });
});
it("falls back to plain text for unsupported characters", () => {
expect(getMermaidHighlightToken("@node")).toEqual({
type: null,
value: "@",
});
});
});
@@ -0,0 +1,79 @@
export type MermaidHighlightTokenType =
| "bracket"
| "comment"
| "keyword"
| "number"
| "operator"
| "punctuation"
| "string"
| "variableName";
export type MermaidHighlightToken = {
type: MermaidHighlightTokenType | null;
value: string;
};
const DIAGRAM_TYPE_PATTERN =
/^(flowchart|graph|sequenceDiagram|classDiagram|stateDiagram|erDiagram|gantt|pie|mindmap|journey|gitGraph|timeline|quadrantChart|sankey|xychart)\b/i;
const DIRECTION_PATTERN = /^(TB|TD|BT|RL|LR)\b/;
const KEYWORD_PATTERN =
/^(subgraph|end|participant|actor|loop|alt|else|opt|par|critical|break|rect|note|over|activate|deactivate|title|section|class|style|linkStyle|classDef|click)\b/i;
const MERMAID_TOKEN_RULES: ReadonlyArray<{
pattern: RegExp;
type: MermaidHighlightTokenType | null;
}> = [
{ pattern: /^%%[^\n]*/, type: "comment" },
{ pattern: /^"(?:[^"\\]|\\.)*"/, type: "string" },
{ pattern: DIAGRAM_TYPE_PATTERN, type: "keyword" },
{ pattern: DIRECTION_PATTERN, type: "keyword" },
{ pattern: KEYWORD_PATTERN, type: "keyword" },
{ pattern: /^[-.=<>|ox]+>/, type: "operator" },
{ pattern: /^<[-.=<>|ox]+/, type: "operator" },
{ pattern: /^(--+|\.\.+|==+)/, type: "operator" },
{ pattern: /^[[\](){}|<>]/, type: "bracket" },
{ pattern: /^[A-Za-z_][A-Za-z0-9_]*/, type: "variableName" },
{ pattern: /^\d+(\.\d+)?/, type: "number" },
{ pattern: /^[,:;]/, type: "punctuation" },
{ pattern: /^\s+/, type: null },
];
export const getMermaidHighlightToken = (
input: string,
): MermaidHighlightToken | null => {
if (!input) {
return null;
}
for (const rule of MERMAID_TOKEN_RULES) {
const match = input.match(rule.pattern);
if (match) {
return {
type: rule.type,
value: match[0],
};
}
}
return {
type: null,
value: input[0],
};
};
export const tokenizeMermaid = (input: string): MermaidHighlightToken[] => {
const tokens: MermaidHighlightToken[] = [];
let remaining = input;
while (remaining) {
const token = getMermaidHighlightToken(remaining);
if (!token) {
break;
}
tokens.push(token);
remaining = remaining.slice(token.value.length);
}
return tokens;
};
@@ -0,0 +1,20 @@
import { StreamLanguage } from "@codemirror/language";
import { getMermaidHighlightToken } from "./mermaid-highlighting";
const mermaidStreamParser = StreamLanguage.define({
token(stream) {
const token = getMermaidHighlightToken(stream.string.slice(stream.pos));
if (!token) {
stream.skipToEnd();
return null;
}
stream.pos += token.value.length;
return token.type;
},
});
export function mermaidLite() {
return mermaidStreamParser;
}
@@ -18,6 +18,8 @@ export type MermaidData = {
files: BinaryFiles | null;
};
export type ChatMessageContentFormat = "text" | "mermaid";
export interface RateLimits {
rateLimit: number;
rateLimitRemaining: number;
@@ -33,6 +35,7 @@ export namespace TChat {
errorType?: "parse" | "network" | "other";
lastAttemptAt?: number;
type: "user" | "assistant" | "warning";
contentFormat?: ChatMessageContentFormat;
warningType?: /* daily rate limit */
"messageLimitExceeded" | /* general 429 */ "rateLimitExceeded";
content?: string;
@@ -7,6 +7,17 @@ import { chatHistoryAtom } from "./TTDContext";
import type { SavedChat, SavedChats, TTDPersistenceAdapter } from "./types";
const normalizePersistedChat = (chat: SavedChat): SavedChat => ({
...chat,
messages: chat.messages.map((message) => ({
...message,
// Legacy TTD chats predate explicit content format metadata.
contentFormat:
message.contentFormat ??
(message.type === "assistant" ? "mermaid" : undefined),
})),
});
interface UseTTDChatStorageProps {
persistenceAdapter: TTDPersistenceAdapter;
}
@@ -56,7 +67,7 @@ export const useTTDChatStorage = ({
setIsLoading(true);
try {
const chats = await persistenceAdapter.loadChats();
setSavedChats(chats);
setSavedChats(chats.map(normalizePersistedChat));
setChatsLoaded(true);
} catch (error) {
console.warn("Failed to load chats:", error);
@@ -1,9 +1,6 @@
import { RequestError } from "@excalidraw/excalidraw/errors";
import { RequestError } from "../../../errors";
import type {
LLMMessage,
TTTDDialog,
} from "@excalidraw/excalidraw/components/TTDDialog/types";
import type { LLMMessage, TTTDDialog } from "../types";
interface RateLimitInfo {
rateLimit?: number;
@@ -117,6 +117,28 @@ describe("chat utils", () => {
expect(result.messages[0].errorType).toBe("network");
});
it("should update content format when provided", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "assistant",
content: "graph TD",
timestamp: new Date("2024-01-01"),
contentFormat: "text",
},
],
};
const result = updateAssistantContent(chatHistory, {
contentFormat: "mermaid",
});
expect(result.messages[0].contentFormat).toBe("mermaid");
});
it("should return unchanged chatHistory if no assistant message exists", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
@@ -357,6 +379,7 @@ describe("chat utils", () => {
{
type: "assistant",
content: "Message",
contentFormat: "mermaid",
isGenerating: true,
error: "Error text",
errorType: "parse",
@@ -364,6 +387,7 @@ describe("chat utils", () => {
]);
expect(result.messages[0].isGenerating).toBe(true);
expect(result.messages[0].contentFormat).toBe("mermaid");
expect(result.messages[0].error).toBe("Error text");
expect(result.messages[0].errorType).toBe("parse");
});
@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import { getMermaidAutoFixCandidates } from "./mermaidAutoFix";
describe("getMermaidAutoFixCandidates", () => {
it("suggests removing trailing token after a closed label shape", () => {
const sourceText = `graph TD
L3_TCP["TCP (Transmission Control Protocol)"]x
L3_UDP["UDP (User Datagram Protocol)"]`;
const errorMessage = `Parse error on line 2:
...ission Control Protocol)"]x
-----------------------------^
Expecting 'SEMI', got 'NODE_STRING'`;
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`graph TD
L3_TCP["TCP (Transmission Control Protocol)"]
L3_UDP["UDP (User Datagram Protocol)"]`);
});
it("suggests appending missing end statements", () => {
const sourceText = `graph TD
subgraph A
A1[Start]`;
const errorMessage = `Parse error on line 3:
... A1[Start]
-------------^
Expecting 'end'`;
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`graph TD
subgraph A
A1[Start]
end`);
});
it("returns empty list for non-parse errors", () => {
expect(
getMermaidAutoFixCandidates("graph TD\nA-->B", "Network error"),
).toEqual([]);
});
it("extracts line index from lexical error format too", () => {
const sourceText = `graph TD
subgraph Layers["X"]x
direction TB`;
const errorMessage = `Lexical error on line 2. Unrecognized text.
... subgraph Layers["X"]x direction
-----------------------^`;
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`graph TD
subgraph Layers["X"]
direction TB`);
});
it("removes extra > after edge label", () => {
const sourceText = `flowchart TD
A["User Input"] -->|text|> B["Tokenization"]
A["User Input"] -->|text|> B["Tokenization"]`;
const errorMessage = `Parse error on line 2:
...A["User Input"] -->|text|> B["Tokenization"]
---------------------------^
Expecting 'NODE_STRING', got 'GT'`;
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`flowchart TD
A["User Input"] -->|text| B["Tokenization"]
A["User Input"] -->|text| B["Tokenization"]`);
});
it("suggests removing the last invalid deactivate for participant errors", () => {
const sourceText = `sequenceDiagram
participant QAEngineer as QA
activate QA
QA->>QA: Verifies Fix
deactivate QA
QA->>QA: Verifies Again
deactivate QA`;
const errorMessage = "Trying to inactivate an inactive participant (QA)";
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`sequenceDiagram
participant QAEngineer as QA
activate QA
QA->>QA: Verifies Fix
deactivate QA
QA->>QA: Verifies Again`);
});
it("adds a fallback candidate that removes all invalid deactivations", () => {
const sourceText = `sequenceDiagram
participant QAEngineer as QA
deactivate QA
QA->>QA: Verifies Fix
deactivate QA`;
const errorMessage = "Trying to inactivate an inactive participant (QA)";
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`sequenceDiagram
participant QAEngineer as QA
QA->>QA: Verifies Fix`);
});
});

Some files were not shown because too many files have changed in this diff Show More