Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e01cd73a7 | ||
|
|
c92ae37f50 | ||
|
|
071043adae | ||
|
|
9616e63e23 |
+2
-2
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(`
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
@use "../css/variables.module" as *;
|
@import "../css/variables.module.scss";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.Avatar {
|
.Avatar {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
@use "../css/variables.module" as *;
|
@import "../css/variables.module.scss";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
&.excalidraw-modal-container {
|
&.excalidraw-modal-container {
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
@use "../css/variables.module" as *;
|
@import "../css/variables.module.scss";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
--slider-thumb-size: 16px;
|
--slider-thumb-size: 16px;
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
@use "../css/variables.module" as *;
|
@import "open-color/open-color.scss";
|
||||||
|
|
||||||
$duration: 1.6s;
|
$duration: 1.6s;
|
||||||
|
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user