Compare commits

..
Author SHA1 Message Date
Mark Tolmacs 4e01cd73a7 feat: Attempt simple chaining of ovoids
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-01-05 16:51:25 +00:00
Mark Tolmacs c92ae37f50 fix: Endcap fix, but loop still closes
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-01-05 14:58:40 +00:00
Mark Tolmacs 071043adae feat: Add polygon debugging
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-01-05 12:59:02 +00:00
Mark Tolmacs 9616e63e23 feat: Ovoids
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-01-05 10:51:10 +00:00
159 changed files with 2259 additions and 6856 deletions
+2 -2
View File
@@ -12,7 +12,7 @@ VITE_APP_WS_SERVER_URL=http://localhost:3002
VITE_APP_PLUS_LP=https://plus.excalidraw.com VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=http://localhost:3000 VITE_APP_PLUS_APP=http://localhost:3000
VITE_APP_AI_BACKEND=http://localhost:3016 VITE_APP_AI_BACKEND=http://localhost:3015
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}' VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
@@ -27,7 +27,7 @@ VITE_APP_ENABLE_TRACKING=true
FAST_REFRESH=false FAST_REFRESH=false
# The port the run the dev server # The port the run the dev server
VITE_APP_PORT=3001 VITE_APP_PORT=3000
#Debug flags #Debug flags
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
export SENTRY_RELEASE=$(sentry-cli releases propose-version) export SENTRY_RELEASE=$(sentry-cli releases propose-version)
sentry-cli releases new $SENTRY_RELEASE --project $SENTRY_PROJECT sentry-cli releases new $SENTRY_RELEASE --project $SENTRY_PROJECT
sentry-cli releases set-commits --auto $SENTRY_RELEASE sentry-cli releases set-commits --auto $SENTRY_RELEASE
sentry-cli sourcemaps upload --release $SENTRY_RELEASE --no-rewrite ./build/static/js/ --url-prefix "~/static/js" sentry-cli releases files $SENTRY_RELEASE upload-sourcemaps --no-rewrite ./build/static/js/ --url-prefix "~/static/js"
sentry-cli releases finalize $SENTRY_RELEASE sentry-cli releases finalize $SENTRY_RELEASE
sentry-cli releases deploys $SENTRY_RELEASE new -e production sentry-cli releases deploys $SENTRY_RELEASE new -e production
env: env:
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
- name: "Install Node" - name: "Install Node"
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: "20.x" node-version: "18.x"
- name: "Install Deps" - name: "Install Deps"
run: yarn install run: yarn install
- name: "Test Coverage" - name: "Test Coverage"
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup Node.js - name: Setup Node.js 18.x
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 18.x
- name: Install and test - name: Install and test
run: | run: |
yarn install yarn install
-1
View File
@@ -46,7 +46,6 @@ export const STORAGE_KEYS = {
VERSION_FILES: "version-files", VERSION_FILES: "version-files",
IDB_LIBRARY: "excalidraw-library", IDB_LIBRARY: "excalidraw-library",
IDB_TTD_CHATS: "excalidraw-ttd-chats",
// do not use apart from migrations // do not use apart from migrations
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library", __LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
+52 -17
View File
@@ -4,15 +4,12 @@ import {
getTextFromElements, getTextFromElements,
MIME_TYPES, MIME_TYPES,
TTDDialog, TTDDialog,
TTDStreamFetch,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { getDataURL } from "@excalidraw/excalidraw/data/blob"; import { getDataURL } from "@excalidraw/excalidraw/data/blob";
import { safelyParseJSON } from "@excalidraw/common"; import { safelyParseJSON } from "@excalidraw/common";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { TTDIndexedDBAdapter } from "../data/TTDStorage";
export const AIComponents = ({ export const AIComponents = ({
excalidrawAPI, excalidrawAPI,
}: { }: {
@@ -102,23 +99,61 @@ export const AIComponents = ({
/> />
<TTDDialog <TTDDialog
onTextSubmit={async (props) => { onTextSubmit={async (input) => {
const { onChunk, onStreamCreated, signal, messages } = props; try {
const response = await fetch(
`${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/text-to-diagram/generate`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ prompt: input }),
},
);
const result = await TTDStreamFetch({ const rateLimit = response.headers.has("X-Ratelimit-Limit")
url: `${ ? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
import.meta.env.VITE_APP_AI_BACKEND : undefined;
}/v1/ai/text-to-diagram/chat-streaming`,
messages,
onChunk,
onStreamCreated,
extractRateLimits: true,
signal,
});
return result; const rateLimitRemaining = response.headers.has(
"X-Ratelimit-Remaining",
)
? parseInt(
response.headers.get("X-Ratelimit-Remaining") || "0",
10,
)
: undefined;
const json = await response.json();
if (!response.ok) {
if (response.status === 429) {
return {
rateLimit,
rateLimitRemaining,
error: new Error(
"Too many requests today, please try again tomorrow!",
),
};
}
throw new Error(json.message || "Generation failed...");
}
const generatedResponse = json.generatedResponse;
if (!generatedResponse) {
throw new Error("Generation failed...");
}
return { generatedResponse, rateLimit, rateLimitRemaining };
} catch (err: any) {
throw new Error("Request failed");
}
}} }}
persistenceAdapter={TTDIndexedDBAdapter}
/> />
</> </>
); );
+23 -26
View File
@@ -27,10 +27,7 @@ import { isCurve } from "@excalidraw/math/curve";
import React from "react"; import React from "react";
import type { Curve } from "@excalidraw/math"; import type { Curve } from "@excalidraw/math";
import type { import type { DebugElement } from "@excalidraw/common";
DebugElement,
DebugPolygon,
} from "@excalidraw/element/visualdebug";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
@@ -78,44 +75,39 @@ const renderCubicBezier = (
context.restore(); context.restore();
}; };
const isPolygon = (data: any): data is GlobalPoint[] => {
return (
Array.isArray(data) &&
data.every((point) => Array.isArray(point) && point.length === 2)
);
};
const renderPolygon = ( const renderPolygon = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
zoom: number, zoom: number,
polygon: DebugPolygon, points: GlobalPoint[],
color: string, color: string,
) => { ) => {
const { points, fill, close } = polygon; if (points.length < 3) {
if (points.length < 2) {
return; return;
} }
context.save(); context.save();
context.fillStyle = color;
context.globalAlpha = 0.3;
context.beginPath(); context.beginPath();
context.moveTo(points[0][0] * zoom, points[0][1] * zoom); context.moveTo(points[0][0] * zoom, points[0][1] * zoom);
for (let i = 1; i < points.length; i += 1) { for (let i = 1; i < points.length; i++) {
context.lineTo(points[i][0] * zoom, points[i][1] * zoom); context.lineTo(points[i][0] * zoom, points[i][1] * zoom);
} }
if (close !== false) { context.closePath();
context.closePath(); context.fill();
} context.globalAlpha = 1.0;
if (fill) {
context.save();
context.globalAlpha = 0.15;
context.fillStyle = color;
context.fill();
context.restore();
}
context.strokeStyle = color; context.strokeStyle = color;
context.stroke(); context.stroke();
context.restore(); context.restore();
}; };
const isDebugPolygon = (data: DebugElement["data"]): data is DebugPolygon =>
(data as DebugPolygon).type === "polygon";
const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => { const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
context.strokeStyle = "#888"; context.strokeStyle = "#888";
context.save(); context.save();
@@ -321,8 +313,13 @@ const render = (
el.color, el.color,
); );
break; break;
case isDebugPolygon(el.data): case isPolygon(el.data):
renderPolygon(context, appState.zoom.value, el.data, el.color); renderPolygon(
context,
appState.zoom.value,
el.data as GlobalPoint[],
el.color,
);
break; break;
default: default:
throw new Error(`Unknown element type ${JSON.stringify(el)}`); throw new Error(`Unknown element type ${JSON.stringify(el)}`);
@@ -0,0 +1,46 @@
import { THEME } from "@excalidraw/common";
import oc from "open-color";
import React from "react";
import type { Theme } from "@excalidraw/element/types";
// https://github.com/tholman/github-corners
export const GitHubCorner = React.memo(
({ theme, dir }: { theme: Theme; dir: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 250 250"
className="rtl-mirror"
style={{
marginTop: "calc(var(--space-factor) * -1)",
[dir === "rtl" ? "marginLeft" : "marginRight"]:
"calc(var(--space-factor) * -1)",
}}
>
<a
href="https://github.com/excalidraw/excalidraw"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub repository"
>
<path
d="M0 0l115 115h15l12 27 108 108V0z"
fill={theme === THEME.LIGHT ? oc.gray[6] : oc.gray[7]}
/>
<path
className="octo-arm"
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
style={{ transformOrigin: "130px 106px" }}
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
/>
<path
className="octo-body"
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
/>
</a>
</svg>
),
);
-51
View File
@@ -1,51 +0,0 @@
import { createStore, get, set } from "idb-keyval";
import type { SavedChats } from "@excalidraw/excalidraw/components/TTDDialog/types";
import { STORAGE_KEYS } from "../app_constants";
/**
* IndexedDB adapter for TTD chat storage.
* Implements TTDPersistenceAdapter interface.
*/
export class TTDIndexedDBAdapter {
/** IndexedDB database name */
private static idb_name = STORAGE_KEYS.IDB_TTD_CHATS;
/** Store key for chat data */
private static key = "ttdChats";
private static store = createStore(
`${TTDIndexedDBAdapter.idb_name}-db`,
`${TTDIndexedDBAdapter.idb_name}-store`,
);
/**
* Load saved chats from IndexedDB.
* @returns Promise resolving to saved chats array (empty if none found)
*/
static async loadChats(): Promise<SavedChats> {
try {
const data = await get<SavedChats>(
TTDIndexedDBAdapter.key,
TTDIndexedDBAdapter.store,
);
return data || [];
} catch (error) {
console.warn("Failed to load TTD chats from IndexedDB:", error);
return [];
}
}
/**
* Save chats to IndexedDB.
* @param chats - The chats array to persist
*/
static async saveChats(chats: SavedChats): Promise<void> {
try {
await set(TTDIndexedDBAdapter.key, chats, TTDIndexedDBAdapter.store);
} catch (error) {
console.warn("Failed to save TTD chats to IndexedDB:", error);
throw error;
}
}
}
+6 -6
View File
@@ -10,6 +10,8 @@ const lessPrecise = (num: number, precision = 5) =>
const getAvgFrameTime = (times: number[]) => const getAvgFrameTime = (times: number[]) =>
lessPrecise(times.reduce((a, b) => a + b) / times.length); lessPrecise(times.reduce((a, b) => a + b) / times.length);
const getFps = (frametime: number) => lessPrecise(1000 / frametime);
export class Debug { export class Debug {
public static DEBUG_LOG_TIMES = true; public static DEBUG_LOG_TIMES = true;
@@ -64,14 +66,12 @@ export class Debug {
} }
for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) { for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) {
if (times.length) { if (times.length) {
// const avgFrameTime = getAvgFrameTime(times); const avgFrameTime = getAvgFrameTime(times);
const totalTime = times.reduce((a, b) => a + b);
const avgFrameTime = lessPrecise(totalTime / Debug.FRAME_COUNT);
console.info( console.info(
name, name,
`- ${times.length} calls - ${avgFrameTime}ms/frame across ${ `${times.length} runs: ${avgFrameTime}ms across ${
Debug.FRAME_COUNT Debug.FRAME_COUNT
} frames (${lessPrecise( } frames (${getFps(avgFrameTime)} fps ~ ${lessPrecise(
(avgFrameTime / 16.67) * 100, (avgFrameTime / 16.67) * 100,
1, 1,
)}% of frame budget)`, )}% of frame budget)`,
@@ -136,7 +136,7 @@ export class Debug {
return (...args: T) => { return (...args: T) => {
const t0 = performance.now(); const t0 = performance.now();
const ret = fn(...args); const ret = fn(...args);
Debug[type](performance.now() - t0, name); Debug.logTime(performance.now() - t0, name);
return ret; return ret;
}; };
}; };
-5
View File
@@ -102,10 +102,6 @@ export default defineConfig(({ mode }) => {
// Taking the substring after "locales/" // Taking the substring after "locales/"
return `locales/${id.substring(index + 8)}`; return `locales/${id.substring(index + 8)}`;
} }
if (id.includes("@excalidraw/mermaid-to-excalidraw")) {
return "mermaid-to-excalidraw";
}
}, },
}, },
}, },
@@ -200,7 +196,6 @@ export default defineConfig(({ mode }) => {
}, },
}, },
], ],
maximumFileSizeToCacheInBytes: 2.3 * 1024 ** 2, // 2.3MB
}, },
manifest: { manifest: {
short_name: "Excalidraw", short_name: "Excalidraw",
@@ -1,97 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`COLOR_PALETTE > color palette doesn't regress 1`] = `
{
"black": "#1e1e1e",
"blue": [
"#e7f5ff",
"#a5d8ff",
"#4dabf7",
"#228be6",
"#1971c2",
],
"bronze": [
"#f8f1ee",
"#eaddd7",
"#d2bab0",
"#a18072",
"#846358",
],
"cyan": [
"#e3fafc",
"#99e9f2",
"#3bc9db",
"#15aabf",
"#0c8599",
],
"grape": [
"#f8f0fc",
"#eebefa",
"#da77f2",
"#be4bdb",
"#9c36b5",
],
"gray": [
"#f8f9fa",
"#e9ecef",
"#ced4da",
"#868e96",
"#343a40",
],
"green": [
"#ebfbee",
"#b2f2bb",
"#69db7c",
"#40c057",
"#2f9e44",
],
"orange": [
"#fff4e6",
"#ffd8a8",
"#ffa94d",
"#fd7e14",
"#e8590c",
],
"pink": [
"#fff0f6",
"#fcc2d7",
"#f783ac",
"#e64980",
"#c2255c",
],
"red": [
"#fff5f5",
"#ffc9c9",
"#ff8787",
"#fa5252",
"#e03131",
],
"teal": [
"#e6fcf5",
"#96f2d7",
"#38d9a9",
"#12b886",
"#099268",
],
"transparent": "transparent",
"violet": [
"#f3f0ff",
"#d0bfff",
"#9775fa",
"#7950f2",
"#6741d9",
],
"white": "#ffffff",
"yellow": [
"#fff9db",
"#ffec99",
"#ffd43b",
"#fab005",
"#f08c00",
],
}
`;
exports[`applyDarkModeFilter > COLOR_PALETTE regression tests > matches snapshot for all palette colors 1`] = ` exports[`applyDarkModeFilter > COLOR_PALETTE regression tests > matches snapshot for all palette colors 1`] = `
{ {
"black": "#d3d3d3", "black": "#d3d3d3",
-6
View File
@@ -4,12 +4,6 @@ import {
rgbToHex, rgbToHex,
} from "@excalidraw/common"; } from "@excalidraw/common";
describe("COLOR_PALETTE", () => {
it("color palette doesn't regress", () => {
expect(COLOR_PALETTE).toMatchSnapshot();
});
});
describe("applyDarkModeFilter", () => { describe("applyDarkModeFilter", () => {
describe("basic transformations", () => { describe("basic transformations", () => {
it("transforms black to near-white", () => { it("transforms black to near-white", () => {
+48 -102
View File
@@ -1,3 +1,4 @@
import oc from "open-color";
import tinycolor from "tinycolor2"; import tinycolor from "tinycolor2";
import { clamp } from "@excalidraw/math"; import { clamp } from "@excalidraw/math";
@@ -5,14 +6,18 @@ import { degreesToRadians } from "@excalidraw/math";
import type { Degrees } from "@excalidraw/math"; import type { Degrees } from "@excalidraw/math";
// --------------------------------------------------------------------------- import type { Merge } from "./utility-types";
// Dark mode color transformation
// --------------------------------------------------------------------------- export { tinycolor };
// Browser-only cache to avoid memory leaks on server // Browser-only cache to avoid memory leaks on server
const DARK_MODE_COLORS_CACHE: Map<string, string> | null = const DARK_MODE_COLORS_CACHE: Map<string, string> | null =
typeof window !== "undefined" ? new Map() : null; typeof window !== "undefined" ? new Map() : null;
// ---------------------------------------------------------------------------
// Dark mode color transformation
// ---------------------------------------------------------------------------
function cssHueRotate( function cssHueRotate(
red: number, red: number,
green: number, green: number,
@@ -110,8 +115,8 @@ export const applyDarkModeFilter = (color: string): string => {
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Color palette
// --------------------------------------------------------------------------- export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
// FIXME can't put to utils.ts rn because of circular dependency // FIXME can't put to utils.ts rn because of circular dependency
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>( const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
@@ -126,7 +131,15 @@ const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
}, {} as Pick<R, K[number]>) as Pick<R, K[number]>; }, {} as Pick<R, K[number]>) as Pick<R, K[number]>;
}; };
export type ColorPickerColor =
| Exclude<keyof oc, "indigo" | "lime">
| "transparent"
| "bronze";
export type ColorTuple = readonly [string, string, string, string, string]; export type ColorTuple = readonly [string, string, string, string, string];
export type ColorPalette = Merge<
Record<ColorPickerColor, ColorTuple>,
{ black: "#1e1e1e"; white: "#ffffff"; transparent: "transparent" }
>;
// used general type instead of specific type (ColorPalette) to support custom colors // used general type instead of specific type (ColorPalette) to support custom colors
export type ColorPaletteCustom = { [key: string]: ColorTuple | string }; export type ColorPaletteCustom = { [key: string]: ColorTuple | string };
@@ -139,30 +152,38 @@ export const DEFAULT_CHART_COLOR_INDEX = 4;
export const DEFAULT_ELEMENT_STROKE_COLOR_INDEX = 4; export const DEFAULT_ELEMENT_STROKE_COLOR_INDEX = 4;
export const DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX = 1; export const DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX = 1;
export const ELEMENTS_PALETTE_SHADE_INDEXES = [0, 2, 4, 6, 8] as const;
export const CANVAS_PALETTE_SHADE_INDEXES = [0, 1, 2, 3, 4] as const;
export const getSpecificColorShades = (
color: Exclude<
ColorPickerColor,
"transparent" | "white" | "black" | "bronze"
>,
indexArr: Readonly<ColorShadesIndexes>,
) => {
return indexArr.map((index) => oc[color][index]) as any as ColorTuple;
};
export const COLOR_PALETTE = { export const COLOR_PALETTE = {
transparent: "transparent", transparent: "transparent",
black: "#1e1e1e", black: "#1e1e1e",
white: "#ffffff", white: "#ffffff",
// open-color from https://github.com/yeun/open-color/blob/master/open-color.js // open-colors
// corresponds to indexes [0,2,4,6,8] (weights: 50, 200, 400, 600, 800) gray: getSpecificColorShades("gray", ELEMENTS_PALETTE_SHADE_INDEXES),
gray: ["#f8f9fa", "#e9ecef", "#ced4da", "#868e96", "#343a40"], red: getSpecificColorShades("red", ELEMENTS_PALETTE_SHADE_INDEXES),
red: ["#fff5f5", "#ffc9c9", "#ff8787", "#fa5252", "#e03131"], pink: getSpecificColorShades("pink", ELEMENTS_PALETTE_SHADE_INDEXES),
pink: ["#fff0f6", "#fcc2d7", "#f783ac", "#e64980", "#c2255c"], grape: getSpecificColorShades("grape", ELEMENTS_PALETTE_SHADE_INDEXES),
grape: ["#f8f0fc", "#eebefa", "#da77f2", "#be4bdb", "#9c36b5"], violet: getSpecificColorShades("violet", ELEMENTS_PALETTE_SHADE_INDEXES),
violet: ["#f3f0ff", "#d0bfff", "#9775fa", "#7950f2", "#6741d9"], blue: getSpecificColorShades("blue", ELEMENTS_PALETTE_SHADE_INDEXES),
blue: ["#e7f5ff", "#a5d8ff", "#4dabf7", "#228be6", "#1971c2"], cyan: getSpecificColorShades("cyan", ELEMENTS_PALETTE_SHADE_INDEXES),
cyan: ["#e3fafc", "#99e9f2", "#3bc9db", "#15aabf", "#0c8599"], teal: getSpecificColorShades("teal", ELEMENTS_PALETTE_SHADE_INDEXES),
teal: ["#e6fcf5", "#96f2d7", "#38d9a9", "#12b886", "#099268"], green: getSpecificColorShades("green", ELEMENTS_PALETTE_SHADE_INDEXES),
green: ["#ebfbee", "#b2f2bb", "#69db7c", "#40c057", "#2f9e44"], yellow: getSpecificColorShades("yellow", ELEMENTS_PALETTE_SHADE_INDEXES),
yellow: ["#fff9db", "#ffec99", "#ffd43b", "#fab005", "#f08c00"], orange: getSpecificColorShades("orange", ELEMENTS_PALETTE_SHADE_INDEXES),
orange: ["#fff4e6", "#ffd8a8", "#ffa94d", "#fd7e14", "#e8590c"], // radix bronze shades 3,5,7,9,11
// radix bronze shades [3,5,7,9,11]
bronze: ["#f8f1ee", "#eaddd7", "#d2bab0", "#a18072", "#846358"], bronze: ["#f8f1ee", "#eaddd7", "#d2bab0", "#a18072", "#846358"],
} as const; } as ColorPalette;
export type ColorPalette = typeof COLOR_PALETTE;
export type ColorPickerColor = keyof typeof COLOR_PALETTE;
const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
"cyan", "cyan",
@@ -177,6 +198,7 @@ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
"red", "red",
]); ]);
// -----------------------------------------------------------------------------
// quick picks defaults // quick picks defaults
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -211,6 +233,7 @@ export const DEFAULT_CANVAS_BACKGROUND_PICKS = [
"#fdf8f6", "#fdf8f6",
] as ColorTuple; ] as ColorTuple;
// -----------------------------------------------------------------------------
// palette defaults // palette defaults
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -236,7 +259,8 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
...COMMON_ELEMENT_SHADES, ...COMMON_ELEMENT_SHADES,
} as const; } as const;
// color palette helpers // -----------------------------------------------------------------------------
// helpers
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// !!!MUST BE WITHOUT GRAY, TRANSPARENT AND BLACK!!! // !!!MUST BE WITHOUT GRAY, TRANSPARENT AND BLACK!!!
@@ -257,10 +281,6 @@ export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) =>
COLOR_PALETTE.red[index], COLOR_PALETTE.red[index],
] as const; ] as const;
// -----------------------------------------------------------------------------
// other helpers
// -----------------------------------------------------------------------------
export const rgbToHex = (r: number, g: number, b: number, a?: number) => { export const rgbToHex = (r: number, g: number, b: number, a?: number) => {
// (1 << 24) adds 0x1000000 to ensure the hex string is always 7 chars, // (1 << 24) adds 0x1000000 to ensure the hex string is always 7 chars,
// then slice(1) removes the leading "1" to get exactly 6 hex digits // then slice(1) removes the leading "1" to get exactly 6 hex digits
@@ -279,78 +299,4 @@ export const rgbToHex = (r: number, g: number, b: number, a?: number) => {
return hex6; return hex6;
}; };
/**
* @returns #RRGGBB or #RRGGBBAA based on color containing non-opaque alpha,
* null if not valid color
*/
export const colorToHex = (color: string): string | null => {
const tc = tinycolor(color);
if (!tc.isValid()) {
return null;
}
const { r, g, b, a } = tc.toRgb();
return rgbToHex(r, g, b, a);
};
export const isTransparent = (color: string) => {
return tinycolor(color).getAlpha() === 0;
};
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// color contract helpers
// -----------------------------------------------------------------------------
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
const calculateContrast = (r: number, g: number, b: number): number => {
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
return yiq;
};
// YIQ algo, inspiration from https://stackoverflow.com/a/11868398
export const isColorDark = (color: string, threshold = 160): boolean => {
// no color ("") -> assume it default to black
if (!color) {
return true;
}
if (isTransparent(color)) {
return false;
}
const tc = tinycolor(color);
if (!tc.isValid()) {
// invalid color -> assume it defaults to black
return true;
}
const { r, g, b } = tc.toRgb();
return calculateContrast(r, g, b) < threshold;
};
// -----------------------------------------------------------------------------
// normalization
// -----------------------------------------------------------------------------
/**
* tries to keep the input color as-is if it's valid, making minimal adjustments
* (trimming whitespace or adding `#` to hex colors)
*/
export const normalizeInputColor = (color: string): string | null => {
color = color.trim();
if (isTransparent(color)) {
return color;
}
const tc = tinycolor(color);
if (tc.isValid()) {
// testing for `#` first fixes a bug on Electron (more specfically, an
// Obsidian popout window), where a hex color without `#` is considered valid
if (tc.getFormat() === "hex" && !color.startsWith("#")) {
return `#${color}`;
}
return color;
}
return null;
};
-2
View File
@@ -190,8 +190,6 @@ export const THEME = {
DARK: "dark", DARK: "dark",
} as const; } as const;
export const DARK_THEME_FILTER = "invert(93%) hue-rotate(180deg)";
export const FRAME_STYLE = { export const FRAME_STYLE = {
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"], strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
strokeWidth: 2 as ExcalidrawElement["strokeWidth"], strokeWidth: 2 as ExcalidrawElement["strokeWidth"],
+3 -2
View File
@@ -16,6 +16,7 @@ export type EditorInterface = Readonly<{
const DESKTOP_UI_MODE_STORAGE_KEY = "excalidraw.desktopUIMode"; const DESKTOP_UI_MODE_STORAGE_KEY = "excalidraw.desktopUIMode";
// breakpoints // breakpoints
// mobile: up to 699px
export const MQ_MAX_MOBILE = 599; export const MQ_MAX_MOBILE = 599;
export const MQ_MAX_WIDTH_LANDSCAPE = 1000; export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
@@ -23,9 +24,9 @@ export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
// tablets // tablets
export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones) export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones)
export const MQ_MAX_TABLET = 1180; // ipad air export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops)
// desktop/laptop (NOTE: not used for form factor detection) // desktop/laptop
export const MQ_MIN_WIDTH_DESKTOP = 1440; export const MQ_MIN_WIDTH_DESKTOP = 1440;
// sidebar // sidebar
+1
View File
@@ -11,4 +11,5 @@ export * from "./random";
export * from "./url"; export * from "./url";
export * from "./utils"; export * from "./utils";
export * from "./emitter"; export * from "./emitter";
export * from "./visualdebug";
export * from "./editorInterface"; export * from "./editorInterface";
+5
View File
@@ -10,6 +10,7 @@ import type {
Zoom, Zoom,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import { tinycolor } from "./colors";
import { import {
DEFAULT_VERSION, DEFAULT_VERSION,
ENV, ENV,
@@ -547,6 +548,10 @@ export const mapFind = <T, K>(
return undefined; return undefined;
}; };
export const isTransparent = (color: string) => {
return tinycolor(color).getAlpha() === 0;
};
export type ResolvablePromise<T> = Promise<T> & { export type ResolvablePromise<T> = Promise<T> & {
resolve: [T] extends [undefined] resolve: [T] extends [undefined]
? (value?: MaybePromise<Awaited<T>>) => void ? (value?: MaybePromise<Awaited<T>>) => void
@@ -1,24 +1,16 @@
import { import {
isLineSegment, isLineSegment,
lineSegment, lineSegment,
pointDistanceSq,
pointFrom, pointFrom,
type GlobalPoint, type GlobalPoint,
type LocalPoint, type LocalPoint,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { type Bounds, isBounds } from "@excalidraw/common";
import {
getElementBounds,
intersectElementWithLineSegment,
isFreeDrawElement,
isLinearElement,
isPathALoop,
} from "@excalidraw/element";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import type { Curve } from "@excalidraw/math"; import type { Curve } from "@excalidraw/math";
import type { LineSegment } from "@excalidraw/utils"; import type { LineSegment } from "@excalidraw/utils";
import { type Bounds, isBounds } from "./bounds";
// The global data holder to collect the debug operations // The global data holder to collect the debug operations
declare global { declare global {
interface Window { interface Window {
@@ -29,71 +21,14 @@ declare global {
} }
} }
export type Polygon = GlobalPoint[];
export type DebugElement = { export type DebugElement = {
color: string; color: string;
data: LineSegment<GlobalPoint> | Curve<GlobalPoint> | DebugPolygon; data: LineSegment<GlobalPoint> | Curve<GlobalPoint> | Polygon;
permanent: boolean; permanent: boolean;
}; };
export type DebugPolygon = {
type: "polygon";
points: GlobalPoint[];
fill?: boolean;
close?: boolean;
};
export const debugDrawHitVolume = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
options?: {
rays?: number;
color?: string;
fill?: boolean;
},
) => {
if (
(isLinearElement(element) || isFreeDrawElement(element)) &&
!isPathALoop(element.points)
) {
return;
}
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
const rays = options?.rays ?? 100;
const radius = Math.max(x2 - x1, y2 - y1) * 2;
const points: GlobalPoint[] = [];
for (let i = 0; i < rays; i += 1) {
const angle = (i / rays) * Math.PI * 2;
const end = pointFrom<GlobalPoint>(
center[0] + Math.cos(angle) * radius,
center[1] + Math.sin(angle) * radius,
);
const hits = intersectElementWithLineSegment(
element,
elementsMap,
lineSegment(center, end),
);
if (hits.length === 0) {
continue;
}
hits.sort(pointDistanceSq);
points.push(hits[0]);
}
if (points.length >= 3) {
debugDrawPolygon(points, {
color: options?.color ?? "orange",
fill: options?.fill ?? true,
});
} else {
console.warn(
`debugDrawHitVolume: could not compute hit volume for element ${element.id}`,
);
}
};
export const debugDrawCubicBezier = ( export const debugDrawCubicBezier = (
c: Curve<GlobalPoint>, c: Curve<GlobalPoint>,
opts?: { opts?: {
@@ -128,31 +63,6 @@ export const debugDrawLine = (
); );
}; };
export const debugDrawPolygon = (
points: GlobalPoint[],
opts?: {
color?: string;
permanent?: boolean;
fill?: boolean;
close?: boolean;
},
) => {
if (points.length < 2) {
return;
}
addToCurrentFrame({
color: opts?.color ?? "orange",
permanent: !!opts?.permanent,
data: {
type: "polygon",
points,
fill: opts?.fill,
close: opts?.close,
},
});
};
export const debugDrawPoint = ( export const debugDrawPoint = (
p: GlobalPoint, p: GlobalPoint,
opts?: { opts?: {
@@ -193,7 +103,7 @@ export const debugDrawBounds = (
permanent?: boolean; permanent?: boolean;
}, },
) => { ) => {
(isBounds(box) ? [box] : box).forEach((bbox: Bounds) => (isBounds(box) ? [box] : box).forEach((bbox) =>
debugDrawLine( debugDrawLine(
[ [
lineSegment( lineSegment(
@@ -221,6 +131,20 @@ export const debugDrawBounds = (
); );
}; };
export const debugDrawPolygon = (
points: GlobalPoint[],
opts?: {
color?: string;
permanent?: boolean;
},
) => {
addToCurrentFrame({
color: opts?.color ?? "blue",
data: points,
permanent: !!opts?.permanent,
});
};
export const debugDrawPoints = ( export const debugDrawPoints = (
{ {
x, x,
-6
View File
@@ -17,12 +17,6 @@
"development": "./dist/dev/index.js", "development": "./dist/dev/index.js",
"production": "./dist/prod/index.js", "production": "./dist/prod/index.js",
"default": "./dist/prod/index.js" "default": "./dist/prod/index.js"
},
"./visualdebug": {
"types": "./dist/types/element/src/visualdebug.d.ts",
"development": "./dist/dev/visualdebug.js",
"production": "./dist/prod/visualdebug.js",
"default": "./dist/prod/visualdebug.js"
} }
}, },
"files": [ "files": [
+1 -7
View File
@@ -15,7 +15,6 @@ import {
pointFrom, pointFrom,
pointFromVector, pointFromVector,
pointRotateRads, pointRotateRads,
pointsEqual,
vectorFromPoint, vectorFromPoint,
vectorNormalize, vectorNormalize,
vectorScale, vectorScale,
@@ -1603,12 +1602,7 @@ export const updateBoundPoint = (
if ( if (
binding == null || binding == null ||
// We only need to update the other end if this is a 2 point line element // We only need to update the other end if this is a 2 point line element
(binding.elementId !== bindableElement.id && arrow.points.length > 2) || (binding.elementId !== bindableElement.id && arrow.points.length > 2)
// Initial arrow created on pointer down needs to not update the points
pointsEqual(
arrow.points[arrow.points.length - 1],
pointFrom<LocalPoint>(0, 0),
)
) { ) {
return null; return null;
} }
+1 -34
View File
@@ -105,12 +105,6 @@ export type HitTestArgs = {
overrideShouldTestInside?: boolean; overrideShouldTestInside?: boolean;
}; };
let cachedPoint: GlobalPoint | null = null;
let cachedElement: WeakRef<ExcalidrawElement> | null = null;
let cachedThreshold: number = Infinity;
let cachedHit: boolean = false;
let cachedOverrideShouldTestInside = false;
export const hitElementItself = ({ export const hitElementItself = ({
point, point,
element, element,
@@ -119,24 +113,6 @@ export const hitElementItself = ({
frameNameBound = null, frameNameBound = null,
overrideShouldTestInside = false, overrideShouldTestInside = false,
}: HitTestArgs) => { }: HitTestArgs) => {
// Return cached result if the same point and element version is tested again
if (
cachedPoint &&
pointsEqual(point, cachedPoint) &&
cachedThreshold <= threshold &&
overrideShouldTestInside === cachedOverrideShouldTestInside
) {
const derefElement = cachedElement?.deref();
if (
derefElement &&
derefElement.id === element.id &&
derefElement.version === element.version &&
derefElement.versionNonce === element.versionNonce
) {
return cachedHit;
}
}
// Hit test against a frame's name // Hit test against a frame's name
const hitFrameName = frameNameBound const hitFrameName = frameNameBound
? isPointWithinBounds( ? isPointWithinBounds(
@@ -177,16 +153,7 @@ export const hitElementItself = ({
isPointOnElementOutline(point, element, elementsMap, threshold) isPointOnElementOutline(point, element, elementsMap, threshold)
: isPointOnElementOutline(point, element, elementsMap, threshold); : isPointOnElementOutline(point, element, elementsMap, threshold);
const result = hitElement || hitFrameName; return hitElement || hitFrameName;
// Cache end result
cachedPoint = point;
cachedElement = new WeakRef(element);
cachedThreshold = threshold;
cachedOverrideShouldTestInside = overrideShouldTestInside;
cachedHit = result;
return result;
}; };
export const hitElementBoundingBox = ( export const hitElementBoundingBox = (
+9 -20
View File
@@ -1,12 +1,10 @@
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
import { updateBoundElements } from "./binding";
import { getCommonBoundingBox } from "./bounds"; import { getCommonBoundingBox } from "./bounds";
import { newElementWith } from "./mutateElement";
import { getSelectedElementsByGroup } from "./groups"; import { getSelectedElementsByGroup } from "./groups";
import type { Scene } from "./Scene";
import type { ElementsMap, ExcalidrawElement } from "./types"; import type { ElementsMap, ExcalidrawElement } from "./types";
export interface Distribution { export interface Distribution {
@@ -19,7 +17,6 @@ export const distributeElements = (
elementsMap: ElementsMap, elementsMap: ElementsMap,
distribution: Distribution, distribution: Distribution,
appState: Readonly<AppState>, appState: Readonly<AppState>,
scene: Scene,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const [start, mid, end, extent] = const [start, mid, end, extent] =
distribution.axis === "x" distribution.axis === "x"
@@ -69,16 +66,12 @@ export const distributeElements = (
translation[distribution.axis] = pos - box[mid]; translation[distribution.axis] = pos - box[mid];
} }
return group.map((element) => { return group.map((element) =>
const updatedElement = scene.mutateElement(element, { newElementWith(element, {
x: element.x + translation.x, x: element.x + translation.x,
y: element.y + translation.y, y: element.y + translation.y,
}); }),
updateBoundElements(element, scene, { );
simultaneouslyUpdated: group,
});
return updatedElement;
});
}); });
} }
@@ -97,15 +90,11 @@ export const distributeElements = (
pos += step; pos += step;
pos += box[extent]; pos += box[extent];
return group.map((element) => { return group.map((element) =>
const updatedElement = scene.mutateElement(element, { newElementWith(element, {
x: element.x + translation.x, x: element.x + translation.x,
y: element.y + translation.y, y: element.y + translation.y,
}); }),
updateBoundElements(element, scene, { );
simultaneouslyUpdated: group,
});
return updatedElement;
});
}); });
}; };
+546
View File
@@ -0,0 +1,546 @@
import {
pointFrom,
pointDistance,
type LocalPoint,
vectorFromPoint,
vectorNormalize,
lineSegment,
type GlobalPoint,
type Polygon,
polygonFromPoints,
} from "@excalidraw/math";
import { debugDrawLine, debugDrawPolygon } from "@excalidraw/common";
import type { ExcalidrawFreeDrawElement } from "./types";
// Number of segments to approximate each semicircular cap
const CAP_SEGMENTS = 20;
// Minimum radius to avoid degenerate shapes
const MIN_RADIUS = 0.05;
// Pressure to radius multiplier (scaled by strokeWidth)
const PRESSURE_RADIUS_MULTIPLIER = 2.0;
// Minimum distance between points to avoid numerical instability
const MIN_POINT_DISTANCE = 0.001;
// Epsilon for filtering near-duplicate points in polygons
const EPSILON = 0.01;
// Simple union implementation taking advantage of the following facts:
// - The ovoids are generated in sequence along the stroke path
// - Each ovoid overlaps only with its immediate neighbors
// - The ovoids are convex shapes
// Therefore, we can simply stitch together the outer edges of the ovoids
// by taking the first half of the first ovoid and the second half of the last ovoid,
// and connecting them with the outer edges of the intermediate ovoids. The overlapping
// ovoid caps are always the same radius at the shared points, so they align perfectly.
// It should be easy to find the closest point to the side of the previous side segment and
// one of the closest points on the next ovoid's start cap.
function chainOvoidsIntoSinglePolygon<P extends LocalPoint | GlobalPoint>(
records: {
polygon: Polygon<P>;
firstPoint: P;
secondPoint: P;
}[],
): Polygon<P> | null {
if (records.length === 0) {
return null;
}
if (records.length === 1) {
return records[0].polygon;
}
const capPointCount = CAP_SEGMENTS + 1;
const isClosedPolygon = (points: P[]) => {
if (points.length < 2) {
return false;
}
const first = points[0];
const last = points[points.length - 1];
return first[0] === last[0] && first[1] === last[1];
};
const openPolygon = (points: P[]) =>
isClosedPolygon(points) ? points.slice(0, -1) : points.slice();
const distanceSq = (a: P, b: P) => {
const dx = a[0] - b[0];
const dy = a[1] - b[1];
return dx * dx + dy * dy;
};
const distanceToSegmentSq = (p: P, a: P, b: P) => {
const abx = b[0] - a[0];
const aby = b[1] - a[1];
const apx = p[0] - a[0];
const apy = p[1] - a[1];
const abLenSq = abx * abx + aby * aby;
if (abLenSq === 0) {
return distanceSq(p, a);
}
const t = Math.max(0, Math.min(1, (apx * abx + apy * aby) / abLenSq));
const closest = pointFrom<P>(a[0] + abx * t, a[1] + aby * t);
return distanceSq(p, closest);
};
const closestIndexToSegment = (points: P[], a: P, b: P) => {
let bestIndex = 0;
let bestDistance = Number.POSITIVE_INFINITY;
for (let i = 0; i < points.length; i++) {
const dist = distanceToSegmentSq(points[i], a, b);
if (dist < bestDistance) {
bestDistance = dist;
bestIndex = i;
}
}
return bestIndex;
};
const pushIfDistinct = (points: P[], point: P) => {
if (points.length === 0) {
points.push(point);
return;
}
if (distanceSq(points[points.length - 1], point) > EPSILON * EPSILON) {
points.push(point);
}
};
const ovoids = records.map((record) => {
const open = openPolygon(record.polygon);
if (open.length < capPointCount * 2) {
return {
cap1: open,
cap2: [] as P[],
p1Right: open[0],
p1Left: open[open.length - 1],
p2Left: open[0],
p2Right: open[open.length - 1],
};
}
const cap1 = open.slice(0, capPointCount);
const cap2 = open.slice(capPointCount, capPointCount * 2);
return {
cap1,
cap2,
p1Right: cap1[0],
p1Left: cap1[cap1.length - 1],
p2Left: cap2[0],
p2Right: cap2[cap2.length - 1],
};
});
const leftChain: P[] = [];
const rightChain: P[] = [];
ovoids[0].cap1.forEach((point) => pushIfDistinct(leftChain, point));
pushIfDistinct(rightChain, ovoids[0].p1Right);
for (let i = 0; i < ovoids.length; i++) {
const current = ovoids[i];
pushIfDistinct(leftChain, current.p2Left);
pushIfDistinct(rightChain, current.p2Right);
if (i + 1 >= ovoids.length) {
continue;
}
const next = ovoids[i + 1];
const leftIndex = closestIndexToSegment(
next.cap1,
current.p1Left,
current.p2Left,
);
for (let j = leftIndex; j < next.cap1.length; j++) {
pushIfDistinct(leftChain, next.cap1[j]);
}
const rightIndex = closestIndexToSegment(
next.cap1,
current.p1Right,
current.p2Right,
);
for (let j = rightIndex; j >= 0; j--) {
pushIfDistinct(rightChain, next.cap1[j]);
}
}
const lastOvoid = ovoids[ovoids.length - 1];
lastOvoid.cap2.forEach((point) => pushIfDistinct(leftChain, point));
const rightChainReversed = rightChain.slice(0, -1).reverse();
const outline = filterNearDuplicates<P>([
...leftChain,
...rightChainReversed,
]);
return polygonFromPoints<P>(outline);
}
/**
* Compute the radius for a point based on pressure and strokeWidth.
* Pressure is typically in [0, 1] range, default to 0.5 if simulating.
*/
function getRadiusForPressure(pressure: number, strokeWidth: number): number {
return Math.max(
MIN_RADIUS,
pressure * strokeWidth * PRESSURE_RADIUS_MULTIPLIER,
);
}
/**
* Generate points along a semicircular arc (dome/cap).
* The arc goes from startAngle to endAngle (counterclockwise).
*
* @param center - Center point of the arc
* @param radius - Radius of the arc
* @param startAngle - Start angle in radians
* @param endAngle - End angle in radians (counterclockwise from start)
* @param segments - Number of segments to divide the arc into
* @returns Array of points along the arc
*/
function generateArcPoints<P extends LocalPoint | GlobalPoint>(
center: LocalPoint,
radius: number,
startAngle: number,
endAngle: number,
segments: number,
): P[] {
const points: P[] = [];
const angleStep = (endAngle - startAngle) / segments;
for (let i = 0; i <= segments; i++) {
const angle = startAngle + i * angleStep;
points.push(
pointFrom<P>(
center[0] + radius * Math.cos(angle),
center[1] + radius * Math.sin(angle),
),
);
}
return points;
}
/**
* Create an ovoid shape between two consecutive points.
* The ovoid consists of:
* - A semicircular cap at the first point (facing away from point 2)
* - A semicircular cap at the second point (facing away from point 1)
* - Connecting lines (tangent lines between the two circles)
*
* @param p1 - First point
* @param r1 - Radius at first point (from pressure)
* @param p2 - Second point
* @param r2 - Radius at second point (from pressure)
* @param forcePerpendicularCap1 - Force cap1 to be perpendicular (for stroke start)
* @param forcePerpendicularCap2 - Force cap2 to be perpendicular (for stroke end)
* @returns Array of points forming the ovoid polygon
*/
function createOvoid<P extends LocalPoint | GlobalPoint>(
p1: LocalPoint,
r1: number,
p2: LocalPoint,
r2: number,
forcePerpendicularCap1: boolean = false,
forcePerpendicularCap2: boolean = false,
): Polygon<P> {
const dist = pointDistance(p1, p2);
// If points are too close, create a circle at the midpoint
if (dist < MIN_POINT_DISTANCE) {
const avgRadius = (r1 + r2) / 2;
return polygonFromPoints<P>(
generateArcPoints(p1, avgRadius, 0, Math.PI * 2, CAP_SEGMENTS * 2),
);
}
// Direction vector from p1 to p2
const dirVec = vectorFromPoint(p2, p1);
const normalizedDir = vectorNormalize(dirVec);
// Calculate the angle of the direction vector
const baseAngle = Math.atan2(normalizedDir[1], normalizedDir[0]);
// For connecting the circles with tangent lines when radii differ,
// we need to compute the tangent points
// When r1 != r2, the tangent lines are not perpendicular to the center line
// Tangent angle offset (when radii differ)
const radiusDiff = r1 - r2;
const tangentAngleOffset =
dist > Math.abs(radiusDiff) ? Math.asin(radiusDiff / dist) : 0;
// Compute tangent points on each circle
// The perpendicular offset needs to be adjusted for the tangent angle
const tangentPerpAngle = baseAngle + Math.PI / 2 + tangentAngleOffset;
const purePerpAngle = baseAngle + Math.PI / 2;
// Cap at p1 (semicircle facing AWAY from p2, i.e., toward baseAngle + PI)
// Use perpendicular cap for stroke start, otherwise use tangent-adjusted
const p1PerpAngle = forcePerpendicularCap1 ? purePerpAngle : tangentPerpAngle;
const p1RightAngle = p1PerpAngle;
const p1LeftAngle = p1PerpAngle + Math.PI;
const cap1Points = generateArcPoints<P>(
p1,
r1,
p1RightAngle,
p1LeftAngle,
CAP_SEGMENTS,
);
// Cap at p2 (semicircle facing AWAY from p1, i.e., toward baseAngle)
// Use perpendicular cap for stroke end, otherwise use tangent-adjusted
const p2PerpAngle = forcePerpendicularCap2 ? purePerpAngle : tangentPerpAngle;
const p2LeftAngle = p2PerpAngle + Math.PI;
const p2RightAngle = p2LeftAngle + Math.PI;
const cap2Points = generateArcPoints<P>(
p2,
r2,
p2LeftAngle,
p2RightAngle,
CAP_SEGMENTS,
);
// Assemble the ovoid polygon:
// cap1 goes around the back of p1, cap2 goes around the front of p2
// The arc endpoints naturally connect with the tangent lines
const ovoidPoints: P[] = [
...cap1Points, // p1's back cap
...cap2Points, // p2's front cap
];
// Filter out near-duplicate consecutive points to avoid numerical issues
return polygonFromPoints<P>(filterNearDuplicates<P>(ovoidPoints));
}
/**
* Filter out consecutive points that are too close together.
* This prevents numerical instability in polygon boolean operations.
*/
function filterNearDuplicates<P extends LocalPoint | GlobalPoint>(
points: P[],
): P[] {
if (points.length < 2) {
return points;
}
const filtered: P[] = [points[0]];
for (let i = 1; i < points.length; i++) {
const prev = filtered[filtered.length - 1];
const curr = points[i];
const dx = curr[0] - prev[0];
const dy = curr[1] - prev[1];
const distSq = dx * dx + dy * dy;
// Only add if far enough from previous point
if (distSq > EPSILON * EPSILON) {
filtered.push(curr);
}
}
// Also check if last point is too close to first point
if (filtered.length > 2) {
const first = filtered[0];
const last = filtered[filtered.length - 1];
const dx = last[0] - first[0];
const dy = last[1] - first[1];
const distSq = dx * dx + dy * dy;
if (distSq < EPSILON * EPSILON) {
filtered.pop();
}
}
return filtered.length >= 3 ? filtered : points;
}
/**
* Generate the outline of a freedraw element using the ovoid-union approach.
*
* This creates ovoid shapes between each consecutive pair of points,
* where each ovoid is defined by:
* - Semicircular caps at each point (radius based on pressure)
* - Connecting tangent lines between the caps
*
* All ovoids are then unioned together to form the final outline.
*
* @param element - The freedraw element to generate outline for
* @returns Array of [x, y] points representing the outline polygon
*/
export function generateFreeDrawOvoidOutline(
element: ExcalidrawFreeDrawElement,
): [number, number][] {
const { x, y, points, pressures, simulatePressure, strokeWidth } = element;
// Debug draw the raw segments from the freedraw element points
const colors = ["red", "green", "blue", "orange", "purple"];
points.forEach((pt, i) => {
if (i === points.length - 1) {
return;
}
debugDrawLine(
lineSegment(
pointFrom<GlobalPoint>(x + pt[0], y + pt[1]),
pointFrom<GlobalPoint>(x + points[i + 1][0], y + points[i + 1][1]),
),
{
color: colors[i % colors.length],
permanent: true,
},
);
});
if (points.length === 0) {
return [];
}
// Single point: just return a circle
if (points.length === 1) {
const pressure = simulatePressure ? 0.5 : pressures[0] ?? 0.5;
const radius = getRadiusForPressure(pressure, strokeWidth);
const circlePoints = generateArcPoints(
points[0] as LocalPoint,
radius,
0,
Math.PI * 2,
CAP_SEGMENTS * 2,
);
return circlePoints.map((p) => [p[0], p[1]]);
}
// Generate ovoids for each consecutive pair of points
const ovoids: Polygon<LocalPoint>[] = [];
for (let i = 0; i < points.length - 1; i++) {
const p1 = points[i] as LocalPoint;
const p2 = points[i + 1] as LocalPoint;
// Get pressures (use 0.5 as default when simulating)
const pressure1 = simulatePressure ? 0.5 : pressures[i] ?? 0.5;
const pressure2 = simulatePressure ? 0.5 : pressures[i + 1] ?? 0.5;
const r1 = getRadiusForPressure(pressure1, strokeWidth);
const r2 = getRadiusForPressure(pressure2, strokeWidth);
// Force perpendicular caps at stroke endpoints
const isFirstSegment = i === 0;
const isLastSegment = i === points.length - 2;
const ovoidPoints = createOvoid<LocalPoint>(
p1,
r1,
p2,
r2,
isFirstSegment, // Force perpendicular cap at stroke start
isLastSegment, // Force perpendicular cap at stroke end
);
// Draw the ovoid with different colors for debugging
debugDrawPolygon(
ovoidPoints.map((p) => pointFrom<GlobalPoint>(x + p[0], y + p[1])),
{
color: colors[i % colors.length],
permanent: true,
},
);
if (ovoidPoints.length >= 3) {
ovoids.push(ovoidPoints);
}
}
if (ovoids.length === 0) {
return [];
}
// Union all ovoids together
const result = chainOvoidsIntoSinglePolygon<LocalPoint>(
ovoids.map((poly, i) => ({
polygon: poly,
firstPoint: points[i],
secondPoint: points[i + 1],
})),
);
if (result === null) {
return [];
}
if (result === null) {
return [];
}
// Return the first (outer) polygon
return result;
}
/**
* Convert the ovoid outline to an SVG path string. Uses quadratic curves
* for smoothing.
*/
export function generateFreeDrawOvoidSvgPath(
element: ExcalidrawFreeDrawElement,
): string {
const points = generateFreeDrawOvoidOutline(element);
if (points.length === 0) {
console.warn("No outline points generated for freedraw element");
return "";
}
if (points.length < 3) {
console.warn("Not enough outline points to form a closed path");
return "";
}
// Use a similar approach to the original getSvgPathFromStroke
// but with the ovoid-generated points
const med = (a: number[], b: number[]) => [
(a[0] + b[0]) / 2,
(a[1] + b[1]) / 2,
];
const pathData = points
// Build a closed, smoothed SVG path from the outline polygon
.reduce(
(acc: (string | number[])[], point, i, arr) => {
if (i === points.length - 1) {
// For the last point, add a line-to ("L") back to the first point and close
// ("Z").
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
} else {
// Use a single quadratic command ("Q") and then emit point + midpoint pairs
// so each segment curves through the current point toward the midpoint of
// the next segment.
acc.push(point, med(point, arr[i + 1]));
}
return acc;
},
// Start with a move-to ("M") to the first point.
["M", points[0], "Q"],
)
.join(" ")
// Trim excessive float precision to keep the path string compact/stable.
.replace(/(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g, "$1");
return pathData;
}
@@ -724,6 +724,7 @@ export class LinearElementEditor {
? [pointerDownState.lastClickedPoint] ? [pointerDownState.lastClickedPoint]
: selectedPointsIndices, : selectedPointsIndices,
isDragging: false, isDragging: false,
pointerOffset: { x: 0, y: 0 },
customLineAngle: null, customLineAngle: null,
initialState: { initialState: {
...editingLinearElement.initialState, ...editingLinearElement.initialState,
+1 -16
View File
@@ -14,7 +14,6 @@ import {
DEFAULT_REDUCED_GLOBAL_ALPHA, DEFAULT_REDUCED_GLOBAL_ALPHA,
ELEMENT_READY_TO_ERASE_OPACITY, ELEMENT_READY_TO_ERASE_OPACITY,
FRAME_STYLE, FRAME_STYLE,
DARK_THEME_FILTER,
MIME_TYPES, MIME_TYPES,
THEME, THEME,
distance, distance,
@@ -434,22 +433,9 @@ const drawElementOnCanvas = (
break; break;
} }
case "image": { case "image": {
context.save();
const cacheEntry =
element.fileId !== null
? renderConfig.imageCache.get(element.fileId)
: null;
const img = isInitializedImageElement(element) const img = isInitializedImageElement(element)
? cacheEntry?.image ? renderConfig.imageCache.get(element.fileId)?.image
: undefined; : undefined;
const shouldInvertImage =
renderConfig.theme === THEME.DARK &&
cacheEntry?.mimeType === MIME_TYPES.svg;
if (shouldInvertImage) {
context.filter = DARK_THEME_FILTER;
}
if (img != null && !(img instanceof Promise)) { if (img != null && !(img instanceof Promise)) {
if (element.roundness && context.roundRect) { if (element.roundness && context.roundRect) {
context.beginPath(); context.beginPath();
@@ -486,7 +472,6 @@ const drawElementOnCanvas = (
} else { } else {
drawImagePlaceholder(element, context); drawImagePlaceholder(element, context);
} }
context.restore();
break; break;
} }
default: { default: {
+3 -14
View File
@@ -318,18 +318,7 @@ export const resizeSingleTextElement = (
) => { ) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const isCornerHandle = transformHandleType.length === 2; const metricsWidth = element.width * (nextHeight / element.height);
let metricsWidth = element.width * (nextHeight / element.height);
let metricsHeight = nextHeight;
if (isCornerHandle) {
const widthRatio = Math.abs(nextWidth) / element.width;
const heightRatio = Math.abs(nextHeight) / element.height;
const ratio = Math.max(widthRatio, heightRatio);
const sign = Math.sign(nextHeight) || 1;
metricsWidth = element.width * ratio * sign;
metricsHeight = element.height * ratio * sign;
}
const metrics = measureFontSizeFromWidth(element, elementsMap, metricsWidth); const metrics = measureFontSizeFromWidth(element, elementsMap, metricsWidth);
if (metrics === null) { if (metrics === null) {
@@ -344,7 +333,7 @@ export const resizeSingleTextElement = (
origElement.width, origElement.width,
origElement.height, origElement.height,
metricsWidth, metricsWidth,
metricsHeight, nextHeight,
origElement.angle, origElement.angle,
transformHandleType, transformHandleType,
false, false,
@@ -354,7 +343,7 @@ export const resizeSingleTextElement = (
scene.mutateElement(element, { scene.mutateElement(element, {
fontSize: metrics.size, fontSize: metrics.size,
width: metricsWidth, width: metricsWidth,
height: metricsHeight, height: nextHeight,
x: newOrigin.x, x: newOrigin.x,
y: newOrigin.y, y: newOrigin.y,
}); });
+11 -2
View File
@@ -63,6 +63,7 @@ import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
} from "./bounds"; } from "./bounds";
import { shouldTestInside } from "./collision"; import { shouldTestInside } from "./collision";
import { generateFreeDrawOvoidSvgPath } from "./freedraw";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@@ -78,6 +79,10 @@ import type {
import type { Drawable, Options } from "roughjs/bin/core"; import type { Drawable, Options } from "roughjs/bin/core";
import type { Point as RoughPoint } from "roughjs/bin/geometry"; import type { Point as RoughPoint } from "roughjs/bin/geometry";
// Toggle between old (perfect-freehand) and new (ovoid-union) freedraw rendering
// Set to true to use the new ovoid-based implementation
export const USE_NEW_FREEDRAW_RENDERER = true;
export class ShapeCache { export class ShapeCache {
private static rg = new RoughGenerator(); private static rg = new RoughGenerator();
private static cache = new WeakMap< private static cache = new WeakMap<
@@ -852,8 +857,12 @@ const _generateElementShape = (
); );
} }
// (2) stroke // (2) stroke - use new ovoid renderer or legacy perfect-freehand
shapes.push(getFreeDrawSvgPath(element)); if (USE_NEW_FREEDRAW_RENDERER) {
shapes.push(generateFreeDrawOvoidSvgPath(element) as SVGPathString);
} else {
shapes.push(getFreeDrawSvgPath(element));
}
return shapes; return shapes;
} }
+2 -182
View File
@@ -1,12 +1,9 @@
import { arrayToMap } from "@excalidraw/common";
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math"; import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw"; import { Excalidraw } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui"; import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import "@excalidraw/utils/test-utils"; import "@excalidraw/utils/test-utils";
import { render } from "@excalidraw/excalidraw/tests/test-utils"; import { render } from "@excalidraw/excalidraw/tests/test-utils";
import * as distance from "../src/distance";
import { hitElementItself } from "../src/collision"; import { hitElementItself } from "../src/collision";
describe("check rotated elements can be hit:", () => { describe("check rotated elements can be hit:", () => {
@@ -28,6 +25,8 @@ describe("check rotated elements can be hit:", () => {
[-4, -302], [-4, -302],
] as LocalPoint[], ] as LocalPoint[],
}); });
//const p = [120, -211];
//const p = [0, 13];
const hit = hitElementItself({ const hit = hitElementItself({
point: pointFrom<GlobalPoint>(88, -68), point: pointFrom<GlobalPoint>(88, -68),
element: window.h.elements[0], element: window.h.elements[0],
@@ -37,182 +36,3 @@ describe("check rotated elements can be hit:", () => {
expect(hit).toBe(true); expect(hit).toBe(true);
}); });
}); });
describe("hitElementItself cache", () => {
beforeEach(async () => {
// reset cache
hitElementItself({
point: pointFrom<GlobalPoint>(50, 50),
element: API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
}),
threshold: Infinity,
elementsMap: new Map([]),
});
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("reuses cached result when threshold increases", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(100.5, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 1,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
distanceSpy.mockRestore();
});
it("does not reuse cache when threshold decreases", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(105, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
expect(
hitElementItself({
point,
element,
threshold: 6,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(2);
distanceSpy.mockRestore();
});
it("invalidates cache when element version changes", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(100.5, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 1,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
const movedElement = {
...element,
version: element.version + 1,
versionNonce: element.versionNonce + 1,
};
expect(
hitElementItself({
point,
element: movedElement,
threshold: 1,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(2);
distanceSpy.mockRestore();
});
it("override does not affect caching", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(50, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
}),
).toBe(false);
expect(distanceSpy).toHaveBeenCalledTimes(1);
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
overrideShouldTestInside: true,
}),
).toBe(true);
});
});
@@ -218,7 +218,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint // drag line from midpoint
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta)); drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(3); expect(line.points.length).toEqual(3);
expect(line.points).toMatchInlineSnapshot(` expect(line.points).toMatchInlineSnapshot(`
[ [
-18
View File
@@ -563,24 +563,6 @@ describe("text element", () => {
expect(text.fontSize).toBeCloseTo(fontSize * scale); expect(text.fontSize).toBeCloseTo(fontSize * scale);
}); });
it("resizes proportionally using horizontal delta from corner handles", async () => {
const text = UI.createElement("text");
await UI.editText(text, "hello\nworld");
const { x, y, width, height, fontSize } = text;
const deltaX = width;
const deltaY = 0;
const scale = (width + deltaX) / width;
UI.resize(text, "se", [deltaX, deltaY]);
expect(text.x).toBeCloseTo(x);
expect(text.y).toBeCloseTo(y);
expect(text.width).toBeCloseTo(width * scale);
expect(text.height).toBeCloseTo(height * scale);
expect(text.angle).toBeCloseTo(0);
expect(text.fontSize).toBeCloseTo(fontSize * scale);
});
// TODO enable this test after adding single text element flipping // TODO enable this test after adding single text element flipping
it.skip("flips while resizing", async () => { it.skip("flips while resizing", async () => {
const text = UI.createElement("text"); const text = UI.createElement("text");
@@ -58,7 +58,6 @@ const distributeSelectedElements = (
app.scene.getNonDeletedElementsMap(), app.scene.getNonDeletedElementsMap(),
distribution, distribution,
appState, appState,
app.scene,
); );
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = arrayToMap(updatedElements);
+4 -11
View File
@@ -82,10 +82,7 @@ export const actionFinalize = register<FormData>({
app.scene, app.scene,
); );
if ( if (isBindingElement(element)) {
isBindingElement(element) &&
!appState.selectedLinearElement.segmentMidPointHoveredCoords
) {
const newArrow = !!appState.newElement; const newArrow = !!appState.newElement;
const selectedPointsIndices = const selectedPointsIndices =
@@ -98,10 +95,7 @@ export const actionFinalize = register<FormData>({
map.set(index, { map.set(index, {
point: LinearElementEditor.pointFromAbsoluteCoords( point: LinearElementEditor.pointFromAbsoluteCoords(
element, element,
pointFrom<GlobalPoint>( pointFrom<GlobalPoint>(sceneCoords.x, sceneCoords.y),
sceneCoords.x - linearElementEditor.pointerOffset.x,
sceneCoords.y - linearElementEditor.pointerOffset.y,
),
elementsMap, elementsMap,
), ),
}); });
@@ -112,8 +106,8 @@ export const actionFinalize = register<FormData>({
bindOrUnbindBindingElement( bindOrUnbindBindingElement(
element, element,
draggedPoints, draggedPoints,
sceneCoords.x - linearElementEditor.pointerOffset.x, sceneCoords.x,
sceneCoords.y - linearElementEditor.pointerOffset.y, sceneCoords.y,
scene, scene,
appState, appState,
{ {
@@ -176,7 +170,6 @@ export const actionFinalize = register<FormData>({
...linearElementEditor.initialState, ...linearElementEditor.initialState,
lastClickedPoint: -1, lastClickedPoint: -1,
}, },
pointerOffset: { x: 0, y: 0 },
}, },
selectionElement: null, selectionElement: null,
suggestedBinding: null, suggestedBinding: null,
+1 -1
View File
@@ -1263,9 +1263,9 @@ export const ShapesSwitcher = ({
onSelect={() => app.onMagicframeToolSelect()} onSelect={() => app.onMagicframeToolSelect()}
icon={MagicIcon} icon={MagicIcon}
data-testid="toolbar-magicframe" data-testid="toolbar-magicframe"
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
> >
{t("toolBar.magicframe")} {t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item> </DropdownMenu.Item>
)} )}
</DropdownMenu.Content> </DropdownMenu.Content>
+6 -3
View File
@@ -2780,7 +2780,7 @@ class App extends React.Component<AppProps, AppState> {
private getFormFactor = (editorWidth: number, editorHeight: number) => { private getFormFactor = (editorWidth: number, editorHeight: number) => {
return ( return (
this.props.UIOptions.getFormFactor?.(editorWidth, editorHeight) ?? this.props.UIOptions.formFactor ??
getFormFactor(editorWidth, editorHeight) getFormFactor(editorWidth, editorHeight)
); );
}; };
@@ -2804,7 +2804,10 @@ class App extends React.Component<AppProps, AppState> {
? this.props.UIOptions.dockedSidebarBreakpoint ? this.props.UIOptions.dockedSidebarBreakpoint
: MQ_RIGHT_SIDEBAR_MIN_WIDTH; : MQ_RIGHT_SIDEBAR_MIN_WIDTH;
const nextEditorInterface = updateObject(this.editorInterface, { const nextEditorInterface = updateObject(this.editorInterface, {
desktopUIMode: storedDesktopUIMode ?? this.editorInterface.desktopUIMode, desktopUIMode:
this.props.UIOptions.desktopUIMode ??
storedDesktopUIMode ??
this.editorInterface.desktopUIMode,
formFactor: this.getFormFactor(editorWidth, editorHeight), formFactor: this.getFormFactor(editorWidth, editorHeight),
userAgent: userAgentDescriptor, userAgent: userAgentDescriptor,
canFitSidebar: editorWidth > sidebarBreakpoint, canFitSidebar: editorWidth > sidebarBreakpoint,
@@ -10052,7 +10055,7 @@ class App extends React.Component<AppProps, AppState> {
}); });
} }
} else if (pointerDownState.drag.hasOccurred && !multiElement) { } else if (pointerDownState.drag.hasOccurred && !multiElement) {
if (isLinearElement(newElement)) { if (isBindingElement(newElement, false)) {
this.actionManager.executeAction(actionFinalize, "ui", { this.actionManager.executeAction(actionFinalize, "ui", {
event: childEvent, event: childEvent,
sceneCoords, sceneCoords,
+1 -1
View File
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.Avatar { .Avatar {
+1 -1
View File
@@ -1,4 +1,4 @@
@use "../css/theme" as *; @import "../css/theme";
.excalidraw { .excalidraw {
.excalidraw-button { .excalidraw-button {
@@ -1,4 +1,4 @@
@use "../css/theme" as *; @import "../css/theme";
.excalidraw { .excalidraw {
button.standalone { button.standalone {
+3 -3
View File
@@ -1,4 +1,4 @@
@use "../css/variables.module.scss" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.Card { .Card {
@@ -19,7 +19,7 @@
padding: 1.4rem; padding: 1.4rem;
border-radius: 50%; border-radius: 50%;
background: var(--card-color); background: var(--card-color);
color: #fff; color: $oc-white;
svg { svg {
width: 2.8rem; width: 2.8rem;
@@ -46,7 +46,7 @@
background-color: var(--card-color-darkest); background-color: var(--card-color-darkest);
} }
.ToolIcon__label { .ToolIcon__label {
color: #fff; color: $oc-white;
} }
.Spinner { .Spinner {
+13 -23
View File
@@ -1,35 +1,25 @@
import OpenColor from "open-color";
import "./Card.scss"; import "./Card.scss";
// for open-color see https://github.com/yeun/open-color/blob/master/open-color.scss
const COLOR_MAP = {
primary: {
base: "var(--color-primary)",
darker: "var(--color-primary-darker)",
darkest: "var(--color-primary-darkest)",
},
lime: {
base: "#74b816", // open-color lime[7]
darker: "#66a80f", // open-color lime[8]
darkest: "#5c940d", // open-color lime[9]
},
pink: {
base: "#d6336c", // open-color pink[7]
darker: "#c2255c", // open-color pink[8]
darkest: "#a61e4d", // open-color pink[9]
},
};
export const Card: React.FC<{ export const Card: React.FC<{
color: "primary" | "lime" | "pink"; color: keyof OpenColor | "primary";
children?: React.ReactNode; children?: React.ReactNode;
}> = ({ children, color }) => { }> = ({ children, color }) => {
return ( return (
<div <div
className="Card" className="Card"
style={{ style={{
["--card-color" as any]: COLOR_MAP[color].base, ["--card-color" as any]:
["--card-color-darker" as any]: COLOR_MAP[color].darker, color === "primary" ? "var(--color-primary)" : OpenColor[color][7],
["--card-color-darkest" as any]: COLOR_MAP[color].darkest, ["--card-color-darker" as any]:
color === "primary"
? "var(--color-primary-darker)"
: OpenColor[color][8],
["--card-color-darkest" as any]:
color === "primary"
? "var(--color-primary-darkest)"
: OpenColor[color][9],
}} }}
> >
{children} {children}
@@ -1,5 +1,4 @@
@use "sass:color"; @import "../css/variables.module.scss";
@use "../css/variables.module" as *;
.excalidraw { .excalidraw {
.Checkbox { .Checkbox {
@@ -13,7 +12,7 @@
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
&:hover:not(.is-checked) .Checkbox-box:not(:focus) { &:hover:not(.is-checked) .Checkbox-box:not(:focus) {
box-shadow: 0 0 0 2px #{$color-blue-4}; box-shadow: 0 0 0 2px #{$oc-blue-4};
} }
&:hover:not(.is-checked) .Checkbox-box:not(:focus) { &:hover:not(.is-checked) .Checkbox-box:not(:focus) {
@@ -25,25 +24,25 @@
&:active { &:active {
.Checkbox-box { .Checkbox-box {
box-shadow: 0 0 2px 1px inset #{$color-blue-7} !important; box-shadow: 0 0 2px 1px inset #{$oc-blue-7} !important;
} }
} }
&:hover { &:hover {
.Checkbox-box { .Checkbox-box {
background-color: color.adjust($color-blue-1, $alpha: -0.8); background-color: fade-out($oc-blue-1, 0.8);
} }
} }
&.is-checked { &.is-checked {
.Checkbox-box { .Checkbox-box {
background-color: #{$color-blue-1}; background-color: #{$oc-blue-1};
svg { svg {
display: block; display: block;
} }
} }
&:hover .Checkbox-box { &:hover .Checkbox-box {
background-color: #{$color-blue-2}; background-color: #{$oc-blue-2};
} }
} }
@@ -59,16 +58,16 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 0 0 2px #{$color-blue-7}; box-shadow: 0 0 0 2px #{$oc-blue-7};
background-color: transparent; background-color: transparent;
border-radius: 4px; border-radius: 4px;
color: #{$color-blue-7}; color: #{$oc-blue-7};
border: 0; border: 0;
&:focus { &:focus {
box-shadow: 0 0 0 3px #{$color-blue-7}; box-shadow: 0 0 0 3px #{$oc-blue-7};
} }
svg { svg {
@@ -1,7 +1,9 @@
import clsx from "clsx"; import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { KEYS, normalizeInputColor } from "@excalidraw/common"; import { isTransparent, KEYS } from "@excalidraw/common";
import tinycolor from "tinycolor2";
import { getShortcutKey } from "../..//shortcut"; import { getShortcutKey } from "../..//shortcut";
import { useAtom } from "../../editor-jotai"; import { useAtom } from "../../editor-jotai";
@@ -14,6 +16,29 @@ import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import type { ColorPickerType } from "./colorPickerUtils"; import type { ColorPickerType } from "./colorPickerUtils";
/**
* tries to keep the input color as-is if it's valid, making minimal adjustments
* (trimming whitespace or adding `#` to hex colors)
*/
export const normalizeInputColor = (color: string): string | null => {
color = color.trim();
if (isTransparent(color)) {
return color;
}
const tc = tinycolor(color);
if (tc.isValid()) {
// testing for `#` first fixes a bug on Electron (more specfically, an
// Obsidian popout window), where a hex color without `#` is considered valid
if (tc.getFormat() === "hex" && !color.startsWith("#")) {
return `#${color}`;
}
return color;
}
return null;
};
export const ColorInput = ({ export const ColorInput = ({
color, color,
onChange, onChange,
@@ -1,5 +1,4 @@
@use "sass:color"; @import "../../css/variables.module.scss";
@use "../../css/variables.module" as *;
.excalidraw { .excalidraw {
.focus-visible-none { .focus-visible-none {
@@ -186,8 +185,8 @@
.color-picker { .color-picker {
background: var(--popup-bg-color); background: var(--popup-bg-color);
border: 0 solid color.adjust(#fff, $alpha: -0.75); border: 0 solid transparentize($oc-white, 0.75);
box-shadow: color.adjust(#000, $alpha: -0.75) 0 1px 4px; box-shadow: transparentize($oc-black, 0.75) 0 1px 4px;
border-radius: 4px; border-radius: 4px;
position: absolute; position: absolute;
@@ -244,7 +243,7 @@
} }
.color-picker-triangle-shadow { .color-picker-triangle-shadow {
border-color: transparent transparent color.adjust(#000, $alpha: -0.9); border-color: transparent transparent transparentize($oc-black, 0.9);
:root[dir="ltr"] & { :root[dir="ltr"] & {
left: -14px; left: -14px;
@@ -281,7 +280,7 @@
padding: 0.25rem; padding: 0.25rem;
&-title { &-title {
color: $color-gray-6; color: $oc-gray-6;
font-size: 12px; font-size: 12px;
padding: 0 0.25rem; padding: 0 0.25rem;
} }
@@ -320,7 +319,7 @@
.color-picker-transparent { .color-picker-transparent {
border-radius: 4px; border-radius: 4px;
box-shadow: color.adjust(#000, $alpha: -0.9) 0 0 0 1px inset; box-shadow: transparentize($oc-black, 0.9) 0 0 0 1px inset;
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
@@ -474,7 +473,7 @@
} }
.color-picker-type-elementBackground .color-picker-keybinding { .color-picker-type-elementBackground .color-picker-keybinding {
color: #fff; color: $oc-white;
} }
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding { .color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
@@ -487,10 +486,10 @@
&.theme--dark { &.theme--dark {
.color-picker-type-elementBackground .color-picker-keybinding { .color-picker-type-elementBackground .color-picker-keybinding {
color: #000; color: $oc-black;
} }
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding { .color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
color: #000; color: $oc-black;
} }
} }
} }
@@ -5,7 +5,6 @@ import { useRef, useEffect } from "react";
import { import {
COLOR_OUTLINE_CONTRAST_THRESHOLD, COLOR_OUTLINE_CONTRAST_THRESHOLD,
COLOR_PALETTE, COLOR_PALETTE,
isColorDark,
isWritableElement, isWritableElement,
} from "@excalidraw/common"; } from "@excalidraw/common";
@@ -30,7 +29,7 @@ import { ColorInput } from "./ColorInput";
import { Picker } from "./Picker"; import { Picker } from "./Picker";
import PickerHeading from "./PickerHeading"; import PickerHeading from "./PickerHeading";
import { TopPicks } from "./TopPicks"; import { TopPicks } from "./TopPicks";
import { activeColorPickerSectionAtom } from "./colorPickerUtils"; import { activeColorPickerSectionAtom, isColorDark } from "./colorPickerUtils";
import "./ColorPicker.scss"; import "./ColorPicker.scss";
@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { isColorDark } from "@excalidraw/common";
import { isColorDark } from "./colorPickerUtils";
interface HotkeyLabelProps { interface HotkeyLabelProps {
color: string; color: string;
@@ -5,9 +5,10 @@ import {
DEFAULT_CANVAS_BACKGROUND_PICKS, DEFAULT_CANVAS_BACKGROUND_PICKS,
DEFAULT_ELEMENT_BACKGROUND_PICKS, DEFAULT_ELEMENT_BACKGROUND_PICKS,
DEFAULT_ELEMENT_STROKE_PICKS, DEFAULT_ELEMENT_STROKE_PICKS,
isColorDark,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { isColorDark } from "./colorPickerUtils";
import type { ColorPickerType } from "./colorPickerUtils"; import type { ColorPickerType } from "./colorPickerUtils";
interface TopPicksProps { interface TopPicksProps {
@@ -1,4 +1,8 @@
import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "@excalidraw/common"; import {
isTransparent,
MAX_CUSTOM_COLORS_USED_IN_CANVAS,
tinycolor,
} from "@excalidraw/common";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
@@ -96,6 +100,32 @@ export type ActiveColorPickerSectionAtomType =
export const activeColorPickerSectionAtom = export const activeColorPickerSectionAtom =
atom<ActiveColorPickerSectionAtomType>(null); atom<ActiveColorPickerSectionAtomType>(null);
const calculateContrast = (r: number, g: number, b: number): number => {
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
return yiq;
};
// YIQ algo, inspiration from https://stackoverflow.com/a/11868398
export const isColorDark = (color: string, threshold = 160): boolean => {
// no color ("") -> assume it default to black
if (!color) {
return true;
}
if (isTransparent(color)) {
return false;
}
const tc = tinycolor(color);
if (!tc.isValid()) {
// invalid color -> assume it defaults to black
return true;
}
const { r, g, b } = tc.toRgb();
return calculateContrast(r, g, b) < threshold;
};
export type ColorPickerType = export type ColorPickerType =
| "canvasBackground" | "canvasBackground"
| "elementBackground" | "elementBackground"
@@ -1,4 +1,4 @@
@use "../../css/variables.module" as *; @import "../../css/variables.module.scss";
$verticalBreakpoint: 861px; $verticalBreakpoint: 861px;
@@ -44,6 +44,7 @@ import { getSelectedElements } from "../../scene";
import { import {
LockedIcon, LockedIcon,
UnlockedIcon, UnlockedIcon,
clockIcon,
searchIcon, searchIcon,
boltIcon, boltIcon,
bucketFillIcon, bucketFillIcon,
@@ -51,7 +52,6 @@ import {
mermaidLogoIcon, mermaidLogoIcon,
brainIconThin, brainIconThin,
LibraryIcon, LibraryIcon,
historyCommandIcon,
} from "../icons"; } from "../icons";
import { SHAPES } from "../shapes"; import { SHAPES } from "../shapes";
@@ -928,7 +928,7 @@ function CommandPaletteInner({
marginLeft: "6px", marginLeft: "6px",
}} }}
> >
{historyCommandIcon} {clockIcon}
</div> </div>
</div> </div>
<CommandItem <CommandItem
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.confirm-dialog { .confirm-dialog {
@@ -1,5 +1,4 @@
@use "sass:color"; @import "../css/variables.module.scss";
@use "../css/variables.module" as *;
.excalidraw { .excalidraw {
.context-menu-popover { .context-menu-popover {
@@ -9,7 +8,7 @@
.context-menu { .context-menu {
position: relative; position: relative;
border-radius: 4px; border-radius: 4px;
box-shadow: 0 3px 10px color.adjust(#000, $alpha: -0.8); box-shadow: 0 3px 10px transparentize($oc-black, 0.8);
padding: 0; padding: 0;
list-style: none; list-style: none;
user-select: none; user-select: none;
@@ -50,7 +49,7 @@
&.dangerous { &.dangerous {
.context-menu-item__label { .context-menu-item__label {
color: $color-red-7; color: $oc-red-7;
} }
} }
@@ -74,7 +73,7 @@
.context-menu-item__label { .context-menu-item__label {
color: var(--popup-bg-color); color: var(--popup-bg-color);
} }
background-color: $color-red-6; background-color: $oc-red-6;
} }
} }
@@ -98,6 +97,6 @@
.context-menu-item-separator { .context-menu-item-separator {
border: none; border: none;
border-top: 1px solid $color-gray-5; border-top: 1px solid $oc-gray-5;
} }
} }
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css//variables.module.scss";
.excalidraw { .excalidraw {
.ConvertElementTypePopup { .ConvertElementTypePopup {
+1 -1
View File
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.Dialog { .Dialog {
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.ElementLinkDialog { .ElementLinkDialog {
@@ -59,7 +59,7 @@
} }
.ElementLinkDialog__remove { .ElementLinkDialog__remove {
color: $color-red-9; color: $oc-red-9;
margin-left: 1rem; margin-left: 1rem;
.ToolIcon__icon { .ToolIcon__icon {
@@ -68,7 +68,7 @@
} }
.ToolIcon__icon svg { .ToolIcon__icon svg {
color: $color-red-6; color: $oc-red-6;
} }
} }
} }
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.ExportDialog__preview { .ExportDialog__preview {
@@ -112,7 +112,7 @@
font-family: Cascadia; font-family: Cascadia;
font-size: 1.8em; font-size: 1.8em;
color: #fff; color: $oc-white;
&:hover { &:hover {
background-color: var(--button-color-darker); background-color: var(--button-color-darker);
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
@keyframes successStatusAnimation { @keyframes successStatusAnimation {
0% { 0% {
@@ -24,14 +24,6 @@
background-color: var(--back-color); background-color: var(--back-color);
border-color: var(--border-color); border-color: var(--border-color);
border-radius: 0.5rem;
border-width: 1px;
border-style: solid;
font-family: var(--font-family);
user-select: none;
&:hover { &:hover {
transition: all 150ms ease-out; transition: all 150ms ease-out;
} }
@@ -60,19 +52,7 @@
} }
&[disabled] { &[disabled] {
cursor: not-allowed; pointer-events: none;
&.ExcButton--variant-filled,
&:hover {
--back-color: var(--color-surface-low) !important;
--text-color: var(--color-on-surface-variant) !important;
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-on-surface-variant);
--border-color: var(--color-surface-high);
}
} }
&, &,
@@ -286,6 +266,14 @@
} }
} }
border-radius: 0.5rem;
border-width: 1px;
border-style: solid;
font-family: var(--font-family);
user-select: none;
&--size-large { &--size-large {
font-weight: 600; font-weight: 600;
font-size: 0.875rem; font-size: 0.875rem;
@@ -33,7 +33,6 @@ export type FilledButtonProps = {
fullWidth?: boolean; fullWidth?: boolean;
icon?: React.ReactNode; icon?: React.ReactNode;
disabled?: boolean;
}; };
export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>( export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
@@ -49,7 +48,6 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
fullWidth, fullWidth,
className, className,
status, status,
disabled,
}, },
ref, ref,
) => { ) => {
@@ -96,7 +94,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
type="button" type="button"
aria-label={label} aria-label={label}
ref={ref} ref={ref}
disabled={disabled || _status === "loading" || _status === "success"} disabled={_status === "loading" || _status === "success"}
> >
<div className="ExcButton__contents"> <div className="ExcButton__contents">
{_status === "loading" ? ( {_status === "loading" ? (
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.FixedSideContainer { .FixedSideContainer {
@@ -1,4 +1,4 @@
@use "../../css/variables.module" as *; @import "../../css/variables.module.scss";
.excalidraw { .excalidraw {
.FontPicker__container { .FontPicker__container {
@@ -290,15 +290,13 @@ export const FontPickerList = React.memo(
onHover(font.value); onHover(font.value);
} }
}} }}
badge={
font.badge && (
<DropDownMenuItemBadge type={font.badge.type}>
{font.badge.placeholder}
</DropDownMenuItemBadge>
)
}
> >
{font.text} {font.text}
{font.badge && (
<DropDownMenuItemBadge type={font.badge.type}>
{font.badge.placeholder}
</DropDownMenuItemBadge>
)}
</DropdownMenuItem> </DropdownMenuItem>
); );
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.HelpDialog { .HelpDialog {
@@ -60,12 +60,11 @@
&__islands-container { &__islands-container {
display: grid; display: grid;
grid-column-gap: 1.5rem;
grid-row-gap: 2rem;
@media screen and (min-width: 1024px) { @media screen and (min-width: 1024px) {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
grid-column-gap: 1.5rem;
grid-row-gap: 2rem;
} }
@media screen and (min-width: 1024px) { @media screen and (min-width: 1024px) {
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
// this is loosely based on the longest hint text // this is loosely based on the longest hint text
$wide-viewport-width: 1000px; $wide-viewport-width: 1000px;
@@ -1,11 +1,10 @@
@use "sass:color"; @import "../css/variables.module.scss";
@use "../css/variables.module" as *;
.excalidraw { .excalidraw {
.picker { .picker {
padding: 0.5rem; padding: 0.5rem;
background: var(--popup-bg-color); background: var(--popup-bg-color);
border: 0 solid color.adjust(#fff, $alpha: -0.75); border: 0 solid transparentize($oc-white, 0.75);
box-shadow: var(--shadow-island); box-shadow: var(--shadow-island);
border-radius: 4px; border-radius: 4px;
position: absolute; position: absolute;
@@ -88,7 +87,7 @@
} }
.picker-type-elementBackground .picker-keybinding { .picker-type-elementBackground .picker-keybinding {
color: #fff; color: $oc-white;
} }
.picker-swatch[aria-label="transparent"] .picker-keybinding { .picker-swatch[aria-label="transparent"] .picker-keybinding {
@@ -101,10 +100,10 @@
&.theme--dark { &.theme--dark {
.picker-type-elementBackground .picker-keybinding { .picker-type-elementBackground .picker-keybinding {
color: #000; color: $oc-black;
} }
.picker-swatch[aria-label="transparent"] .picker-keybinding { .picker-swatch[aria-label="transparent"] .picker-keybinding {
color: #000; color: $oc-black;
} }
} }
} }
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
--ImageExportModal-preview-border: #d6d6d6; --ImageExportModal-preview-border: #d6d6d6;
+2 -1
View File
@@ -1,4 +1,5 @@
@use "../css/variables.module" as *; @import "open-color/open-color";
@import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.layer-ui__wrapper.animate { .layer-ui__wrapper.animate {
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "open-color/open-color";
.excalidraw { .excalidraw {
.layer-ui__library { .layer-ui__library {
@@ -46,15 +46,15 @@
} }
&-close.ToolIcon_type_button { &-close.ToolIcon_type_button {
background-color: $color-blue-6; background-color: $oc-blue-6;
align-self: flex-end; align-self: flex-end;
&:hover { &:hover {
background-color: $color-blue-8; background-color: $oc-blue-8;
} }
.ToolIcon__icon { .ToolIcon__icon {
width: auto; width: auto;
font-size: 1rem; font-size: 1rem;
color: #fff; color: $oc-white;
padding: 0 0.5rem; padding: 0 0.5rem;
} }
} }
@@ -90,7 +90,7 @@
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
background-color: var(--color-primary); background-color: var(--color-primary);
color: #fff; color: $oc-white;
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
text-decoration: none !important; text-decoration: none !important;
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "open-color/open-color";
.excalidraw { .excalidraw {
--container-padding-y: 1rem; --container-padding-y: 1rem;
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.library-unit { .library-unit {
@@ -1,4 +1,5 @@
@use "../css/variables.module" as *; @import "open-color/open-color.scss";
@import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.mobile-toolbar { .mobile-toolbar {
+1 -1
View File
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
&.excalidraw-modal-container { &.excalidraw-modal-container {
+1
View File
@@ -49,6 +49,7 @@ export const Modal: React.FC<{
aria-modal="true" aria-modal="true"
onKeyDown={handleKeydown} onKeyDown={handleKeydown}
aria-labelledby={props.labelledBy} aria-labelledby={props.labelledBy}
data-prevent-outside-click
> >
<div <div
className="Modal__background" className="Modal__background"
@@ -1,4 +1,4 @@
@use "../../css/variables.module" as *; @import "../../css/variables.module.scss";
.excalidraw { .excalidraw {
.OverwriteConfirm { .OverwriteConfirm {
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.PasteChartDialog { .PasteChartDialog {
@@ -25,7 +25,7 @@
height: 128px; height: 128px;
border-radius: 2px; border-radius: 2px;
padding: 1px; padding: 1px;
border: 1px solid $color-gray-4; border: 1px solid $oc-gray-4;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -39,7 +39,7 @@
} }
&:hover { &:hover {
padding: 0; padding: 0;
border: 2px solid $color-blue-5; border: 2px solid $oc-blue-5;
} }
} }
} }
@@ -1,3 +1,4 @@
import oc from "open-color";
import React, { useLayoutEffect, useRef, useState } from "react"; import React, { useLayoutEffect, useRef, useState } from "react";
import type { ChartType } from "@excalidraw/element/types"; import type { ChartType } from "@excalidraw/element/types";
@@ -48,7 +49,7 @@ const ChartPreviewBtn = (props: {
elements, elements,
{ {
exportBackground: false, exportBackground: false,
viewBackgroundColor: "#fff", viewBackgroundColor: oc.white,
}, },
null, // files null, // files
{ {
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.publish-library { .publish-library {
@@ -14,7 +14,7 @@
span { span {
font-weight: 500; font-weight: 500;
font-size: 1rem; font-size: 1rem;
color: $color-gray-6; color: $oc-gray-6;
} }
input, input,
textarea { textarea {
@@ -24,7 +24,7 @@
} }
.required { .required {
color: $color-red-8; color: $oc-red-8;
margin: 0.2rem; margin: 0.2rem;
} }
} }
@@ -48,22 +48,22 @@
} }
&--confirm.ToolIcon_type_button { &--confirm.ToolIcon_type_button {
background-color: $color-blue-6; background-color: $oc-blue-6;
&:hover { &:hover {
background-color: $color-blue-8; background-color: $oc-blue-8;
} }
} }
&--cancel.ToolIcon_type_button { &--cancel.ToolIcon_type_button {
background-color: $color-gray-5; background-color: $oc-gray-5;
&:hover { &:hover {
background-color: $color-gray-6; background-color: $oc-gray-6;
} }
} }
.ToolIcon__icon { .ToolIcon__icon {
color: #fff; color: $oc-white;
.Spinner { .Spinner {
--spinner-color: #fff; --spinner-color: #fff;
svg { svg {
@@ -83,7 +83,7 @@
} }
&-warning { &-warning {
color: $color-red-6; color: $oc-red-6;
} }
&-note { &-note {
@@ -102,14 +102,14 @@
top: 0.3rem; top: 0.3rem;
left: 0.3rem; left: 0.3rem;
font-size: 0.7rem; font-size: 0.7rem;
color: $color-red-7; color: $oc-red-7;
background: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.9);
padding: 0.1rem 0.2rem; padding: 0.1rem 0.2rem;
border-radius: 0.2rem; border-radius: 0.2rem;
} }
&__svg { &__svg {
background-color: #fff; background-color: $oc-white;
padding: 0.3rem; padding: 0.3rem;
width: 7.5rem; width: 7.5rem;
height: 7.5rem; height: 7.5rem;
@@ -121,7 +121,7 @@
} }
.ToolIcon__icon { .ToolIcon__icon {
background-color: #fff; background-color: $oc-white;
width: auto; width: auto;
height: auto; height: auto;
margin: 0 0.5rem; margin: 0 0.5rem;
@@ -132,7 +132,7 @@
} }
.required, .required,
.error { .error {
color: $color-red-8; color: $oc-red-8;
font-weight: 700; font-weight: 700;
font-size: 1rem; font-size: 1rem;
margin: 0.2rem; margin: 0.2rem;
@@ -152,16 +152,16 @@
margin: 0; margin: 0;
} }
.ToolIcon__icon { .ToolIcon__icon {
background-color: $color-red-6; background-color: $oc-red-6;
&:hover { &:hover {
background-color: $color-red-7; background-color: $oc-red-7;
} }
&:active { &:active {
background-color: $color-red-8; background-color: $oc-red-8;
} }
} }
svg { svg {
color: #fff; color: $oc-white;
padding: 0.26rem; padding: 0.26rem;
border-radius: 0.3em; border-radius: 0.3em;
width: 1rem; width: 1rem;
@@ -1,4 +1,5 @@
import { exportToCanvas, exportToSvg } from "@excalidraw/utils/export"; import { exportToCanvas, exportToSvg } from "@excalidraw/utils/export";
import OpenColor from "open-color";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
@@ -56,7 +57,7 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => {
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "#fff"; ctx.fillStyle = OpenColor.white;
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height);
// draw items // draw items
@@ -86,7 +87,7 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => {
// draw item border // draw item border
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
ctx.lineWidth = BORDER_WIDTH; ctx.lineWidth = BORDER_WIDTH;
ctx.strokeStyle = "#ced4da"; ctx.strokeStyle = OpenColor.gray[4];
ctx.strokeRect( ctx.strokeRect(
colOffset + BOX_PADDING / 2, colOffset + BOX_PADDING / 2,
rowOffset + BOX_PADDING / 2, rowOffset + BOX_PADDING / 2,
@@ -130,7 +131,7 @@ const SingleLibraryItem = ({
elements: libItem.elements, elements: libItem.elements,
appState: { appState: {
...appState, ...appState,
viewBackgroundColor: "#fff", viewBackgroundColor: OpenColor.white,
exportBackground: true, exportBackground: true,
}, },
files: null, files: null,
@@ -174,7 +175,7 @@ const SingleLibraryItem = ({
}} }}
> >
<div style={{ padding: "0.5em 0" }}> <div style={{ padding: "0.5em 0" }}>
<span style={{ fontWeight: 500, color: "#868e96" }}> <span style={{ fontWeight: 500, color: OpenColor.gray[6] }}>
{t("publishDialog.itemName")} {t("publishDialog.itemName")}
</span> </span>
<span aria-hidden="true" className="required"> <span aria-hidden="true" className="required">
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
--RadioGroup-background: var(--island-bg-color); --RadioGroup-background: var(--island-bg-color);
+1 -1
View File
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
--slider-thumb-size: 16px; --slider-thumb-size: 16px;
+1 -1
View File
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.SVGLayer { .SVGLayer {
@@ -1,4 +1,5 @@
@use "../css/variables.module" as *; @import "open-color/open-color";
@import "../css//variables.module.scss";
.excalidraw { .excalidraw {
.layer-ui__search { .layer-ui__search {
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.ShareableLinkDialog { .ShareableLinkDialog {
@@ -1,4 +1,5 @@
@use "../../css/variables.module" as *; @import "open-color/open-color";
@import "../../css/variables.module.scss";
.excalidraw { .excalidraw {
.sidebar { .sidebar {
@@ -18,12 +19,6 @@
pointer-events: var(--ui-pointerEvents); pointer-events: var(--ui-pointerEvents);
overflow: hidden;
border-radius: 0;
width: calc(var(--right-sidebar-width) - var(--space-factor) * 2);
border-left: 1px solid var(--sidebar-border-color);
:root[dir="rtl"] & { :root[dir="rtl"] & {
left: 0; left: 0;
right: auto; right: auto;
@@ -33,6 +28,12 @@
box-shadow: none; box-shadow: none;
} }
overflow: hidden;
border-radius: 0;
width: calc(var(--right-sidebar-width) - var(--space-factor) * 2);
border-left: 1px solid var(--sidebar-border-color);
:root[dir="rtl"] & { :root[dir="rtl"] & {
border-right: 1px solid var(--sidebar-border-color); border-right: 1px solid var(--sidebar-border-color);
border-left: 0; border-left: 0;
@@ -1,4 +1,4 @@
@use "../../css/variables.module" as *; @import "../../css/variables.module.scss";
.excalidraw { .excalidraw {
.sidebar-trigger { .sidebar-trigger {
+1 -1
View File
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "open-color/open-color.scss";
$duration: 1.6s; $duration: 1.6s;
+1 -1
View File
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
--Switch-disabled-color: var(--color-border-outline); --Switch-disabled-color: var(--color-border-outline);
@@ -1,403 +0,0 @@
@import "../../../css/variables.module.scss";
$verticalBreakpoint: 861px;
.excalidraw {
&.theme--dark {
.chat-message {
&--assistant {
.chat-message__content {
background: var(--color-surface-lowest);
}
}
&--system {
.chat-message__content {
color: var(--color-surface-low);
}
}
}
}
.chat-interface {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
position: relative;
@media screen and (max-width: $verticalBreakpoint) {
min-height: 200px;
}
&__messages {
flex: 1 1 0;
overflow-y: auto;
margin-bottom: 0.5rem;
padding: 1rem 0.5rem 0 0.5rem;
display: flex;
flex-direction: column;
gap: 2rem;
min-height: 0;
border-top-left-radius: var(--border-radius-lg);
border-top-right-radius: var(--border-radius-lg);
@media screen and (max-width: $verticalBreakpoint) {
min-height: 100px;
padding: 0.75rem;
}
}
&__empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
&-content {
text-align: center;
h3 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
font-weight: 600;
}
p {
margin: 0 0 0.2rem 0;
font-size: 0.875rem;
color: var(--color-on-surface);
}
}
@media screen and (max-width: $verticalBreakpoint) {
min-height: 100px;
}
}
&__input-outer {
position: relative;
min-height: 71px;
}
&__input-container {
display: flex;
flex-direction: column;
justify-content: flex-end;
}
&__input-wrapper {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: flex-end;
gap: 0.5rem;
border: 1px solid var(--dialog-border-color);
border-radius: var(--border-radius-lg);
padding: 0.75rem;
background: var(--color-surface-lowest);
transition: border-color 0.2s ease;
&:focus-within {
border-color: var(--color-primary);
}
@media screen and (max-width: $verticalBreakpoint) {
padding: 0.5rem 0.75rem;
}
}
&__input {
flex: 1;
border: none;
outline: none;
font-size: 0.875rem;
line-height: 1.5;
min-height: 24px;
max-height: 120px;
overflow-x: hidden;
border: none !important;
background: transparent !important;
color: var(--color-on-surface);
&::placeholder {
color: var(--color-gray-40);
}
@media screen and (max-width: $verticalBreakpoint) {
line-height: 1.4;
min-height: 20px;
max-height: 100px;
resize: none;
padding: 0;
&::placeholder {
opacity: 0.6;
}
}
}
&__send-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(--color-primary);
color: var(--color-surface-lowest);
cursor: pointer;
margin-bottom: 8px;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
&:hover {
background-color: var(--color-primary-darker);
}
svg {
transform: rotate(-90deg);
}
}
}
.chat-message {
display: flex;
align-items: center;
gap: 0.5rem;
&--user {
justify-content: flex-end;
.chat-message__content {
background: var(--color-primary-light);
color: var(--text-primary-color);
border-radius: var(--border-radius-md);
min-width: 6rem;
}
}
&--assistant {
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
.chat-message__content {
background: var(--color-surface-low);
color: var(--color-on-surface);
border-radius: var(--border-radius-md);
min-width: 6rem;
.chat-message__body {
font-family: monospace;
}
}
}
&--system {
justify-content: flex-start;
margin-bottom: 0;
.chat-message__content {
background: var(--color-warning);
color: var(--color-on-surface);
border-radius: var(--border-radius-md);
min-width: 6rem;
.chat-message__body {
font-family: monospace;
}
}
}
&__content {
max-width: 80%;
padding: 0.75rem 1rem;
box-shadow: var(--chat-msg-shadow);
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
font-size: 0.75rem;
}
&__role {
font-weight: 600;
}
&__timestamp {
font-size: 0.625rem;
}
&__actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
&__action {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
padding: 0.25rem 0;
color: var(--color-gray-60);
transition: color 0.2s ease;
flex-shrink: 0;
&.invisible {
visibility: hidden;
pointer-events: none;
}
&:hover {
color: var(--color-primary);
}
svg {
width: 14px;
height: 14px;
}
}
&__action--danger {
&:hover {
color: var(--color-danger);
}
}
&__body {
line-height: 1.5;
}
&__text {
white-space: pre-wrap;
word-wrap: break-word;
}
&__cursor {
display: inline-block;
margin-left: 2px;
color: currentColor;
animation: blink 1s infinite;
}
&__loading {
display: flex;
align-items: center;
gap: 0.5rem;
}
&__typing-indicator {
display: flex;
gap: 0.25rem;
span {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.4;
animation: typing 1.4s infinite ease-in-out;
&:nth-child(1) {
animation-delay: -0.32s;
}
&:nth-child(2) {
animation-delay: -0.16s;
}
&:nth-child(3) {
animation-delay: 0s;
}
}
}
&__error {
color: var(--color-danger);
font-weight: 500;
white-space: pre-wrap;
word-wrap: break-word;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
&__error_message {
background: var(--color-warning-background);
color: var(--color-warning-color);
padding: 0.5rem 0.75rem;
border-radius: var(--border-radius-md);
margin-top: 1rem;
font-size: 0.875rem;
}
&__error-link {
display: flex;
align-items: center;
background: none;
border: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
padding: 0;
margin-top: 1rem;
text-decoration: underline;
font-family: inherit;
color: var(--link-color);
&:hover {
color: var(--link-color-hover);
}
&:active {
color: var(--link-color-active);
}
&:disabled {
cursor: not-allowed;
text-decoration: none;
}
}
}
@keyframes typing {
0%,
80%,
100% {
transform: scale(0.8);
opacity: 0.4;
}
40% {
transform: scale(1);
opacity: 1;
}
}
@keyframes blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
}
@@ -1,92 +0,0 @@
import clsx from "clsx";
import { t } from "../../../i18n";
import { historyIcon, TrashIcon } from "../../icons";
import DropdownMenu from "../../dropdownMenu/DropdownMenu";
import { FilledButton } from "../../FilledButton";
import type { SavedChat } from "../types";
interface ChatHistoryMenuProps {
isOpen: boolean;
onToggle: () => void;
onClose: () => void;
onNewChat: () => void;
onRestoreChat: (chat: SavedChat) => void;
onDeleteChat: (chatId: string, event: React.MouseEvent) => void;
savedChats: SavedChat[];
activeSessionId: string;
disabled?: boolean;
isNewChatBtnVisible?: boolean;
}
export const ChatHistoryMenu = ({
isOpen,
onToggle,
onClose,
onNewChat,
onRestoreChat,
onDeleteChat,
isNewChatBtnVisible,
savedChats,
activeSessionId,
disabled,
}: ChatHistoryMenuProps) => {
return (
<div className="ttd-chat-history-menu">
{isNewChatBtnVisible && (
<FilledButton onClick={onNewChat} disabled={disabled}>
{t("chat.newChat")}
</FilledButton>
)}
{savedChats.length > 0 && (
<div className="ttd-dialog-panel__menu-wrapper">
<DropdownMenu open={isOpen}>
<DropdownMenu.Trigger
onToggle={onToggle}
className="ttd-dialog-menu-trigger"
disabled={disabled}
title={t("chat.menu")}
aria-label={t("chat.menu")}
>
{historyIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={onClose}
onSelect={onClose}
placement="bottom"
>
<>
{savedChats.map((chat) => (
<DropdownMenu.ItemCustom
key={chat.id}
className={clsx("ttd-chat-menu-item", {
"ttd-chat-menu-item--active": chat.id === activeSessionId,
})}
onClick={() => {
onRestoreChat(chat);
}}
>
<span className="ttd-chat-menu-item__title">
{chat.title}
</span>
<button
className="ttd-chat-menu-item__delete"
onClick={(e) => onDeleteChat(chat.id, e)}
title={t("chat.deleteChat")}
aria-label={t("chat.deleteChat")}
type="button"
>
{TrashIcon}
</button>
</DropdownMenu.ItemCustom>
))}
</>
</DropdownMenu.Content>
</DropdownMenu>
</div>
)}
</div>
);
};
@@ -1,193 +0,0 @@
import React, { useRef, useEffect, useLayoutEffect } from "react";
import { KEYS } from "@excalidraw/common";
import { ArrowRightIcon, stop as StopIcon } from "../../icons";
import { InlineIcon } from "../../InlineIcon";
import { t } from "../../../i18n";
import { ChatMessage } from "./ChatMessage";
import type { TChat, TTTDDialog } from "../types";
import type { FormEventHandler } from "react";
export const ChatInterface = ({
chatId,
messages,
currentPrompt,
onPromptChange,
onGenerate,
isGenerating,
rateLimits,
placeholder,
onAbort,
onMermaidTabClick,
onAiRepairClick,
onDeleteMessage,
onInsertMessage,
onRetry,
renderWarning,
}: {
chatId: string;
messages: TChat.ChatMessage[];
currentPrompt: string;
onPromptChange: (prompt: string) => void;
onGenerate: TTTDDialog.OnGenerate;
isGenerating: boolean;
rateLimits?: {
rateLimit: number;
rateLimitRemaining: number;
} | null;
onViewAsMermaid?: () => void;
generatedResponse?: string | null;
placeholder: {
title: string;
description: string;
hint: string;
};
onAbort?: () => void;
onMermaidTabClick?: (message: TChat.ChatMessage) => void;
onAiRepairClick?: (message: TChat.ChatMessage) => void;
onDeleteMessage?: (messageId: string) => void;
onInsertMessage?: (message: TChat.ChatMessage) => void;
onRetry?: (message: TChat.ChatMessage) => void;
renderWarning?: TTTDDialog.renderWarning;
}) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useLayoutEffect(() => {
messagesEndRef.current?.scrollIntoView();
}, [messages]);
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.focus();
}
}, [chatId]);
const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = event.target.value;
onPromptChange(value);
};
const handleSubmit = () => {
if (isGenerating && onAbort) {
onAbort();
return;
}
const trimmedPrompt = currentPrompt.trim();
if (!trimmedPrompt) {
return;
}
onGenerate({ prompt: trimmedPrompt });
onPromptChange("");
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === KEYS.ENTER && !event.shiftKey) {
event.preventDefault();
if (!isGenerating) {
handleSubmit();
}
}
};
const canSend =
currentPrompt.trim().length > 3 &&
!isGenerating &&
(rateLimits?.rateLimitRemaining ?? 1) > 0;
const canStop = isGenerating && !!onAbort;
const onInput: FormEventHandler<HTMLTextAreaElement> = (ev) => {
const target = ev.target as HTMLTextAreaElement;
target.style.height = "auto";
target.style.height = `${Math.min(target.scrollHeight, 120)}px`;
};
return (
<div className="chat-interface">
<div className="chat-interface__messages">
{messages.length === 0 ? (
<div className="chat-interface__empty-state">
<div className="chat-interface__empty-state-content">
<h3>{placeholder.title}</h3>
<p>{placeholder.description}</p>
<p>{placeholder.hint}</p>
</div>
</div>
) : (
messages.map((message, index) => (
<ChatMessage
key={message.id}
message={message}
onMermaidTabClick={onMermaidTabClick}
onAiRepairClick={onAiRepairClick}
onDeleteMessage={onDeleteMessage}
onInsertMessage={onInsertMessage}
onRetry={onRetry}
rateLimitRemaining={rateLimits?.rateLimitRemaining}
isLastMessage={index === messages.length - 1}
renderWarning={renderWarning}
// so we don't allow to repair parse errors which aren't the last message
allowFixingParseError={
message.errorType === "parse" && index === messages.length - 1
}
/>
))
)}
<div ref={messagesEndRef} id="messages-end" />
</div>
<div className="chat-interface__input-container">
<div className="chat-interface__input-outer">
<div
className="chat-interface__input-wrapper"
style={{
borderColor: isGenerating
? "var(--dialog-border-color)"
: undefined,
}}
>
<textarea
ref={textareaRef}
autoFocus
className="chat-interface__input"
value={currentPrompt}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={
isGenerating
? t("chat.generating")
: rateLimits?.rateLimitRemaining === 0
? t("chat.rateLimit.messageLimitInputPlaceholder")
: messages.length > 0
? t("chat.inputPlaceholderWithMessages")
: t("chat.inputPlaceholder", { shortcut: "Shift + Enter" })
}
disabled={rateLimits?.rateLimitRemaining === 0}
rows={1}
cols={30}
onInput={onInput}
/>
<button
className="chat-interface__send-button"
onClick={handleSubmit}
disabled={!canSend && !canStop}
type="button"
>
<InlineIcon
size="1.5em"
icon={isGenerating ? StopIcon : ArrowRightIcon}
/>
</button>
</div>
</div>
</div>
</div>
);
};
@@ -1,218 +0,0 @@
import clsx from "clsx";
import React, { useState, useEffect } from "react";
import { t } from "../../../i18n";
import { FilledButton } from "../../FilledButton";
import { TrashIcon, codeIcon, stackPushIcon, RetryIcon } from "../../icons";
import type { TChat, TTTDDialog } from "../types";
export const ChatMessage: React.FC<{
message: TChat.ChatMessage;
onMermaidTabClick?: (message: TChat.ChatMessage) => void;
onAiRepairClick?: (message: TChat.ChatMessage) => void;
onDeleteMessage?: (messageId: string) => void;
onInsertMessage?: (message: TChat.ChatMessage) => void;
onRetry?: (message: TChat.ChatMessage) => void;
rateLimitRemaining?: number;
isLastMessage?: boolean;
renderWarning?: TTTDDialog.renderWarning;
allowFixingParseError?: boolean;
}> = ({
message,
onMermaidTabClick,
onAiRepairClick,
onDeleteMessage,
onInsertMessage,
onRetry,
rateLimitRemaining,
isLastMessage,
renderWarning,
allowFixingParseError,
}) => {
const [canRetry, setCanRetry] = useState(false);
useEffect(() => {
if (!message.error || !isLastMessage) {
return;
}
if (message.error && !message.lastAttemptAt) {
setCanRetry(true);
return;
}
const timeSinceLastAttempt = Date.now() - message.lastAttemptAt!;
const remainingTime = Math.max(0, 5000 - timeSinceLastAttempt);
if (remainingTime === 0) {
setCanRetry(true);
return;
}
setCanRetry(false);
const timer = setTimeout(() => {
setCanRetry(true);
}, remainingTime);
return () => clearTimeout(timer);
}, [message.error, message.lastAttemptAt, isLastMessage]);
const formatTime = (date: Date) => {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
if (message.type === "warning") {
const customOverride = renderWarning?.(message);
return (
<div className="chat-message chat-message--system">
<div className="chat-message__content">
<div className="chat-message__header">
<span className="chat-message__role">{t("chat.role.system")}</span>
<span className="chat-message__timestamp">
{formatTime(message.timestamp)}
</span>
</div>
<div className="chat-message__body">
<div className="chat-message__text">
{customOverride ? (
customOverride
) : message.warningType === "messageLimitExceeded" ? (
<>
{t("chat.rateLimit.messageLimit")}
<div style={{ marginTop: "10px" }}>
<FilledButton
onClick={() => {
window.open(
`${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=ttdChatBanner#excalidraw-redirect`,
"_blank",
"noopener",
);
}}
>
{t("chat.upsellBtnLabel")}
</FilledButton>
</div>
</>
) : (
t("chat.rateLimit.generalRateLimit")
)}
</div>
</div>
</div>
</div>
);
}
return (
<div className={`chat-message chat-message--${message.type}`}>
<div className="chat-message__content">
<div className="chat-message__header">
<span className="chat-message__role">
{message.type === "user"
? t("chat.role.user")
: t("chat.role.assistant")}
</span>
<span className="chat-message__timestamp">
{formatTime(message.timestamp)}
</span>
</div>
<div className="chat-message__body">
{message.error ? (
<>
<div className="chat-message__error">{message.content}</div>
{message.errorType !== "parse" && (
<div className="chat-message__error_message">
Error: {message.error || t("chat.errors.generationFailed")}
</div>
)}
{message.errorType === "parse" && allowFixingParseError && (
<div className="chat-message__error_message">
<p>{t("chat.errors.invalidDiagram")}</p>
<div className="chat-message__error-actions">
{onMermaidTabClick && (
<button
className="chat-message__error-link"
onClick={() => onMermaidTabClick(message)}
type="button"
>
{t("chat.errors.fixInMermaid")}
</button>
)}
{onAiRepairClick && (
<button
className="chat-message__error-link"
onClick={() => onAiRepairClick(message)}
disabled={rateLimitRemaining === 0}
type="button"
>
{t("chat.errors.aiRepair")}
</button>
)}
</div>
</div>
)}
</>
) : (
<div className="chat-message__text">
{message.content}
{message.isGenerating && (
<span className="chat-message__cursor"></span>
)}
</div>
)}
</div>
</div>
{message.type === "assistant" && !message.isGenerating && (
<div className="chat-message__actions">
{!message.error && onInsertMessage && (
<button
className="chat-message__action"
onClick={() => onInsertMessage(message)}
type="button"
aria-label={t("chat.insert")}
title={t("chat.insert")}
>
{stackPushIcon}
</button>
)}
{onMermaidTabClick && message.content && (
<button
className="chat-message__action"
onClick={() => onMermaidTabClick(message)}
type="button"
aria-label={t("chat.viewAsMermaid")}
title={t("chat.viewAsMermaid")}
>
{codeIcon}
</button>
)}
{onDeleteMessage && message.errorType !== "network" && (
<button
className="chat-message__action chat-message__action--danger"
onClick={() => onDeleteMessage(message.id)}
type="button"
aria-label={t("chat.deleteMessage")}
title={t("chat.deleteMessage")}
>
{TrashIcon}
</button>
)}
{message.errorType === "network" && onRetry && isLastMessage && (
<button
className={clsx("chat-message__action", { invisible: !canRetry })}
onClick={() => onRetry(message)}
type="button"
aria-label={t("chat.retry")}
title={t("chat.retry")}
>
{RetryIcon}
</button>
)}
</div>
)}
</div>
);
};
@@ -1,163 +0,0 @@
import { t } from "../../../i18n";
import { ArrowRightIcon } from "../../icons";
import { InlineIcon } from "../../InlineIcon";
import { TTDDialogPanel } from "../TTDDialogPanel";
import { useAtom } from "../../../editor-jotai";
import { rateLimitsAtom } from "../TTDContext";
import { ChatHistoryMenu } from "./ChatHistoryMenu";
import { ChatInterface } from ".";
import type { TTDPanelAction } from "../TTDDialogPanel";
import type { SavedChat, TChat, TTTDDialog } from "../types";
export const TTDChatPanel = ({
chatId,
messages,
currentPrompt,
onPromptChange,
onGenerate,
isGenerating,
generatedResponse,
isMenuOpen,
onMenuToggle,
onMenuClose,
onNewChat,
onRestoreChat,
onDeleteChat,
savedChats,
activeSessionId,
onAbort,
onMermaidTabClick,
onAiRepairClick,
onDeleteMessage,
onInsertMessage,
onRetry,
onViewAsMermaid,
renderWarning,
}: {
chatId: string;
messages: TChat.ChatMessage[];
currentPrompt: string;
onPromptChange: (prompt: string) => void;
onGenerate: TTTDDialog.OnGenerate;
isGenerating: boolean;
generatedResponse: string | null | undefined;
isMenuOpen: boolean;
onMenuToggle: () => void;
onMenuClose: () => void;
onNewChat: () => void;
onRestoreChat: (chat: SavedChat) => void;
onDeleteChat: (chatId: string, event: React.MouseEvent) => void;
savedChats: SavedChat[];
activeSessionId: string;
onAbort: () => void;
onMermaidTabClick: (message: TChat.ChatMessage) => void;
onAiRepairClick: (message: TChat.ChatMessage) => void;
onDeleteMessage: (messageId: string) => void;
onInsertMessage: (message: TChat.ChatMessage) => void;
onRetry?: (message: TChat.ChatMessage) => void;
onViewAsMermaid: () => void;
renderWarning?: TTTDDialog.renderWarning;
}) => {
const [rateLimits] = useAtom(rateLimitsAtom);
const getPanelActions = () => {
const actions: TTDPanelAction[] = [];
if (rateLimits) {
actions.push({
label: t("chat.rateLimitRemaining", {
count: rateLimits.rateLimitRemaining,
}),
variant: "rateLimit",
className:
rateLimits.rateLimitRemaining < 5
? "ttd-dialog-panel__rate-limit--danger"
: "",
});
}
if (generatedResponse) {
actions.push({
action: onViewAsMermaid,
label: t("chat.viewAsMermaid"),
icon: <InlineIcon icon={ArrowRightIcon} />,
variant: "link",
});
}
return actions;
};
const actions = getPanelActions();
const getPanelActionFlexProp = () => {
if (actions.length === 2) {
return "space-between";
}
if (actions.length === 1 && actions[0].variant === "rateLimit") {
return "flex-start";
}
return "flex-end";
};
return (
<TTDDialogPanel
label={
<div className="ttd-dialog-panel__label-wrapper">
<div className="ttd-dialog-panel__label-group"></div>
<div className="ttd-dialog-panel__header-right">
<ChatHistoryMenu
isNewChatBtnVisible={!!messages.length}
isOpen={isMenuOpen}
onToggle={onMenuToggle}
onClose={onMenuClose}
onNewChat={onNewChat}
onRestoreChat={onRestoreChat}
onDeleteChat={onDeleteChat}
savedChats={savedChats}
activeSessionId={activeSessionId}
disabled={isGenerating}
/>
</div>
</div>
}
className="ttd-dialog-chat-panel"
panelActionJustifyContent={getPanelActionFlexProp()}
panelActions={actions}
>
<ChatInterface
chatId={chatId}
messages={messages}
currentPrompt={currentPrompt}
onPromptChange={onPromptChange}
onGenerate={onGenerate}
isGenerating={isGenerating}
generatedResponse={generatedResponse}
onAbort={onAbort}
onMermaidTabClick={onMermaidTabClick}
onAiRepairClick={onAiRepairClick}
onDeleteMessage={onDeleteMessage}
onInsertMessage={onInsertMessage}
onRetry={onRetry}
rateLimits={rateLimits}
placeholder={{
title: t("chat.placeholder.title"),
description: t("chat.placeholder.description"),
hint: t("chat.placeholder.hint"),
}}
renderWarning={renderWarning}
/>
</TTDDialogPanel>
);
};
@@ -1,3 +0,0 @@
export { ChatInterface } from "./ChatInterface";
export { ChatMessage } from "./ChatMessage";
export { useChatAgent } from "./useChatAgent";
@@ -1,76 +0,0 @@
import { useAtom } from "../../../editor-jotai";
import { chatHistoryAtom } from "../../TTDDialog/TTDContext";
import {
addMessages,
updateAssistantContent,
} from "../../TTDDialog/utils/chat";
export const useChatAgent = () => {
const [chatHistory, setChatHistory] = useAtom(chatHistoryAtom);
const addUserMessage = (content: string) => {
setChatHistory((prev) =>
addMessages(prev, [
{
type: "user",
content,
},
]),
);
};
const addAssistantMessage = () => {
setChatHistory((prev) =>
addMessages(prev, [
{
type: "assistant",
content: "",
isGenerating: true,
},
]),
);
};
const setLastRetryAttempt = () => {
setChatHistory((prev) =>
updateAssistantContent(prev, {
lastAttemptAt: Date.now(),
}),
);
};
const setAssistantError = (
errorMessage: string,
errorType: "parse" | "network" | "other" = "other",
errorDetails?: Error | unknown,
) => {
const serializedErrorDetails = errorDetails
? JSON.stringify({
name: errorDetails instanceof Error ? errorDetails.name : "Error",
message:
errorDetails instanceof Error
? errorDetails.message
: String(errorDetails),
stack: errorDetails instanceof Error ? errorDetails.stack : undefined,
})
: undefined;
setChatHistory((prev) =>
updateAssistantContent(prev, {
isGenerating: false,
error: errorMessage,
errorType,
errorDetails: serializedErrorDetails,
}),
);
};
return {
addUserMessage,
addAssistantMessage,
setAssistantError,
chatHistory,
setChatHistory,
setLastRetryAttempt,
};
};
@@ -10,8 +10,6 @@ import { EditorLocalStorage } from "../../data/EditorLocalStorage";
import { t } from "../../i18n"; import { t } from "../../i18n";
import Trans from "../Trans"; import Trans from "../Trans";
import { useUIAppState } from "../../context/ui-appState";
import { TTDDialogInput } from "./TTDDialogInput"; import { TTDDialogInput } from "./TTDDialogInput";
import { TTDDialogOutput } from "./TTDDialogOutput"; import { TTDDialogOutput } from "./TTDDialogOutput";
import { TTDDialogPanel } from "./TTDDialogPanel"; import { TTDDialogPanel } from "./TTDDialogPanel";
@@ -21,13 +19,12 @@ import {
convertMermaidToExcalidraw, convertMermaidToExcalidraw,
insertToEditor, insertToEditor,
saveMermaidDataToStorage, saveMermaidDataToStorage,
resetPreview,
} from "./common"; } from "./common";
import "./MermaidToExcalidraw.scss"; import "./MermaidToExcalidraw.scss";
import type { BinaryFiles } from "../../types"; import type { BinaryFiles } from "../../types";
import type { MermaidToExcalidrawLibProps } from "./types"; import type { MermaidToExcalidrawLibProps } from "./common";
const MERMAID_EXAMPLE = 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]"; "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]";
@@ -36,10 +33,8 @@ const debouncedSaveMermaidDefinition = debounce(saveMermaidDataToStorage, 300);
const MermaidToExcalidraw = ({ const MermaidToExcalidraw = ({
mermaidToExcalidrawLib, mermaidToExcalidrawLib,
isActive,
}: { }: {
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps; mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
isActive?: boolean;
}) => { }) => {
const [text, setText] = useState( const [text, setText] = useState(
() => () =>
@@ -56,40 +51,22 @@ const MermaidToExcalidraw = ({
}>({ elements: [], files: null }); }>({ elements: [], files: null });
const app = useApp(); const app = useApp();
const { theme } = useUIAppState();
useEffect(() => { useEffect(() => {
const doRender = async () => { convertMermaidToExcalidraw({
try { canvasRef,
if (!deferredText) { data,
resetPreview({ canvasRef, setError }); mermaidToExcalidrawLib,
return; setError,
} mermaidDefinition: deferredText,
const result = await convertMermaidToExcalidraw({ }).catch((err) => {
canvasRef, if (isDevEnv()) {
data, console.error("Failed to parse mermaid definition", err);
mermaidToExcalidrawLib,
setError,
mermaidDefinition: deferredText,
theme,
});
if (!result.success) {
const err = result.error ?? new Error("Invalid mermaid definition");
setError(err);
}
} catch (err) {
if (isDevEnv()) {
console.error("Failed to parse mermaid definition", err);
}
} }
}; });
if (isActive) { debouncedSaveMermaidDefinition(deferredText);
doRender(); }, [deferredText, mermaidToExcalidrawLib]);
debouncedSaveMermaidDefinition(deferredText);
}
}, [deferredText, mermaidToExcalidrawLib, isActive, theme]);
useEffect( useEffect(
() => () => { () => () => {
@@ -126,10 +103,10 @@ const MermaidToExcalidraw = ({
/> />
</div> </div>
<TTDDialogPanels> <TTDDialogPanels>
<TTDDialogPanel> <TTDDialogPanel label={t("mermaid.syntax")}>
<TTDDialogInput <TTDDialogInput
input={text} input={text}
placeholder={t("mermaid.inputPlaceholder")} placeholder={"Write Mermaid diagram defintion here..."}
onChange={(event) => setText(event.target.value)} onChange={(event) => setText(event.target.value)}
onKeyboardSubmit={() => { onKeyboardSubmit={() => {
onInsertToEditor(); onInsertToEditor();
@@ -137,16 +114,14 @@ const MermaidToExcalidraw = ({
/> />
</TTDDialogPanel> </TTDDialogPanel>
<TTDDialogPanel <TTDDialogPanel
panelActions={[ label={t("mermaid.preview")}
{ panelAction={{
action: () => { action: () => {
onInsertToEditor(); onInsertToEditor();
},
label: t("mermaid.button"),
icon: ArrowRightIcon,
variant: "button",
}, },
]} label: t("mermaid.button"),
icon: ArrowRightIcon,
}}
renderSubmitShortcut={() => <TTDDialogSubmitShortcut />} renderSubmitShortcut={() => <TTDDialogSubmitShortcut />}
> >
<TTDDialogOutput <TTDDialogOutput
@@ -1,17 +0,0 @@
import { randomId } from "@excalidraw/common";
import { atom } from "../../editor-jotai";
import type { RateLimits, TChat } from "./types";
export const rateLimitsAtom = atom<RateLimits | null>(null);
export const showPreviewAtom = atom<boolean>(false);
export const errorAtom = atom<Error | null>(null);
export const chatHistoryAtom = atom<TChat.ChatHistory>({
id: randomId(),
messages: [],
currentPrompt: "",
});
@@ -1,8 +1,6 @@
@use "../../css/variables.module.scss" as *; @import "../../css/variables.module.scss";
@use "./Chat/Chat.scss";
$verticalBreakpoint: 861px; $verticalBreakpoint: 861px;
$fullScreenModalBreakpoint: 600px;
.excalidraw { .excalidraw {
.Modal.Dialog.ttd-dialog { .Modal.Dialog.ttd-dialog {
@@ -18,26 +16,21 @@ $fullScreenModalBreakpoint: 600px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1 1 auto; flex: 1 1 auto;
box-shadow: none;
} }
.Modal__content { .Modal__content {
height: auto;
max-height: 100%; max-height: 100%;
min-height: 95vh;
height: 100%;
@media screen and (min-width: $verticalBreakpoint) { @media screen and (min-width: $verticalBreakpoint) {
max-height: min(950px, calc(100vh - 4rem)); max-height: 750px;
height: 100%; height: 100%;
min-height: unset;
} }
} }
.Dialog__content { .Dialog__content {
flex: 1 1 auto; flex: 1 1 auto;
@media screen and (max-width: $verticalBreakpoint) {
margin-top: 0 !important;
}
} }
} }
@@ -45,7 +38,7 @@ $fullScreenModalBreakpoint: 600px;
font-size: 15px; font-size: 15px;
font-style: italic; font-style: italic;
font-weight: 500; font-weight: 500;
margin: 0.5rem 0 1.5rem 0; margin-bottom: 1.5rem;
} }
.ttd-dialog-tabs-root { .ttd-dialog-tabs-root {
@@ -70,33 +63,12 @@ $fullScreenModalBreakpoint: 600px;
&[data-state="active"] { &[data-state="active"] {
border-bottom: 2px solid var(--color-primary); border-bottom: 2px solid var(--color-primary);
} }
&__content {
display: flex;
align-items: center;
}
&__badge {
display: flex;
align-items: center;
justify-content: center;
padding: 1px 6px;
margin-left: 10px;
font-size: 10px;
border-radius: 12px;
background: var(--color-promo);
color: var(--color-surface-lowest);
}
} }
.ttd-dialog-triggers { .ttd-dialog-triggers {
border-bottom: 1px solid var(--color-surface-high); border-bottom: 1px solid var(--color-surface-high);
margin-bottom: 1rem; margin-bottom: 1.5rem;
padding-inline: 2.5rem; padding-inline: 2.5rem;
@media screen and (max-width: $verticalBreakpoint) {
margin-bottom: 1rem;
}
} }
.ttd-dialog-content { .ttd-dialog-content {
@@ -104,102 +76,10 @@ $fullScreenModalBreakpoint: 600px;
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: visible;
outline: none;
&[hidden] { &[hidden] {
display: none; display: none;
} }
@media screen and (max-width: $verticalBreakpoint) {
padding-inline: 1rem;
flex-grow: 1;
}
}
.ttd-dialog-panel__header .dropdown-menu {
z-index: 2;
margin: 0;
right: 0;
left: auto;
min-width: 280px;
.dropdown-menu-container.dropdown-menu-container {
padding-inline: 0.5rem !important;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
max-height: min(400px, 70vh);
height: fit-content;
overflow-y: auto;
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-lg);
background-color: var(--island-bg-color-alt);
}
}
.ttd-dialog-layout {
display: grid;
grid-template-columns: 1fr 1fr;
height: 100%;
&--split {
gap: 2rem;
min-width: 0;
@media screen and (max-width: $verticalBreakpoint) {
grid-template-columns: 1fr;
grid-template-rows: 1fr auto;
grid-row-gap: 0;
}
.ttd-dialog-chat-panel {
min-width: 0;
@media screen and (max-width: $verticalBreakpoint) {
width: 100%;
flex: 0 0 auto;
height: auto;
min-height: 250px;
}
}
.ttd-dialog-preview-panel {
min-width: 0;
overflow: hidden;
@media screen and (max-width: $verticalBreakpoint) {
width: 100%;
flex: 0 0 auto;
max-width: 100%;
min-height: 280px;
height: unset;
}
}
}
&--chat-only {
grid-template-columns: 1fr;
.invisible {
display: none;
}
.ttd-dialog-chat-panel {
@media screen and (max-width: $verticalBreakpoint) {
height: unset;
}
}
@media screen and (max-width: $verticalBreakpoint) {
.chat-interface {
max-height: 100%;
&__messages {
max-height: unset;
}
}
}
}
} }
.ttd-dialog-input { .ttd-dialog-input {
@@ -221,15 +101,12 @@ $fullScreenModalBreakpoint: 600px;
.ttd-dialog-output-wrapper { .ttd-dialog-output-wrapper {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0.85rem;
box-sizing: border-box; box-sizing: border-box;
flex-grow: 1; flex-grow: 1;
position: relative; position: relative;
overflow: hidden;
max-width: 100%;
max-height: 100%;
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==") background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
left center; left center;
@@ -238,29 +115,16 @@ $fullScreenModalBreakpoint: 600px;
height: 400px; height: 400px;
width: auto; width: auto;
max-width: 100%;
@media screen and (max-width: $fullScreenModalBreakpoint) { @media screen and (min-width: $verticalBreakpoint) {
height: 200px;
}
@media screen and (max-width: $verticalBreakpoint) {
width: 100%; width: 100%;
max-width: 100%; // acts as min-height
} height: 200px;
&--error {
background: none;
border: 1px solid var(--dialog-border-color);
border-radius: var(--border-radius-lg);
} }
canvas { canvas {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
} }
} }
@@ -271,74 +135,28 @@ $fullScreenModalBreakpoint: 600px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-grow: 1; flex-grow: 1;
position: relative;
user-select: none;
&.invisible {
visibility: hidden;
pointer-events: none;
}
}
.ttd-dialog-output-canvas-content {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
flex-grow: 1;
canvas {
image-rendering: auto;
}
} }
.ttd-dialog-output-error { .ttd-dialog-output-error {
color: red;
font-weight: 700;
font-size: 30px;
word-break: break-word;
overflow: auto;
max-height: 100%;
height: 100%;
width: 100%;
text-align: center;
position: absolute; position: absolute;
z-index: 10; z-index: 10;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
.ttd-dialog-output-error-content { p {
display: flex; font-weight: 500;
flex-direction: column; font-family: Cascadia;
align-items: center;
gap: 1rem;
padding: 2.5rem 2rem;
background: var(--color-surface-primary);
}
.ttd-dialog-output-error-icon {
color: var(--color-danger);
display: flex;
align-items: center;
justify-content: center;
svg {
width: 52px;
height: 52px;
stroke-width: 1.5;
}
}
.ttd-dialog-output-error-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-danger);
text-align: center;
margin-top: 0.25rem;
}
.ttd-dialog-output-error-message {
text-align: left; text-align: left;
font-weight: 400;
color: var(--color-gray-50);
word-break: break-word;
white-space: pre-wrap; white-space: pre-wrap;
max-width: 100%; font-size: 0.875rem;
font-family: monospace; padding: 0 10px;
} }
} }
@@ -348,86 +166,37 @@ $fullScreenModalBreakpoint: 600px;
@media screen and (min-width: $verticalBreakpoint) { @media screen and (min-width: $verticalBreakpoint) {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 2rem; gap: 4rem;
} }
} }
.ttd-dialog-chat-panel,
.ttd-dialog-preview-panel {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-width: 0;
align-items: stretch;
}
.ttd-dialog-panel { .ttd-dialog-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
height: 100%;
@media screen and (max-width: $verticalBreakpoint) {
height: 50%;
}
&__header { &__header {
display: flex; display: flex;
margin: 0px 4px 4px 4px;
align-items: center; align-items: center;
gap: 0.3rem; gap: 1rem;
height: 36px;
margin-top: 0.2rem;
margin-bottom: 0.5rem;
flex-shrink: 0;
label { label {
font-size: 14px; font-size: 14px;
line-height: 22px;
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
} }
} }
&__label-wrapper { &:first-child {
display: flex; .ttd-dialog-panel-button-container:not(.invisible) {
justify-content: space-between; margin-bottom: 4rem;
align-items: center;
width: 100%;
}
&__label-group {
display: flex;
gap: 5px;
}
&__header-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
&__rate-limit {
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
&--danger {
color: var(--color-danger);
} }
} }
&__menu-wrapper { @media screen and (min-width: $verticalBreakpoint) {
position: relative; .ttd-dialog-panel-button-container:not(.invisible) {
margin-bottom: 0.5rem !important;
.ttd-dialog-menu-trigger {
height: 40px;
width: 40px;
}
.dropdown-menu {
margin-top: 0.375rem;
right: 0;
} }
} }
@@ -444,28 +213,23 @@ $fullScreenModalBreakpoint: 600px;
@media screen and (max-width: $verticalBreakpoint) { @media screen and (max-width: $verticalBreakpoint) {
width: auto; width: auto;
height: 10rem;
} }
} }
} }
.ttd-dialog-panel-button-container { .ttd-dialog-panel-button-container {
display: flex;
align-items: center;
flex-grow: 0;
height: 40px;
flex-shrink: 0;
margin-top: 1rem; margin-top: 1rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
@media screen and (max-width: $verticalBreakpoint) {
margin-top: 0.5rem;
}
&.invisible { &.invisible {
visibility: hidden; .ttd-dialog-panel-button {
@media screen and (max-width: $verticalBreakpoint) {
display: none; display: none;
@media screen and (min-width: $verticalBreakpoint) {
display: block;
visibility: hidden;
}
} }
} }
} }
@@ -477,7 +241,7 @@ $fullScreenModalBreakpoint: 600px;
height: 2.5rem; height: 2.5rem;
font-size: 12px; font-size: 12px;
color: #fff; color: $oc-white;
background-color: var(--color-primary); background-color: var(--color-primary);
width: 100%; width: 100%;
@@ -507,9 +271,7 @@ $fullScreenModalBreakpoint: 600px;
} }
} }
& { position: relative;
position: relative;
}
div { div {
display: contents; display: contents;
@@ -550,109 +312,4 @@ $fullScreenModalBreakpoint: 600px;
border-radius: 4px; border-radius: 4px;
} }
} }
.ttd-dialog-panel-action-link {
display: flex;
align-items: center;
gap: 0.25rem;
background: none;
border: none;
color: var(--color-primary);
font-weight: 500;
font-family: inherit;
height: 40px;
&__icon {
display: inline-flex;
align-items: center;
svg {
width: 12px;
height: 12px;
}
}
@media screen and (max-width: $verticalBreakpoint) {
height: unset;
}
}
.dropdown-menu-item-custom.ttd-chat-menu-item {
display: flex;
width: unset;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0.5rem;
cursor: pointer;
position: relative;
border: 1px solid transparent;
border-radius: var(--border-radius-md);
margin-top: 1px;
&:hover {
background-color: var(--button-hover-bg);
}
&:active {
border: 1px solid var(--button-active-border);
}
&--active {
background-color: var(--color-surface-primary-container);
}
.ttd-chat-menu-item__title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:hover {
.ttd-chat-menu-item__delete {
visibility: visible;
}
}
.ttd-chat-menu-item__delete {
border: none;
padding: 0.25rem;
cursor: pointer;
flex-shrink: 0;
margin-left: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
visibility: hidden;
&:hover {
color: var(--color-danger);
}
@at-root .excalidraw.theme--dark#{&} {
svg {
color: var(--color-on-surface);
}
&:hover svg {
color: var(--color-danger);
}
}
svg {
width: 16px;
height: 16px;
}
}
}
.ttd-dialog-preview-panel--hidden {
display: none;
}
.ttd-chat-history-menu {
display: flex;
gap: 1rem;
align-items: center;
}
} }
@@ -1,32 +1,71 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { isFiniteNumber } from "@excalidraw/math";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { trackEvent } from "../../analytics";
import { useUIAppState } from "../../context/ui-appState"; import { useUIAppState } from "../../context/ui-appState";
import { atom, useAtom } from "../../editor-jotai";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { useApp } from "../App"; import { useApp, useExcalidrawSetAppState } from "../App";
import { Dialog } from "../Dialog"; import { Dialog } from "../Dialog";
import { InlineIcon } from "../InlineIcon";
import { withInternalFallback } from "../hoc/withInternalFallback"; import { withInternalFallback } from "../hoc/withInternalFallback";
import { ArrowRightIcon } from "../icons";
import MermaidToExcalidraw from "./MermaidToExcalidraw"; import MermaidToExcalidraw from "./MermaidToExcalidraw";
import TextToDiagram from "./TextToDiagram";
import TTDDialogTabs from "./TTDDialogTabs"; import TTDDialogTabs from "./TTDDialogTabs";
import { TTDDialogTabTriggers } from "./TTDDialogTabTriggers"; import { TTDDialogTabTriggers } from "./TTDDialogTabTriggers";
import { TTDDialogTabTrigger } from "./TTDDialogTabTrigger"; import { TTDDialogTabTrigger } from "./TTDDialogTabTrigger";
import { TTDDialogTab } from "./TTDDialogTab"; import { TTDDialogTab } from "./TTDDialogTab";
import { TTDDialogInput } from "./TTDDialogInput";
import { TTDDialogOutput } from "./TTDDialogOutput";
import { TTDDialogPanel } from "./TTDDialogPanel";
import { TTDDialogPanels } from "./TTDDialogPanels";
import {
convertMermaidToExcalidraw,
insertToEditor,
saveMermaidDataToStorage,
} from "./common";
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
import "./TTDDialog.scss"; import "./TTDDialog.scss";
import type { import type { ChangeEventHandler } from "react";
MermaidToExcalidrawLibProps, import type { MermaidToExcalidrawLibProps } from "./common";
TTDPersistenceAdapter,
TTTDDialog, import type { BinaryFiles } from "../../types";
} from "./types";
const MIN_PROMPT_LENGTH = 3;
const MAX_PROMPT_LENGTH = 1000;
const rateLimitsAtom = atom<{
rateLimit: number;
rateLimitRemaining: number;
} | null>(null);
const ttdGenerationAtom = atom<{
generatedResponse: string | null;
prompt: string | null;
} | null>(null);
type OnTestSubmitRetValue = {
rateLimit?: number | null;
rateLimitRemaining?: number | null;
} & (
| { generatedResponse: string | undefined; error?: null | undefined }
| {
error: Error;
generatedResponse?: null | undefined;
}
);
export const TTDDialog = ( export const TTDDialog = (
props: props:
| { | {
onTextSubmit: TTTDDialog.onTextSubmit; onTextSubmit(value: string): Promise<OnTestSubmitRetValue>;
renderWarning?: TTTDDialog.renderWarning;
persistenceAdapter: TTDPersistenceAdapter;
} }
| { __fallback: true }, | { __fallback: true },
) => { ) => {
@@ -42,7 +81,7 @@ export const TTDDialog = (
/** /**
* Text to diagram (TTD) dialog * Text to diagram (TTD) dialog
*/ */
const TTDDialogBase = withInternalFallback( export const TTDDialogBase = withInternalFallback(
"TTDDialogBase", "TTDDialogBase",
({ ({
tab, tab,
@@ -51,15 +90,127 @@ const TTDDialogBase = withInternalFallback(
tab: "text-to-diagram" | "mermaid"; tab: "text-to-diagram" | "mermaid";
} & ( } & (
| { | {
onTextSubmit( onTextSubmit(value: string): Promise<OnTestSubmitRetValue>;
props: TTTDDialog.OnTextSubmitProps,
): Promise<TTTDDialog.OnTextSubmitRetValue>;
renderWarning?: TTTDDialog.renderWarning;
persistenceAdapter: TTDPersistenceAdapter;
} }
| { __fallback: true } | { __fallback: true }
)) => { )) => {
const app = useApp(); const app = useApp();
const setAppState = useExcalidrawSetAppState();
const someRandomDivRef = useRef<HTMLDivElement>(null);
const [ttdGeneration, setTtdGeneration] = useAtom(ttdGenerationAtom);
const [text, setText] = useState(ttdGeneration?.prompt ?? "");
const prompt = text.trim();
const handleTextChange: ChangeEventHandler<HTMLTextAreaElement> = (
event,
) => {
setText(event.target.value);
setTtdGeneration((s) => ({
generatedResponse: s?.generatedResponse ?? null,
prompt: event.target.value,
}));
};
const [onTextSubmitInProgess, setOnTextSubmitInProgess] = useState(false);
const [rateLimits, setRateLimits] = useAtom(rateLimitsAtom);
const onGenerate = async () => {
if (
prompt.length > MAX_PROMPT_LENGTH ||
prompt.length < MIN_PROMPT_LENGTH ||
onTextSubmitInProgess ||
rateLimits?.rateLimitRemaining === 0 ||
// means this is not a text-to-diagram dialog (needed for TS only)
"__fallback" in rest
) {
if (prompt.length < MIN_PROMPT_LENGTH) {
setError(
new Error(
`Prompt is too short (min ${MIN_PROMPT_LENGTH} characters)`,
),
);
}
if (prompt.length > MAX_PROMPT_LENGTH) {
setError(
new Error(
`Prompt is too long (max ${MAX_PROMPT_LENGTH} characters)`,
),
);
}
return;
}
try {
setOnTextSubmitInProgess(true);
trackEvent("ai", "generate", "ttd");
const { generatedResponse, error, rateLimit, rateLimitRemaining } =
await rest.onTextSubmit(prompt);
if (typeof generatedResponse === "string") {
setTtdGeneration((s) => ({
generatedResponse,
prompt: s?.prompt ?? null,
}));
}
if (isFiniteNumber(rateLimit) && isFiniteNumber(rateLimitRemaining)) {
setRateLimits({ rateLimit, rateLimitRemaining });
}
if (error) {
setError(error);
return;
}
if (!generatedResponse) {
setError(new Error("Generation failed"));
return;
}
try {
await convertMermaidToExcalidraw({
canvasRef: someRandomDivRef,
data,
mermaidToExcalidrawLib,
setError,
mermaidDefinition: generatedResponse,
});
trackEvent("ai", "mermaid parse success", "ttd");
} catch (error: any) {
console.info(
`%cTTD mermaid render errror: ${error.message}`,
"color: red",
);
console.info(
`>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\nTTD mermaid definition render errror: ${error.message}`,
"color: yellow",
);
trackEvent("ai", "mermaid parse failed", "ttd");
setError(
new Error(
"Generated an invalid diagram :(. You may also try a different prompt.",
),
);
}
} catch (error: any) {
let message: string | undefined = error.message;
if (!message || message === "Failed to fetch") {
message = "Request failed";
}
setError(new Error(message));
} finally {
setOnTextSubmitInProgess(false);
}
};
const refOnGenerate = useRef(onGenerate);
refOnGenerate.current = onGenerate;
const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] = const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] =
useState<MermaidToExcalidrawLibProps>({ useState<MermaidToExcalidrawLibProps>({
@@ -75,13 +226,20 @@ const TTDDialogBase = withInternalFallback(
fn(); fn();
}, [mermaidToExcalidrawLib.api]); }, [mermaidToExcalidrawLib.api]);
const data = useRef<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>({ elements: [], files: null });
const [error, setError] = useState<Error | null>(null);
return ( return (
<Dialog <Dialog
className="ttd-dialog" className="ttd-dialog"
onCloseRequest={() => { onCloseRequest={() => {
app.setOpenDialog(null); app.setOpenDialog(null);
}} }}
size={1520} size={1200}
title={false} title={false}
{...rest} {...rest}
autofocus={false} autofocus={false}
@@ -92,35 +250,150 @@ const TTDDialogBase = withInternalFallback(
) : ( ) : (
<TTDDialogTabTriggers> <TTDDialogTabTriggers>
<TTDDialogTabTrigger tab="text-to-diagram"> <TTDDialogTabTrigger tab="text-to-diagram">
<div className="ttd-dialog-tab-trigger__content"> <div style={{ display: "flex", alignItems: "center" }}>
{t("labels.textToDiagram")} {t("labels.textToDiagram")}
<div className="ttd-dialog-tab-trigger__badge"> <div
{t("chat.aiBeta")} style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1px 6px",
marginLeft: "10px",
fontSize: 10,
borderRadius: "12px",
background: "var(--color-promo)",
color: "var(--color-surface-lowest)",
}}
>
AI Beta
</div> </div>
</div> </div>
</TTDDialogTabTrigger> </TTDDialogTabTrigger>
<TTDDialogTabTrigger tab="mermaid"> <TTDDialogTabTrigger tab="mermaid">Mermaid</TTDDialogTabTrigger>
{t("mermaid.label")}
</TTDDialogTabTrigger>
</TTDDialogTabTriggers> </TTDDialogTabTriggers>
)} )}
{!("__fallback" in rest) && (
<TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
<TextToDiagram
mermaidToExcalidrawLib={mermaidToExcalidrawLib}
onTextSubmit={rest.onTextSubmit}
renderWarning={rest.renderWarning}
persistenceAdapter={rest.persistenceAdapter}
/>
</TTDDialogTab>
)}
<TTDDialogTab className="ttd-dialog-content" tab="mermaid"> <TTDDialogTab className="ttd-dialog-content" tab="mermaid">
<MermaidToExcalidraw <MermaidToExcalidraw
mermaidToExcalidrawLib={mermaidToExcalidrawLib} mermaidToExcalidrawLib={mermaidToExcalidrawLib}
isActive={tab === "mermaid"}
/> />
</TTDDialogTab> </TTDDialogTab>
{!("__fallback" in rest) && (
<TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
<div className="ttd-dialog-desc">
Currently we use Mermaid as a middle step, so you'll get best
results if you describe a diagram, workflow, flow chart, and
similar.
</div>
<TTDDialogPanels>
<TTDDialogPanel
label={t("labels.prompt")}
panelAction={{
action: onGenerate,
label: "Generate",
icon: ArrowRightIcon,
}}
onTextSubmitInProgess={onTextSubmitInProgess}
panelActionDisabled={
prompt.length > MAX_PROMPT_LENGTH ||
rateLimits?.rateLimitRemaining === 0
}
renderTopRight={() => {
if (!rateLimits) {
return null;
}
return (
<div
className="ttd-dialog-rate-limit"
style={{
fontSize: 12,
marginLeft: "auto",
color:
rateLimits.rateLimitRemaining === 0
? "var(--color-danger)"
: undefined,
}}
>
{rateLimits.rateLimitRemaining} requests left today
</div>
);
}}
renderSubmitShortcut={() => <TTDDialogSubmitShortcut />}
renderBottomRight={() => {
if (typeof ttdGeneration?.generatedResponse === "string") {
return (
<div
className="excalidraw-link"
style={{ marginLeft: "auto", fontSize: 14 }}
onClick={() => {
if (
typeof ttdGeneration?.generatedResponse ===
"string"
) {
saveMermaidDataToStorage(
ttdGeneration.generatedResponse,
);
setAppState({
openDialog: { name: "ttd", tab: "mermaid" },
});
}
}}
>
View as Mermaid
<InlineIcon icon={ArrowRightIcon} />
</div>
);
}
const ratio = prompt.length / MAX_PROMPT_LENGTH;
if (ratio > 0.8) {
return (
<div
style={{
marginLeft: "auto",
fontSize: 12,
fontFamily: "monospace",
color:
ratio > 1 ? "var(--color-danger)" : undefined,
}}
>
Length: {prompt.length}/{MAX_PROMPT_LENGTH}
</div>
);
}
return null;
}}
>
<TTDDialogInput
onChange={handleTextChange}
input={text}
placeholder={"Describe what you want to see..."}
onKeyboardSubmit={() => {
refOnGenerate.current();
}}
/>
</TTDDialogPanel>
<TTDDialogPanel
label="Preview"
panelAction={{
action: () => {
console.info("Panel action clicked");
insertToEditor({ app, data });
},
label: "Insert",
icon: ArrowRightIcon,
}}
>
<TTDDialogOutput
canvasRef={someRandomDivRef}
error={error}
loaded={mermaidToExcalidrawLib.loaded}
/>
</TTDDialogPanel>
</TTDDialogPanels>
</TTDDialogTab>
)}
</TTDDialogTabs> </TTDDialogTabs>
</Dialog> </Dialog>
); );
@@ -1,58 +1,36 @@
import clsx from "clsx";
import Spinner from "../Spinner"; import Spinner from "../Spinner";
import { t } from "../../i18n";
import { alertTriangleIcon } from "../icons"; const ErrorComp = ({ error }: { error: string }) => {
return (
<div
data-testid="ttd-dialog-output-error"
className="ttd-dialog-output-error"
>
Error! <p>{error}</p>
</div>
);
};
interface TTDDialogOutputProps { interface TTDDialogOutputProps {
error: Error | null; error: Error | null;
canvasRef: React.RefObject<HTMLDivElement | null>; canvasRef: React.RefObject<HTMLDivElement | null>;
loaded: boolean; loaded: boolean;
hideErrorDetails?: boolean;
} }
export const TTDDialogOutput = ({ export const TTDDialogOutput = ({
error, error,
canvasRef, canvasRef,
loaded, loaded,
hideErrorDetails,
}: TTDDialogOutputProps) => { }: TTDDialogOutputProps) => {
return ( return (
<div <div className="ttd-dialog-output-wrapper">
className={`ttd-dialog-output-wrapper ${ {error && <ErrorComp error={error.message} />}
error ? "ttd-dialog-output-wrapper--error" : ""
}`}
>
{error && (
<div
key="error"
data-testid="ttd-dialog-output-error"
className="ttd-dialog-output-error"
>
<div className="ttd-dialog-output-error-content">
<div className="ttd-dialog-output-error-icon">
{alertTriangleIcon}
</div>
<div className="ttd-dialog-output-error-title">
{t("ttd.error")}
</div>
<div className="ttd-dialog-output-error-message">
{hideErrorDetails
? t("chat.errors.mermaidParseError")
: error.message}
</div>
</div>
</div>
)}
{loaded ? ( {loaded ? (
<div <div
key="canvas" ref={canvasRef}
className={clsx("ttd-dialog-output-canvas-container", { style={{ opacity: error ? "0.15" : 1 }}
invisible: !!error, className="ttd-dialog-output-canvas-container"
})} />
>
<div ref={canvasRef} className="ttd-dialog-output-canvas-content" />
</div>
) : ( ) : (
<Spinner size="2rem" /> <Spinner size="2rem" />
)} )}
@@ -1,76 +1,53 @@
import clsx from "clsx"; import clsx from "clsx";
import { Fragment } from "react";
import { Button } from "../Button"; import { Button } from "../Button";
import Spinner from "../Spinner"; import Spinner from "../Spinner";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
export type TTDPanelAction = {
label: string;
action?: () => void;
icon?: ReactNode;
variant: "button" | "link" | "rateLimit";
disabled?: boolean;
className?: string;
};
interface TTDDialogPanelProps { interface TTDDialogPanelProps {
label?: string | ReactNode; label: string;
children: ReactNode; children: ReactNode;
panelActions?: TTDPanelAction[]; panelAction?: {
label: string;
action: () => void;
icon?: ReactNode;
};
panelActionDisabled?: boolean;
onTextSubmitInProgess?: boolean; onTextSubmitInProgess?: boolean;
renderTopRight?: () => ReactNode; renderTopRight?: () => ReactNode;
renderSubmitShortcut?: () => ReactNode; renderSubmitShortcut?: () => ReactNode;
className?: string; renderBottomRight?: () => ReactNode;
panelActionJustifyContent?:
| "flex-start"
| "flex-end"
| "center"
| "space-between"
| "space-around"
| "space-evenly";
} }
export const TTDDialogPanel = ({ export const TTDDialogPanel = ({
label, label,
children, children,
panelActions = [], panelAction,
panelActionDisabled = false,
onTextSubmitInProgess, onTextSubmitInProgess,
renderTopRight, renderTopRight,
renderSubmitShortcut, renderSubmitShortcut,
className, renderBottomRight,
panelActionJustifyContent = "flex-start",
}: TTDDialogPanelProps) => { }: TTDDialogPanelProps) => {
const renderPanelAction = (panelAction: TTDPanelAction) => { return (
if (panelAction?.variant === "link") { <div className="ttd-dialog-panel">
return ( <div className="ttd-dialog-panel__header">
<button <label>{label}</label>
className={clsx( {renderTopRight?.()}
"ttd-dialog-panel-action-link", </div>
panelAction.className,
)}
onClick={panelAction.action}
disabled={panelAction?.disabled || onTextSubmitInProgess}
type="button"
>
{panelAction.label}
{panelAction.icon && (
<span className="ttd-dialog-panel-action-link__icon">
{panelAction.icon}
</span>
)}
</button>
);
}
if (panelAction?.variant === "button") { {children}
return ( <div
className={clsx("ttd-dialog-panel-button-container", {
invisible: !panelAction,
})}
style={{ display: "flex", alignItems: "center" }}
>
<Button <Button
className={clsx("ttd-dialog-panel-button", panelAction.className)} className="ttd-dialog-panel-button"
onSelect={panelAction.action ? panelAction.action : () => {}} onSelect={panelAction ? panelAction.action : () => {}}
disabled={panelAction?.disabled || onTextSubmitInProgess} disabled={panelActionDisabled || onTextSubmitInProgess}
> >
<div className={clsx({ invisible: onTextSubmitInProgess })}> <div className={clsx({ invisible: onTextSubmitInProgess })}>
{panelAction?.label} {panelAction?.label}
@@ -78,46 +55,10 @@ export const TTDDialogPanel = ({
</div> </div>
{onTextSubmitInProgess && <Spinner />} {onTextSubmitInProgess && <Spinner />}
</Button> </Button>
); {!panelActionDisabled &&
} !onTextSubmitInProgess &&
renderSubmitShortcut?.()}
if (panelAction?.variant === "rateLimit") { {renderBottomRight?.()}
return (
<div
className={clsx(
"ttd-dialog-panel__rate-limit",
panelAction.className,
)}
>
{panelAction.label}
</div>
);
}
};
return (
<div className={clsx("ttd-dialog-panel", className)}>
{(label || renderTopRight) && (
<div className="ttd-dialog-panel__header">
{typeof label === "string" ? <label>{label}</label> : label}
{renderTopRight?.()}
</div>
)}
{children}
<div
className={clsx("ttd-dialog-panel-button-container", {
invisible: !panelActions.length,
})}
style={{
justifyContent: panelActionJustifyContent,
}}
>
{panelActions.filter(Boolean).map((panelAction) => (
<Fragment key={panelAction.label}>
{renderPanelAction(panelAction)}
</Fragment>
))}
{!onTextSubmitInProgess && renderSubmitShortcut?.()}
</div> </div>
</div> </div>
); );
@@ -26,9 +26,9 @@ export const TTDDialogTrigger = ({
setAppState({ openDialog: { name: "ttd", tab: "text-to-diagram" } }); setAppState({ openDialog: { name: "ttd", tab: "text-to-diagram" } });
}} }}
icon={icon ?? brainIcon} icon={icon ?? brainIcon}
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
> >
{children ?? t("labels.textToDiagram")} {children ?? t("labels.textToDiagram")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item> </DropdownMenu.Item>
</TTDDialogTriggerTunnel.In> </TTDDialogTriggerTunnel.In>
); );
@@ -1,47 +0,0 @@
import { t } from "../../i18n";
import { ArrowRightIcon } from "../icons";
import { TTDDialogPanel } from "./TTDDialogPanel";
import { TTDDialogOutput } from "./TTDDialogOutput";
import type { TTDPanelAction } from "./TTDDialogPanel";
interface TTDPreviewPanelProps {
canvasRef: React.RefObject<HTMLDivElement | null>;
error: Error | null;
loaded: boolean;
onInsert: () => void;
hideErrorDetails?: boolean;
}
export const TTDPreviewPanel = ({
canvasRef,
error,
loaded,
onInsert,
hideErrorDetails,
}: TTDPreviewPanelProps) => {
const actions: TTDPanelAction[] = [
{
action: onInsert,
label: t("chat.insert"),
icon: ArrowRightIcon,
variant: "button",
},
];
return (
<TTDDialogPanel
panelActionJustifyContent="flex-end"
panelActions={actions}
className="ttd-dialog-preview-panel"
>
<TTDDialogOutput
canvasRef={canvasRef}
error={error}
loaded={loaded}
hideErrorDetails={hideErrorDetails}
/>
</TTDDialogPanel>
);
};

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