mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-08-31 22:27:34 +02:00
Compare commits
18 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
e72721e39f | ||
![]() |
f6ac19bce9 | ||
![]() |
530e92189f | ||
![]() |
0958241589 | ||
![]() |
42d8c5a040 | ||
![]() |
f299514e44 | ||
![]() |
dd220bcaea | ||
![]() |
fe75f29c15 | ||
![]() |
14845a343b | ||
![]() |
dd8a7d41e2 | ||
![]() |
fda5c6fdf7 | ||
![]() |
3d1631f375 | ||
![]() |
c7ee46e7f8 | ||
![]() |
d1e4421823 | ||
![]() |
7c9cf30909 | ||
![]() |
1e37dbd60e | ||
![]() |
f8d5c2a1b6 | ||
![]() |
23b24ea5c3 |
@@ -13,6 +13,8 @@ VITE_APP_PORTAL_URL=
|
||||
VITE_APP_PLUS_LP=https://plus.excalidraw.com
|
||||
VITE_APP_PLUS_APP=https://app.excalidraw.com
|
||||
|
||||
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"}'
|
||||
|
||||
# put these in your .env.local, or make sure you don't commit!
|
||||
|
@@ -9,6 +9,8 @@ VITE_APP_PORTAL_URL=https://portal.excalidraw.com
|
||||
VITE_APP_PLUS_LP=https://plus.excalidraw.com
|
||||
VITE_APP_PLUS_APP=https://app.excalidraw.com
|
||||
|
||||
VITE_APP_AI_BACKEND=https://oss-ai.excalidraw.com
|
||||
|
||||
# Fill to set socket server URL used for collaboration.
|
||||
# Meant for forks only: excalidraw.com uses custom VITE_APP_PORTAL_URL flow
|
||||
VITE_APP_WS_SERVER_URL=
|
||||
|
@@ -25,6 +25,8 @@ import {
|
||||
Excalidraw,
|
||||
defaultLang,
|
||||
LiveCollaborationTrigger,
|
||||
TTDDialog,
|
||||
TTDDialogTrigger,
|
||||
} from "../src/packages/excalidraw/index";
|
||||
import {
|
||||
AppState,
|
||||
@@ -102,6 +104,7 @@ import { ShareableLinkDialog } from "../src/components/ShareableLinkDialog";
|
||||
import { openConfirmModal } from "../src/components/OverwriteConfirm/OverwriteConfirmState";
|
||||
import { OverwriteConfirmDialog } from "../src/components/OverwriteConfirm/OverwriteConfirm";
|
||||
import Trans from "../src/components/Trans";
|
||||
import { drawingIcon } from "../src/components/icons";
|
||||
|
||||
polyfill();
|
||||
|
||||
@@ -773,6 +776,65 @@ const ExcalidrawWrapper = () => {
|
||||
)}
|
||||
</OverwriteConfirmDialog>
|
||||
<AppFooter />
|
||||
<TTDDialog
|
||||
onTextSubmit={async (input, type) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_APP_AI_BACKEND}/v1/ai/${type}/generate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ prompt: input }),
|
||||
},
|
||||
);
|
||||
|
||||
const rateLimit = response.headers.has("X-Ratelimit-Limit")
|
||||
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
|
||||
: undefined;
|
||||
|
||||
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");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TTDDialogTrigger />
|
||||
<TTDDialogTrigger tab="text-to-drawing" icon={drawingIcon}>
|
||||
{t("labels.textToDrawing")}
|
||||
</TTDDialogTrigger>
|
||||
{isCollaborating && isOffline && (
|
||||
<div className="collab-offline-warning">
|
||||
{t("alerts.collabOfflineWarning")}
|
||||
|
@@ -96,7 +96,7 @@
|
||||
"vitest-canvas-mock": "0.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": "18.0.0 - 20.x.x"
|
||||
},
|
||||
"homepage": ".",
|
||||
"name": "excalidraw",
|
||||
|
@@ -9,6 +9,7 @@ import {
|
||||
} from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { t } from "../i18n";
|
||||
@@ -28,7 +29,7 @@ const alignActionsPredicate = (
|
||||
return (
|
||||
selectedElements.length > 1 &&
|
||||
// TODO enable aligning frames when implemented properly
|
||||
!selectedElements.some((el) => el.type === "frame")
|
||||
!selectedElements.some((el) => isFrameLikeElement(el))
|
||||
);
|
||||
};
|
||||
|
||||
|
@@ -265,7 +265,21 @@ export const zoomToFit = ({
|
||||
30.0,
|
||||
) as NormalizedZoomValue;
|
||||
|
||||
scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX;
|
||||
let appStateWidth = appState.width;
|
||||
|
||||
if (appState.openSidebar) {
|
||||
const sidebarDOMElem = document.querySelector(
|
||||
".sidebar",
|
||||
) as HTMLElement | null;
|
||||
const sidebarWidth = sidebarDOMElem?.offsetWidth ?? 0;
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
appStateWidth = !isRTL
|
||||
? appState.width - sidebarWidth
|
||||
: appState.width + sidebarWidth;
|
||||
}
|
||||
|
||||
scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX;
|
||||
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
|
||||
} else {
|
||||
newZoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
|
||||
|
@@ -10,7 +10,7 @@ import { newElementWith } from "../element/mutateElement";
|
||||
import { getElementsInGroup } from "../groups";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
|
||||
import { updateActiveTool } from "../utils";
|
||||
import { TrashIcon } from "../components/icons";
|
||||
|
||||
@@ -20,7 +20,7 @@ const deleteSelectedElements = (
|
||||
) => {
|
||||
const framesToBeDeleted = new Set(
|
||||
getSelectedElements(
|
||||
elements.filter((el) => el.type === "frame"),
|
||||
elements.filter((el) => isFrameLikeElement(el)),
|
||||
appState,
|
||||
).map((el) => el.id),
|
||||
);
|
||||
|
@@ -5,6 +5,7 @@ import {
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { distributeElements, Distribution } from "../distribute";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { t } from "../i18n";
|
||||
@@ -19,7 +20,7 @@ const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
|
||||
return (
|
||||
selectedElements.length > 1 &&
|
||||
// TODO enable distributing frames when implemented properly
|
||||
!selectedElements.some((el) => el.type === "frame")
|
||||
!selectedElements.some((el) => isFrameLikeElement(el))
|
||||
);
|
||||
};
|
||||
|
||||
|
@@ -20,7 +20,7 @@ import {
|
||||
bindTextToShapeAfterDuplication,
|
||||
getBoundTextElement,
|
||||
} from "../element/textElement";
|
||||
import { isBoundToContainer, isFrameElement } from "../element/typeChecks";
|
||||
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
|
||||
import { normalizeElementOrder } from "../element/sortElements";
|
||||
import { DuplicateIcon } from "../components/icons";
|
||||
import {
|
||||
@@ -140,11 +140,11 @@ const duplicateElements = (
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
const isElementAFrame = isFrameElement(element);
|
||||
const isElementAFrameLike = isFrameLikeElement(element);
|
||||
|
||||
if (idsOfElementsToDuplicate.get(element.id)) {
|
||||
// if a group or a container/bound-text or frame, duplicate atomically
|
||||
if (element.groupIds.length || boundTextElement || isElementAFrame) {
|
||||
if (element.groupIds.length || boundTextElement || isElementAFrameLike) {
|
||||
const groupId = getSelectedGroupForElement(appState, element);
|
||||
if (groupId) {
|
||||
// TODO:
|
||||
@@ -154,7 +154,7 @@ const duplicateElements = (
|
||||
sortedElements,
|
||||
groupId,
|
||||
).flatMap((element) =>
|
||||
isFrameElement(element)
|
||||
isFrameLikeElement(element)
|
||||
? [...getFrameChildren(elements, element.id), element]
|
||||
: [element],
|
||||
);
|
||||
@@ -180,7 +180,7 @@ const duplicateElements = (
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (isElementAFrame) {
|
||||
if (isElementAFrameLike) {
|
||||
const elementsInFrame = getFrameChildren(sortedElements, element.id);
|
||||
|
||||
elementsWithClones.push(
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { KEYS } from "../keys";
|
||||
import { arrayToMap } from "../utils";
|
||||
@@ -51,7 +52,7 @@ export const actionToggleElementLock = register({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: false,
|
||||
});
|
||||
if (selected.length === 1 && selected[0].type !== "frame") {
|
||||
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
|
||||
return selected[0].locked
|
||||
? "labels.elementLock.unlock"
|
||||
: "labels.elementLock.lock";
|
||||
|
@@ -7,23 +7,27 @@ import { AppClassProperties, AppState } from "../types";
|
||||
import { updateActiveTool } from "../utils";
|
||||
import { setCursorForShape } from "../cursor";
|
||||
import { register } from "./register";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
|
||||
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
return selectedElements.length === 1 && selectedElements[0].type === "frame";
|
||||
return (
|
||||
selectedElements.length === 1 && isFrameLikeElement(selectedElements[0])
|
||||
);
|
||||
};
|
||||
|
||||
export const actionSelectAllElementsInFrame = register({
|
||||
name: "selectAllElementsInFrame",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedFrame = app.scene.getSelectedElements(appState)[0];
|
||||
const selectedElement =
|
||||
app.scene.getSelectedElements(appState).at(0) || null;
|
||||
|
||||
if (selectedFrame && selectedFrame.type === "frame") {
|
||||
if (isFrameLikeElement(selectedElement)) {
|
||||
const elementsInFrame = getFrameChildren(
|
||||
getNonDeletedElements(elements),
|
||||
selectedFrame.id,
|
||||
selectedElement.id,
|
||||
).filter((element) => !(element.type === "text" && element.containerId));
|
||||
|
||||
return {
|
||||
@@ -54,15 +58,20 @@ export const actionRemoveAllElementsFromFrame = register({
|
||||
name: "removeAllElementsFromFrame",
|
||||
trackEvent: { category: "history" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedFrame = app.scene.getSelectedElements(appState)[0];
|
||||
const selectedElement =
|
||||
app.scene.getSelectedElements(appState).at(0) || null;
|
||||
|
||||
if (selectedFrame && selectedFrame.type === "frame") {
|
||||
if (isFrameLikeElement(selectedElement)) {
|
||||
return {
|
||||
elements: removeAllElementsFromFrame(elements, selectedFrame, appState),
|
||||
elements: removeAllElementsFromFrame(
|
||||
elements,
|
||||
selectedElement,
|
||||
appState,
|
||||
),
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: {
|
||||
[selectedFrame.id]: true,
|
||||
[selectedElement.id]: true,
|
||||
},
|
||||
},
|
||||
commitToHistory: true,
|
||||
|
@@ -22,8 +22,8 @@ import { AppClassProperties, AppState } from "../types";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import {
|
||||
getElementsInResizingFrame,
|
||||
getFrameElements,
|
||||
groupByFrames,
|
||||
getFrameLikeElements,
|
||||
groupByFrameLikes,
|
||||
removeElementsFromFrame,
|
||||
replaceAllElementsInFrame,
|
||||
} from "../frame";
|
||||
@@ -102,7 +102,7 @@ export const actionGroup = register({
|
||||
// when it happens, we want to remove elements that are in the frame
|
||||
// and are going to be grouped from the frame (mouthful, I know)
|
||||
if (groupingElementsFromDifferentFrames) {
|
||||
const frameElementsMap = groupByFrames(selectedElements);
|
||||
const frameElementsMap = groupByFrameLikes(selectedElements);
|
||||
|
||||
frameElementsMap.forEach((elementsInFrame, frameId) => {
|
||||
nextElements = removeElementsFromFrame(
|
||||
@@ -219,7 +219,7 @@ export const actionUngroup = register({
|
||||
.map((element) => element.frameId!),
|
||||
);
|
||||
|
||||
const targetFrames = getFrameElements(elements).filter((frame) =>
|
||||
const targetFrames = getFrameLikeElements(elements).filter((frame) =>
|
||||
selectedElementFrameIds.has(frame.id),
|
||||
);
|
||||
|
||||
|
@@ -56,13 +56,18 @@ export const actionShortcuts = register({
|
||||
viewMode: true,
|
||||
trackEvent: { category: "menu", action: "toggleHelpDialog" },
|
||||
perform: (_elements, appState, _, { focusContainer }) => {
|
||||
if (appState.openDialog === "help") {
|
||||
if (appState.openDialog?.name === "help") {
|
||||
focusContainer();
|
||||
}
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
openDialog: appState.openDialog === "help" ? null : "help",
|
||||
openDialog:
|
||||
appState.openDialog?.name === "help"
|
||||
? null
|
||||
: {
|
||||
name: "help",
|
||||
},
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
|
@@ -20,7 +20,7 @@ import {
|
||||
hasBoundTextElement,
|
||||
canApplyRoundnessTypeToElement,
|
||||
getDefaultRoundnessTypeForElement,
|
||||
isFrameElement,
|
||||
isFrameLikeElement,
|
||||
isArrowElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getSelectedElements } from "../scene";
|
||||
@@ -138,7 +138,7 @@ export const actionPasteStyles = register({
|
||||
});
|
||||
}
|
||||
|
||||
if (isFrameElement(element)) {
|
||||
if (isFrameLikeElement(element)) {
|
||||
newElement = newElementWith(newElement, {
|
||||
roundness: null,
|
||||
backgroundColor: "transparent",
|
||||
|
@@ -1,3 +1,7 @@
|
||||
// place here categories that you want to track. We want to track just a
|
||||
// small subset of categories at a given time.
|
||||
const ALLOWED_CATEGORIES_TO_TRACK = ["ai"] as string[];
|
||||
|
||||
export const trackEvent = (
|
||||
category: string,
|
||||
action: string,
|
||||
@@ -5,13 +9,13 @@ export const trackEvent = (
|
||||
value?: number,
|
||||
) => {
|
||||
try {
|
||||
// place here categories that you want to track as events
|
||||
// KEEP IN MIND THE PRICING
|
||||
const ALLOWED_CATEGORIES_TO_TRACK = [] as string[];
|
||||
// Uncomment the next line to track locally
|
||||
// console.log("Track Event", { category, action, label, value });
|
||||
|
||||
if (typeof window === "undefined" || import.meta.env.VITE_WORKER_ID) {
|
||||
// prettier-ignore
|
||||
if (
|
||||
typeof window === "undefined"
|
||||
|| import.meta.env.VITE_WORKER_ID
|
||||
// comment out to debug locally
|
||||
|| import.meta.env.PROD
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -19,6 +23,10 @@ export const trackEvent = (
|
||||
return;
|
||||
}
|
||||
|
||||
if (!import.meta.env.PROD) {
|
||||
console.info("trackEvent", { category, action, label, value });
|
||||
}
|
||||
|
||||
if (window.sa_event) {
|
||||
window.sa_event(action, {
|
||||
category,
|
||||
|
@@ -9,7 +9,10 @@ import {
|
||||
EXPORT_DATA_TYPES,
|
||||
MIME_TYPES,
|
||||
} from "./constants";
|
||||
import { isInitializedImageElement } from "./element/typeChecks";
|
||||
import {
|
||||
isFrameLikeElement,
|
||||
isInitializedImageElement,
|
||||
} from "./element/typeChecks";
|
||||
import { deepCopyElement } from "./element/newElement";
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
import { getContainingFrame } from "./frame";
|
||||
@@ -124,7 +127,7 @@ export const serializeAsClipboardJSON = ({
|
||||
files: BinaryFiles | null;
|
||||
}) => {
|
||||
const framesToCopy = new Set(
|
||||
elements.filter((element) => element.type === "frame"),
|
||||
elements.filter((element) => isFrameLikeElement(element)),
|
||||
);
|
||||
let foundFile = false;
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { ExcalidrawElement, ExcalidrawElementType } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useDevice } from "../components/App";
|
||||
import {
|
||||
@@ -36,8 +36,11 @@ import {
|
||||
frameToolIcon,
|
||||
mermaidLogoIcon,
|
||||
laserPointerToolIcon,
|
||||
OpenAIIcon,
|
||||
MagicIcon,
|
||||
} from "./icons";
|
||||
import { KEYS } from "../keys";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
appState,
|
||||
@@ -79,7 +82,8 @@ export const SelectedShapeActions = ({
|
||||
const showLinkIcon =
|
||||
targetElements.length === 1 || isSingleElementBoundContainer;
|
||||
|
||||
let commonSelectedType: string | null = targetElements[0]?.type || null;
|
||||
let commonSelectedType: ExcalidrawElementType | null =
|
||||
targetElements[0]?.type || null;
|
||||
|
||||
for (const element of targetElements) {
|
||||
if (element.type !== commonSelectedType) {
|
||||
@@ -94,7 +98,8 @@ export const SelectedShapeActions = ({
|
||||
{((hasStrokeColor(appState.activeTool.type) &&
|
||||
appState.activeTool.type !== "image" &&
|
||||
commonSelectedType !== "image" &&
|
||||
commonSelectedType !== "frame") ||
|
||||
commonSelectedType !== "frame" &&
|
||||
commonSelectedType !== "magicframe") ||
|
||||
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
||||
renderAction("changeStrokeColor")}
|
||||
</div>
|
||||
@@ -231,6 +236,8 @@ export const ShapesSwitcher = ({
|
||||
const laserToolSelected = activeTool.type === "laser";
|
||||
const embeddableToolSelected = activeTool.type === "embeddable";
|
||||
|
||||
const { TTDDialogTriggerTunnel } = useTunnels();
|
||||
|
||||
return (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||
@@ -331,13 +338,43 @@ export const ShapesSwitcher = ({
|
||||
>
|
||||
{t("toolBar.laser")}
|
||||
</DropdownMenu.Item>
|
||||
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
|
||||
Generate
|
||||
</div>
|
||||
{app.props.aiEnabled !== false && <TTDDialogTriggerTunnel.Out />}
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setOpenDialog("mermaid")}
|
||||
onSelect={() => app.setOpenDialog({ name: "ttd", tab: "mermaid" })}
|
||||
icon={mermaidLogoIcon}
|
||||
data-testid="toolbar-embeddable"
|
||||
>
|
||||
{t("toolBar.mermaidToExcalidraw")}
|
||||
</DropdownMenu.Item>
|
||||
{app.props.aiEnabled !== false && (
|
||||
<>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.onMagicframeToolSelect()}
|
||||
icon={MagicIcon}
|
||||
data-testid="toolbar-magicframe"
|
||||
>
|
||||
{t("toolBar.magicframe")}
|
||||
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
trackEvent("ai", "open-settings", "d2c");
|
||||
app.setOpenDialog({
|
||||
name: "settings",
|
||||
source: "settings",
|
||||
tab: "diagram-to-code",
|
||||
});
|
||||
}}
|
||||
icon={OpenAIIcon}
|
||||
data-testid="toolbar-magicSettings"
|
||||
>
|
||||
{t("toolBar.magicSettings")}
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,11 @@ import clsx from "clsx";
|
||||
import { composeEventHandlers } from "../utils";
|
||||
import "./Button.scss";
|
||||
|
||||
interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
interface ButtonProps
|
||||
extends React.DetailedHTMLProps<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
HTMLButtonElement
|
||||
> {
|
||||
type?: "button" | "submit" | "reset";
|
||||
onSelect: () => any;
|
||||
/** whether button is in active state */
|
||||
|
15
src/components/InlineIcon.tsx
Normal file
15
src/components/InlineIcon.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
export const InlineIcon = ({ icon }: { icon: JSX.Element }) => {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
width: "1em",
|
||||
margin: "0 0.5ex 0 0.5ex",
|
||||
display: "inline-block",
|
||||
lineHeight: 0,
|
||||
verticalAlign: "middle",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
};
|
@@ -117,7 +117,7 @@ export const JSONExportDialog = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{appState.openDialog === "jsonExport" && (
|
||||
{appState.openDialog?.name === "jsonExport" && (
|
||||
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
|
||||
<JSONExportModal
|
||||
elements={elements}
|
||||
|
@@ -1,7 +1,12 @@
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { CLASSES, DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_WIDTH } from "../constants";
|
||||
import {
|
||||
CLASSES,
|
||||
DEFAULT_SIDEBAR,
|
||||
LIBRARY_SIDEBAR_WIDTH,
|
||||
TOOL_TYPE,
|
||||
} from "../constants";
|
||||
import { showSelectedShapeActions } from "../element";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { Language, t } from "../i18n";
|
||||
@@ -56,6 +61,8 @@ import { mutateElement } from "../element/mutateElement";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import Scene from "../scene/Scene";
|
||||
import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
|
||||
import { MagicSettings } from "./MagicSettings";
|
||||
import { TTDDialog } from "./TTDDialog/TTDDialog";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
@@ -77,6 +84,14 @@ interface LayerUIProps {
|
||||
children?: React.ReactNode;
|
||||
app: AppClassProperties;
|
||||
isCollaborating: boolean;
|
||||
openAIKey: string | null;
|
||||
isOpenAIKeyPersisted: boolean;
|
||||
onOpenAIAPIKeyChange: (apiKey: string, shouldPersist: boolean) => void;
|
||||
onMagicSettingsConfirm: (
|
||||
apiKey: string,
|
||||
shouldPersist: boolean,
|
||||
source: "tool" | "generation" | "settings",
|
||||
) => void;
|
||||
}
|
||||
|
||||
const DefaultMainMenu: React.FC<{
|
||||
@@ -133,6 +148,10 @@ const LayerUI = ({
|
||||
children,
|
||||
app,
|
||||
isCollaborating,
|
||||
openAIKey,
|
||||
isOpenAIKeyPersisted,
|
||||
onOpenAIAPIKeyChange,
|
||||
onMagicSettingsConfirm,
|
||||
}: LayerUIProps) => {
|
||||
const device = useDevice();
|
||||
const tunnels = useInitializeTunnels();
|
||||
@@ -163,7 +182,7 @@ const LayerUI = ({
|
||||
const renderImageExportDialog = () => {
|
||||
if (
|
||||
!UIOptions.canvasActions.saveAsImage ||
|
||||
appState.openDialog !== "imageExport"
|
||||
appState.openDialog?.name !== "imageExport"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -295,9 +314,11 @@ const LayerUI = ({
|
||||
>
|
||||
<LaserPointerButton
|
||||
title={t("toolBar.laser")}
|
||||
checked={appState.activeTool.type === "laser"}
|
||||
checked={
|
||||
appState.activeTool.type === TOOL_TYPE.laser
|
||||
}
|
||||
onChange={() =>
|
||||
app.setActiveTool({ type: "laser" })
|
||||
app.setActiveTool({ type: TOOL_TYPE.laser })
|
||||
}
|
||||
isMobile
|
||||
/>
|
||||
@@ -376,6 +397,7 @@ const LayerUI = ({
|
||||
{t("toolBar.library")}
|
||||
</DefaultSidebar.Trigger>
|
||||
<DefaultOverwriteConfirmDialog />
|
||||
{appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
|
||||
{appState.isLoading && <LoadingMessage delay={250} />}
|
||||
@@ -432,13 +454,32 @@ const LayerUI = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{appState.openDialog === "help" && (
|
||||
{appState.openDialog?.name === "help" && (
|
||||
<HelpDialog
|
||||
onClose={() => {
|
||||
setAppState({ openDialog: null });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{appState.openDialog?.name === "settings" && (
|
||||
<MagicSettings
|
||||
openAIKey={openAIKey}
|
||||
isPersisted={isOpenAIKeyPersisted}
|
||||
onChange={onOpenAIAPIKeyChange}
|
||||
onConfirm={(apiKey, shouldPersist) => {
|
||||
const source =
|
||||
appState.openDialog?.name === "settings"
|
||||
? appState.openDialog?.source
|
||||
: "settings";
|
||||
setAppState({ openDialog: null }, () => {
|
||||
onMagicSettingsConfirm(apiKey, shouldPersist, source);
|
||||
});
|
||||
}}
|
||||
onClose={() => {
|
||||
setAppState({ openDialog: null });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ActiveConfirmDialog />
|
||||
<tunnels.OverwriteConfirmDialogTunnel.Out />
|
||||
{renderImageExportDialog()}
|
||||
|
38
src/components/MagicButton.tsx
Normal file
38
src/components/MagicButton.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import "./ToolIcon.scss";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { ToolButtonSize } from "./ToolButton";
|
||||
|
||||
const DEFAULT_SIZE: ToolButtonSize = "small";
|
||||
|
||||
export const ElementCanvasButton = (props: {
|
||||
title?: string;
|
||||
icon: JSX.Element;
|
||||
name?: string;
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
isMobile?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<label
|
||||
className={clsx(
|
||||
"ToolIcon ToolIcon__MagicButton",
|
||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
||||
{
|
||||
"is-mobile": props.isMobile,
|
||||
},
|
||||
)}
|
||||
title={`${props.title}`}
|
||||
>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox"
|
||||
type="checkbox"
|
||||
name={props.name}
|
||||
onChange={props.onChange}
|
||||
checked={props.checked}
|
||||
aria-label={props.title}
|
||||
/>
|
||||
<div className="ToolIcon__icon">{props.icon}</div>
|
||||
</label>
|
||||
);
|
||||
};
|
18
src/components/MagicSettings.scss
Normal file
18
src/components/MagicSettings.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
.excalidraw {
|
||||
.MagicSettings {
|
||||
.Island {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.MagicSettings-confirm {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.MagicSettings__confirm {
|
||||
margin-top: 2rem;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
160
src/components/MagicSettings.tsx
Normal file
160
src/components/MagicSettings.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useState } from "react";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { TextField } from "./TextField";
|
||||
import { MagicIcon, OpenAIIcon } from "./icons";
|
||||
import { FilledButton } from "./FilledButton";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
import { KEYS } from "../keys";
|
||||
import { useUIAppState } from "../context/ui-appState";
|
||||
import { InlineIcon } from "./InlineIcon";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
|
||||
import "./MagicSettings.scss";
|
||||
import TTDDialogTabs from "./TTDDialog/TTDDialogTabs";
|
||||
import { TTDDialogTab } from "./TTDDialog/TTDDialogTab";
|
||||
|
||||
export const MagicSettings = (props: {
|
||||
openAIKey: string | null;
|
||||
isPersisted: boolean;
|
||||
onChange: (key: string, shouldPersist: boolean) => void;
|
||||
onConfirm: (key: string, shouldPersist: boolean) => void;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const [keyInputValue, setKeyInputValue] = useState(props.openAIKey || "");
|
||||
const [shouldPersist, setShouldPersist] = useState<boolean>(
|
||||
props.isPersisted,
|
||||
);
|
||||
|
||||
const appState = useUIAppState();
|
||||
|
||||
const onConfirm = () => {
|
||||
props.onConfirm(keyInputValue.trim(), shouldPersist);
|
||||
};
|
||||
|
||||
if (appState.openDialog?.name !== "settings") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onCloseRequest={() => {
|
||||
props.onClose();
|
||||
props.onConfirm(keyInputValue.trim(), shouldPersist);
|
||||
}}
|
||||
title={
|
||||
<div style={{ display: "flex" }}>
|
||||
Wireframe to Code (AI){" "}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0.1rem 0.5rem",
|
||||
marginLeft: "1rem",
|
||||
fontSize: 14,
|
||||
borderRadius: "12px",
|
||||
color: "#000",
|
||||
background: "pink",
|
||||
}}
|
||||
>
|
||||
Experimental
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
className="MagicSettings"
|
||||
autofocus={false}
|
||||
>
|
||||
{/* <h2
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: "1.25rem",
|
||||
paddingLeft: "2.5rem",
|
||||
}}
|
||||
>
|
||||
AI Settings
|
||||
</h2> */}
|
||||
<TTDDialogTabs dialog="settings" tab={appState.openDialog.tab}>
|
||||
{/* <TTDDialogTabTriggers>
|
||||
<TTDDialogTabTrigger tab="text-to-diagram">
|
||||
<InlineIcon icon={brainIcon} /> Text to diagram
|
||||
</TTDDialogTabTrigger>
|
||||
<TTDDialogTabTrigger tab="diagram-to-code">
|
||||
<InlineIcon icon={MagicIcon} /> Wireframe to code
|
||||
</TTDDialogTabTrigger>
|
||||
</TTDDialogTabTriggers> */}
|
||||
{/* <TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
|
||||
TODO
|
||||
</TTDDialogTab> */}
|
||||
<TTDDialogTab
|
||||
// className="ttd-dialog-content"
|
||||
tab="diagram-to-code"
|
||||
>
|
||||
<Paragraph>
|
||||
For the diagram-to-code feature we use{" "}
|
||||
<InlineIcon icon={OpenAIIcon} />
|
||||
OpenAI.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
While the OpenAI API is in beta, its use is strictly limited — as
|
||||
such we require you use your own API key. You can create an{" "}
|
||||
<a
|
||||
href="https://platform.openai.com/login?launch"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
OpenAI account
|
||||
</a>
|
||||
, add a small credit (5 USD minimum), and{" "}
|
||||
<a
|
||||
href="https://platform.openai.com/api-keys"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
generate your own API key
|
||||
</a>
|
||||
.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Your OpenAI key does not leave the browser, and you can also set
|
||||
your own limit in your OpenAI account dashboard if needed.
|
||||
</Paragraph>
|
||||
<TextField
|
||||
isRedacted
|
||||
value={keyInputValue}
|
||||
placeholder="Paste your API key here"
|
||||
label="OpenAI API key"
|
||||
onChange={(value) => {
|
||||
setKeyInputValue(value);
|
||||
props.onChange(value.trim(), shouldPersist);
|
||||
}}
|
||||
selectOnRender
|
||||
onKeyDown={(event) => event.key === KEYS.ENTER && onConfirm()}
|
||||
/>
|
||||
<Paragraph>
|
||||
By default, your API token is not persisted anywhere so you'll need
|
||||
to insert it again after reload. But, you can persist locally in
|
||||
your browser below.
|
||||
</Paragraph>
|
||||
|
||||
<CheckboxItem checked={shouldPersist} onChange={setShouldPersist}>
|
||||
Persist API key in browser storage
|
||||
</CheckboxItem>
|
||||
|
||||
<Paragraph>
|
||||
Once API key is set, you can use the <InlineIcon icon={MagicIcon} />{" "}
|
||||
tool to wrap your elements in a frame that will then allow you to
|
||||
turn it into code. This dialog can be accessed using the{" "}
|
||||
<b>AI Settings</b> <InlineIcon icon={OpenAIIcon} />.
|
||||
</Paragraph>
|
||||
|
||||
<FilledButton
|
||||
className="MagicSettings__confirm"
|
||||
size="large"
|
||||
label="Confirm"
|
||||
onClick={onConfirm}
|
||||
/>
|
||||
</TTDDialogTab>
|
||||
</TTDDialogTabs>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
@@ -1,221 +0,0 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
$verticalBreakpoint: 860px;
|
||||
|
||||
.excalidraw {
|
||||
.dialog-mermaid {
|
||||
&-title {
|
||||
margin-bottom: 5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
&-desc {
|
||||
font-size: 15px;
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.Modal__content .Island {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@at-root .excalidraw:not(.excalidraw--mobile)#{&} {
|
||||
padding: 1.25rem;
|
||||
|
||||
.Modal__content {
|
||||
height: 100%;
|
||||
max-height: 750px;
|
||||
|
||||
@media screen and (max-width: $verticalBreakpoint) {
|
||||
height: auto;
|
||||
// When vertical, we want the height to span whole viewport.
|
||||
// This is also important for the children not to overflow the
|
||||
// modal/viewport (for some reason).
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.Island {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
|
||||
.Dialog__content {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-mermaid-body {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr auto;
|
||||
height: 100%;
|
||||
column-gap: 4rem;
|
||||
|
||||
@media screen and (max-width: $verticalBreakpoint) {
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-mermaid-panels {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
justify-content: space-between;
|
||||
gap: 4rem;
|
||||
|
||||
grid-row: 1;
|
||||
grid-column: 1 / 3;
|
||||
|
||||
@media screen and (max-width: $verticalBreakpoint) {
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
margin-left: 4px;
|
||||
|
||||
@media screen and (max-width: $verticalBreakpoint) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
textarea {
|
||||
width: 20rem;
|
||||
height: 100%;
|
||||
resize: none;
|
||||
border-radius: var(--border-radius-lg);
|
||||
border: 1px solid var(--dialog-border-color);
|
||||
white-space: pre-wrap;
|
||||
padding: 0.85rem;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
font-family: monospace;
|
||||
|
||||
@media screen and (max-width: $verticalBreakpoint) {
|
||||
width: auto;
|
||||
height: 10rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-preview-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.85rem;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
// acts as min-height
|
||||
height: 200px;
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
|
||||
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
|
||||
left center;
|
||||
border-radius: var(--border-radius-lg);
|
||||
border: 1px solid var(--dialog-border-color);
|
||||
|
||||
@media screen and (max-width: $verticalBreakpoint) {
|
||||
// acts as min-height
|
||||
height: 400px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
canvas {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&-preview-canvas-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
&-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mermaid-error {
|
||||
color: red;
|
||||
font-weight: 800;
|
||||
font-size: 30px;
|
||||
word-break: break-word;
|
||||
overflow: auto;
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
|
||||
p {
|
||||
font-weight: 500;
|
||||
font-family: Cascadia;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.875rem;
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-mermaid-buttons {
|
||||
grid-column: 2;
|
||||
|
||||
.dialog-mermaid-insert {
|
||||
&.excalidraw-button {
|
||||
font-family: "Assistant";
|
||||
font-weight: 600;
|
||||
height: 2.5rem;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.3em;
|
||||
width: 7.5rem;
|
||||
font-size: 12px;
|
||||
color: $oc-white;
|
||||
background-color: var(--color-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary-darker);
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--color-primary-darkest);
|
||||
}
|
||||
|
||||
@media screen and (max-width: $verticalBreakpoint) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@at-root .excalidraw.theme--dark#{&} {
|
||||
color: var(--color-gray-100);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
padding-left: 0.5rem;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,243 +0,0 @@
|
||||
import { useState, useRef, useEffect, useDeferredValue } from "react";
|
||||
import { BinaryFiles } from "../types";
|
||||
import { useApp } from "./App";
|
||||
import { Button } from "./Button";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { DEFAULT_EXPORT_PADDING, DEFAULT_FONT_SIZE } from "../constants";
|
||||
import {
|
||||
convertToExcalidrawElements,
|
||||
exportToCanvas,
|
||||
} from "../packages/excalidraw/index";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { canvasToBlob } from "../data/blob";
|
||||
import { ArrowRightIcon } from "./icons";
|
||||
import Spinner from "./Spinner";
|
||||
import "./MermaidToExcalidraw.scss";
|
||||
|
||||
import { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
|
||||
import type { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
|
||||
import { t } from "../i18n";
|
||||
import Trans from "./Trans";
|
||||
|
||||
const LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW = "mermaid-to-excalidraw";
|
||||
const MERMAID_EXAMPLE =
|
||||
"flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[Car]";
|
||||
|
||||
const saveMermaidDataToStorage = (data: string) => {
|
||||
try {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW, data);
|
||||
} catch (error: any) {
|
||||
// Unable to access window.localStorage
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const importMermaidDataFromStorage = () => {
|
||||
try {
|
||||
const data = localStorage.getItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW);
|
||||
if (data) {
|
||||
return data;
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Unable to access localStorage
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const ErrorComp = ({ error }: { error: string }) => {
|
||||
return (
|
||||
<div data-testid="mermaid-error" className="mermaid-error">
|
||||
Error! <p>{error}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MermaidToExcalidraw = () => {
|
||||
const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] = useState<{
|
||||
loaded: boolean;
|
||||
api: {
|
||||
parseMermaidToExcalidraw: (
|
||||
defination: string,
|
||||
options: MermaidOptions,
|
||||
) => Promise<MermaidToExcalidrawResult>;
|
||||
} | null;
|
||||
}>({ loaded: false, api: null });
|
||||
|
||||
const [text, setText] = useState("");
|
||||
const deferredText = useDeferredValue(text.trim());
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const data = useRef<{
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles | null;
|
||||
}>({ elements: [], files: null });
|
||||
|
||||
const app = useApp();
|
||||
|
||||
const resetPreview = () => {
|
||||
const canvasNode = canvasRef.current;
|
||||
|
||||
if (!canvasNode) {
|
||||
return;
|
||||
}
|
||||
const parent = canvasNode.parentElement;
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
parent.style.background = "";
|
||||
setError(null);
|
||||
canvasNode.replaceChildren();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadMermaidToExcalidrawLib = async () => {
|
||||
const api = await import(
|
||||
/* webpackChunkName:"mermaid-to-excalidraw" */ "@excalidraw/mermaid-to-excalidraw"
|
||||
);
|
||||
setMermaidToExcalidrawLib({ loaded: true, api });
|
||||
};
|
||||
loadMermaidToExcalidrawLib();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const data = importMermaidDataFromStorage() || MERMAID_EXAMPLE;
|
||||
setText(data);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const renderExcalidrawPreview = async () => {
|
||||
const canvasNode = canvasRef.current;
|
||||
const parent = canvasNode?.parentElement;
|
||||
if (
|
||||
!mermaidToExcalidrawLib.loaded ||
|
||||
!canvasNode ||
|
||||
!parent ||
|
||||
!mermaidToExcalidrawLib.api
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!deferredText) {
|
||||
resetPreview();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { elements, files } =
|
||||
await mermaidToExcalidrawLib.api.parseMermaidToExcalidraw(
|
||||
deferredText,
|
||||
{
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
},
|
||||
);
|
||||
setError(null);
|
||||
|
||||
data.current = {
|
||||
elements: convertToExcalidrawElements(elements, {
|
||||
regenerateIds: true,
|
||||
}),
|
||||
files,
|
||||
};
|
||||
|
||||
const canvas = await exportToCanvas({
|
||||
elements: data.current.elements,
|
||||
files: data.current.files,
|
||||
exportPadding: DEFAULT_EXPORT_PADDING,
|
||||
maxWidthOrHeight:
|
||||
Math.max(parent.offsetWidth, parent.offsetHeight) *
|
||||
window.devicePixelRatio,
|
||||
});
|
||||
// if converting to blob fails, there's some problem that will
|
||||
// likely prevent preview and export (e.g. canvas too big)
|
||||
await canvasToBlob(canvas);
|
||||
parent.style.background = "var(--default-bg-color)";
|
||||
canvasNode.replaceChildren(canvas);
|
||||
} catch (e: any) {
|
||||
parent.style.background = "var(--default-bg-color)";
|
||||
if (deferredText) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
renderExcalidrawPreview();
|
||||
}, [deferredText, mermaidToExcalidrawLib]);
|
||||
|
||||
const onClose = () => {
|
||||
app.setOpenDialog(null);
|
||||
saveMermaidDataToStorage(text);
|
||||
};
|
||||
|
||||
const onSelect = () => {
|
||||
const { elements: newElements, files } = data.current;
|
||||
app.addElementsFromPasteOrLibrary({
|
||||
elements: newElements,
|
||||
files,
|
||||
position: "center",
|
||||
fitToContent: true,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className="dialog-mermaid"
|
||||
onCloseRequest={onClose}
|
||||
size={1200}
|
||||
title={
|
||||
<>
|
||||
<p className="dialog-mermaid-title">{t("mermaid.title")}</p>
|
||||
<span className="dialog-mermaid-desc">
|
||||
<Trans
|
||||
i18nKey="mermaid.description"
|
||||
flowchartLink={(el) => (
|
||||
<a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a>
|
||||
)}
|
||||
sequenceLink={(el) => (
|
||||
<a href="https://mermaid.js.org/syntax/sequenceDiagram.html">
|
||||
{el}
|
||||
</a>
|
||||
)}
|
||||
/>
|
||||
<br />
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="dialog-mermaid-body">
|
||||
<div className="dialog-mermaid-panels">
|
||||
<div className="dialog-mermaid-panels-text">
|
||||
<label>{t("mermaid.syntax")}</label>
|
||||
|
||||
<textarea
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
value={text}
|
||||
/>
|
||||
</div>
|
||||
<div className="dialog-mermaid-panels-preview">
|
||||
<label>{t("mermaid.preview")}</label>
|
||||
<div className="dialog-mermaid-panels-preview-wrapper">
|
||||
{error && <ErrorComp error={error} />}
|
||||
{mermaidToExcalidrawLib.loaded ? (
|
||||
<div
|
||||
ref={canvasRef}
|
||||
style={{ opacity: error ? "0.15" : 1 }}
|
||||
className="dialog-mermaid-panels-preview-canvas-container"
|
||||
/>
|
||||
) : (
|
||||
<Spinner size="2rem" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dialog-mermaid-buttons">
|
||||
<Button className="dialog-mermaid-insert" onSelect={onSelect}>
|
||||
{t("mermaid.button")}
|
||||
<span>{ArrowRightIcon}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
export default MermaidToExcalidraw;
|
@@ -18,8 +18,11 @@
|
||||
overflow: auto;
|
||||
padding: calc(var(--space-factor) * 10);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.Island {
|
||||
padding: 2.5rem !important;
|
||||
padding: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -47,7 +47,7 @@ export const ExportToImage = () => {
|
||||
actionLabel={t("overwriteConfirm.action.exportToImage.button")}
|
||||
onClick={() => {
|
||||
actionManager.executeAction(actionChangeExportEmbedScene, "ui", true);
|
||||
setAppState({ openDialog: "imageExport" });
|
||||
setAppState({ openDialog: { name: "imageExport" } });
|
||||
}}
|
||||
>
|
||||
{t("overwriteConfirm.action.exportToImage.description")}
|
||||
|
10
src/components/Paragraph.tsx
Normal file
10
src/components/Paragraph.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export const Paragraph = (props: {
|
||||
children: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}) => {
|
||||
return (
|
||||
<p className="excalidraw__paragraph" style={props.style}>
|
||||
{props.children}
|
||||
</p>
|
||||
);
|
||||
};
|
@@ -94,7 +94,7 @@ export const PasteChartDialog = ({
|
||||
|
||||
const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
|
||||
onInsertElements(elements);
|
||||
trackEvent("magic", "chart", chartType);
|
||||
trackEvent("paste", "chart", chartType);
|
||||
setAppState({
|
||||
currentChartType: chartType,
|
||||
pasteDialog: {
|
||||
|
@@ -8,6 +8,7 @@ import Trans from "./Trans";
|
||||
import { LibraryItems, LibraryItem, UIAppState } from "../types";
|
||||
import { exportToCanvas, exportToSvg } from "../packages/utils";
|
||||
import {
|
||||
EDITOR_LS_KEYS,
|
||||
EXPORT_DATA_TYPES,
|
||||
EXPORT_SOURCE,
|
||||
MIME_TYPES,
|
||||
@@ -19,6 +20,7 @@ import { chunk } from "../utils";
|
||||
import DialogActionButton from "./DialogActionButton";
|
||||
import { CloseIcon } from "./icons";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { EditorLocalStorage } from "../data/EditorLocalStorage";
|
||||
|
||||
import "./PublishLibrary.scss";
|
||||
|
||||
@@ -31,34 +33,6 @@ interface PublishLibraryDataParams {
|
||||
website: string;
|
||||
}
|
||||
|
||||
const LOCAL_STORAGE_KEY_PUBLISH_LIBRARY = "publish-library-data";
|
||||
|
||||
const savePublishLibDataToStorage = (data: PublishLibraryDataParams) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
LOCAL_STORAGE_KEY_PUBLISH_LIBRARY,
|
||||
JSON.stringify(data),
|
||||
);
|
||||
} catch (error: any) {
|
||||
// Unable to access window.localStorage
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const importPublishLibDataFromStorage = () => {
|
||||
try {
|
||||
const data = localStorage.getItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Unable to access localStorage
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const generatePreviewImage = async (libraryItems: LibraryItems) => {
|
||||
const MAX_ITEMS_PER_ROW = 6;
|
||||
const BOX_SIZE = 128;
|
||||
@@ -255,7 +229,9 @@ const PublishLibrary = ({
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const data = importPublishLibDataFromStorage();
|
||||
const data = EditorLocalStorage.get<PublishLibraryDataParams>(
|
||||
EDITOR_LS_KEYS.PUBLISH_LIBRARY,
|
||||
);
|
||||
if (data) {
|
||||
setLibraryData(data);
|
||||
}
|
||||
@@ -328,7 +304,7 @@ const PublishLibrary = ({
|
||||
if (response.ok) {
|
||||
return response.json().then(({ url }) => {
|
||||
// flush data from local storage
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
|
||||
EditorLocalStorage.delete(EDITOR_LS_KEYS.PUBLISH_LIBRARY);
|
||||
onSuccess({
|
||||
url,
|
||||
authorName: libraryData.authorName,
|
||||
@@ -384,7 +360,7 @@ const PublishLibrary = ({
|
||||
|
||||
const onDialogClose = useCallback(() => {
|
||||
updateItemsInStorage(clonedLibItems);
|
||||
savePublishLibDataToStorage(libraryData);
|
||||
EditorLocalStorage.set(EDITOR_LS_KEYS.PUBLISH_LIBRARY, libraryData);
|
||||
onClose();
|
||||
}, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
|
||||
|
||||
|
10
src/components/TTDDialog/MermaidToExcalidraw.scss
Normal file
10
src/components/TTDDialog/MermaidToExcalidraw.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
.excalidraw {
|
||||
.dialog-mermaid {
|
||||
&-title {
|
||||
margin-block: 0.25rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
padding-inline: 2.5rem;
|
||||
}
|
||||
}
|
||||
}
|
133
src/components/TTDDialog/MermaidToExcalidraw.tsx
Normal file
133
src/components/TTDDialog/MermaidToExcalidraw.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useState, useRef, useEffect, useDeferredValue } from "react";
|
||||
import { BinaryFiles } from "../../types";
|
||||
import { useApp } from "../App";
|
||||
import { NonDeletedExcalidrawElement } from "../../element/types";
|
||||
import { ArrowRightIcon } from "../icons";
|
||||
import "./MermaidToExcalidraw.scss";
|
||||
import { t } from "../../i18n";
|
||||
import Trans from "../Trans";
|
||||
import {
|
||||
LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW,
|
||||
MermaidToExcalidrawLibProps,
|
||||
convertMermaidToExcalidraw,
|
||||
insertToEditor,
|
||||
saveMermaidDataToStorage,
|
||||
} from "./common";
|
||||
import { TTDDialogPanels } from "./TTDDialogPanels";
|
||||
import { TTDDialogPanel } from "./TTDDialogPanel";
|
||||
import { TTDDialogInput } from "./TTDDialogInput";
|
||||
import { TTDDialogOutput } from "./TTDDialogOutput";
|
||||
|
||||
const MERMAID_EXAMPLE =
|
||||
"flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[Car]";
|
||||
|
||||
const importMermaidDataFromStorage = () => {
|
||||
try {
|
||||
const data = localStorage.getItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW);
|
||||
if (data) {
|
||||
return data;
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Unable to access localStorage
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const MermaidToExcalidraw = ({
|
||||
mermaidToExcalidrawLib,
|
||||
}: {
|
||||
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
|
||||
}) => {
|
||||
const [text, setText] = useState("");
|
||||
const deferredText = useDeferredValue(text.trim());
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const data = useRef<{
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles | null;
|
||||
}>({ elements: [], files: null });
|
||||
|
||||
const app = useApp();
|
||||
|
||||
useEffect(() => {
|
||||
const data = importMermaidDataFromStorage() || MERMAID_EXAMPLE;
|
||||
setText(data);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
convertMermaidToExcalidraw({
|
||||
canvasRef,
|
||||
data,
|
||||
mermaidToExcalidrawLib,
|
||||
setError,
|
||||
mermaidDefinition: deferredText,
|
||||
}).catch(() => {});
|
||||
}, [deferredText, mermaidToExcalidrawLib]);
|
||||
|
||||
const textRef = useRef(text);
|
||||
|
||||
// slightly hacky but really quite simple
|
||||
// essentially, we want to save the text to LS when the component unmounts
|
||||
useEffect(() => {
|
||||
textRef.current = text;
|
||||
}, [text]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (textRef.current) {
|
||||
saveMermaidDataToStorage(textRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ttd-dialog-desc">
|
||||
<Trans
|
||||
i18nKey="mermaid.description"
|
||||
flowchartLink={(el) => (
|
||||
<a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a>
|
||||
)}
|
||||
sequenceLink={(el) => (
|
||||
<a href="https://mermaid.js.org/syntax/sequenceDiagram.html">
|
||||
{el}
|
||||
</a>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<TTDDialogPanels>
|
||||
<TTDDialogPanel label={t("mermaid.syntax")}>
|
||||
<TTDDialogInput
|
||||
input={text}
|
||||
placeholder={"Write Mermaid diagram defintion here..."}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
/>
|
||||
</TTDDialogPanel>
|
||||
<TTDDialogPanel
|
||||
label={t("mermaid.preview")}
|
||||
panelAction={{
|
||||
action: () => {
|
||||
insertToEditor({
|
||||
app,
|
||||
data: data.current,
|
||||
text,
|
||||
shouldSaveMermaidDataToStorage: true,
|
||||
});
|
||||
},
|
||||
label: t("mermaid.button"),
|
||||
icon: ArrowRightIcon,
|
||||
}}
|
||||
>
|
||||
<TTDDialogOutput
|
||||
canvasRef={canvasRef}
|
||||
loaded={mermaidToExcalidrawLib.loaded}
|
||||
error={error}
|
||||
/>
|
||||
</TTDDialogPanel>
|
||||
</TTDDialogPanels>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default MermaidToExcalidraw;
|
301
src/components/TTDDialog/TTDDialog.scss
Normal file
301
src/components/TTDDialog/TTDDialog.scss
Normal file
@@ -0,0 +1,301 @@
|
||||
@import "../../css/variables.module";
|
||||
|
||||
$verticalBreakpoint: 861px;
|
||||
|
||||
.excalidraw {
|
||||
.Modal.Dialog.ttd-dialog {
|
||||
padding: 1.25rem;
|
||||
|
||||
&.Dialog--fullscreen {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.Island {
|
||||
padding-inline: 0 !important;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.Modal__content {
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
|
||||
@media screen and (min-width: $verticalBreakpoint) {
|
||||
max-height: 750px;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.Dialog__content {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.ttd-dialog-desc {
|
||||
font-size: 15px;
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.ttd-dialog-tabs-root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ttd-dialog-tab-trigger {
|
||||
color: var(--color-on-surface);
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
padding: 0 1rem;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
height: 2.875rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
letter-spacing: 0.4px;
|
||||
|
||||
&[data-state="active"] {
|
||||
border-bottom: 2px solid var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.ttd-dialog-triggers {
|
||||
border-bottom: 1px solid var(--color-surface-high);
|
||||
margin-bottom: 1.5rem;
|
||||
padding-inline: 2.5rem;
|
||||
}
|
||||
|
||||
.ttd-dialog-content {
|
||||
padding-inline: 2.5rem;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&[hidden] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ttd-dialog-input {
|
||||
width: auto;
|
||||
height: 10rem;
|
||||
resize: none;
|
||||
border-radius: var(--border-radius-lg);
|
||||
border: 1px solid var(--dialog-border-color);
|
||||
white-space: pre-wrap;
|
||||
padding: 0.85rem;
|
||||
box-sizing: border-box;
|
||||
font-family: monospace;
|
||||
|
||||
@media screen and (min-width: $verticalBreakpoint) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.ttd-dialog-output-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.85rem;
|
||||
box-sizing: border-box;
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
|
||||
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
|
||||
left center;
|
||||
border-radius: var(--border-radius-lg);
|
||||
border: 1px solid var(--dialog-border-color);
|
||||
|
||||
height: 400px;
|
||||
width: auto;
|
||||
|
||||
@media screen and (min-width: $verticalBreakpoint) {
|
||||
width: 100%;
|
||||
// acts as min-height
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
canvas {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.ttd-dialog-output-canvas-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.ttd-dialog-output-error {
|
||||
color: red;
|
||||
font-weight: 800;
|
||||
font-size: 30px;
|
||||
word-break: break-word;
|
||||
overflow: auto;
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
|
||||
p {
|
||||
font-weight: 500;
|
||||
font-family: Cascadia;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.875rem;
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.ttd-dialog-panels {
|
||||
height: 100%;
|
||||
|
||||
@media screen and (min-width: $verticalBreakpoint) {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ttd-dialog-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
margin: 0px 4px 4px 4px;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
label {
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
.ttd-dialog-panel-button-container:not(.invisible) {
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: $verticalBreakpoint) {
|
||||
.ttd-dialog-panel-button-container:not(.invisible) {
|
||||
margin-bottom: 0.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: 100%;
|
||||
resize: none;
|
||||
border-radius: var(--border-radius-lg);
|
||||
border: 1px solid var(--dialog-border-color);
|
||||
white-space: pre-wrap;
|
||||
padding: 0.85rem;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
font-family: monospace;
|
||||
|
||||
@media screen and (max-width: $verticalBreakpoint) {
|
||||
width: auto;
|
||||
height: 10rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ttd-dialog-panel-button-container {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&.invisible {
|
||||
.ttd-dialog-panel-button {
|
||||
display: none;
|
||||
|
||||
@media screen and (min-width: $verticalBreakpoint) {
|
||||
display: block;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ttd-dialog-panel-button {
|
||||
&.excalidraw-button {
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
height: 2.5rem;
|
||||
|
||||
font-size: 12px;
|
||||
color: $oc-white;
|
||||
background-color: var(--color-primary);
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary-darker);
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--color-primary-darkest);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: $verticalBreakpoint) {
|
||||
width: auto;
|
||||
min-width: 7.5rem;
|
||||
}
|
||||
|
||||
@at-root .excalidraw.theme--dark#{&} {
|
||||
color: var(--color-gray-100);
|
||||
}
|
||||
}
|
||||
|
||||
position: relative;
|
||||
|
||||
div {
|
||||
display: contents;
|
||||
|
||||
&.invisible {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&.Spinner {
|
||||
display: flex !important;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
--spinner-color: white;
|
||||
|
||||
@at-root .excalidraw.theme--dark#{&} {
|
||||
--spinner-color: var(--color-gray-100);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
padding-left: 0.5rem;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
139
src/components/TTDDialog/TTDDialog.tsx
Normal file
139
src/components/TTDDialog/TTDDialog.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Dialog } from "../Dialog";
|
||||
import { useApp } from "../App";
|
||||
import MermaidToExcalidraw from "./MermaidToExcalidraw";
|
||||
import TTDDialogTabs from "./TTDDialogTabs";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useUIAppState } from "../../context/ui-appState";
|
||||
import { withInternalFallback } from "../hoc/withInternalFallback";
|
||||
import { TTDDialogTabTriggers } from "./TTDDialogTabTriggers";
|
||||
import { TTDDialogTabTrigger } from "./TTDDialogTabTrigger";
|
||||
import { TTDDialogTab } from "./TTDDialogTab";
|
||||
import { t } from "../../i18n";
|
||||
import { CommonDialogProps, MermaidToExcalidrawLibProps } from "./common";
|
||||
|
||||
import "./TTDDialog.scss";
|
||||
import { TextToDiagram } from "./TextToDiagram";
|
||||
import { TextToDrawing } from "./TextToDrawing";
|
||||
|
||||
export const TTDDialog = (props: CommonDialogProps | { __fallback: true }) => {
|
||||
const appState = useUIAppState();
|
||||
|
||||
if (appState.openDialog?.name !== "ttd") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <TTDDialogBase {...props} tab={appState.openDialog.tab} />;
|
||||
};
|
||||
|
||||
/**
|
||||
* Text to diagram (TTD) dialog
|
||||
*/
|
||||
export const TTDDialogBase = withInternalFallback(
|
||||
"TTDDialogBase",
|
||||
({
|
||||
tab,
|
||||
...rest
|
||||
}: {
|
||||
tab: "text-to-diagram" | "mermaid" | "text-to-drawing";
|
||||
} & (CommonDialogProps | { __fallback: true })) => {
|
||||
const app = useApp();
|
||||
|
||||
const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] =
|
||||
useState<MermaidToExcalidrawLibProps>({
|
||||
loaded: false,
|
||||
api: import(
|
||||
/* webpackChunkName:"mermaid-to-excalidraw" */ "@excalidraw/mermaid-to-excalidraw"
|
||||
),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fn = async () => {
|
||||
await mermaidToExcalidrawLib.api;
|
||||
setMermaidToExcalidrawLib((prev) => ({ ...prev, loaded: true }));
|
||||
};
|
||||
fn();
|
||||
}, [mermaidToExcalidrawLib.api]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className="ttd-dialog"
|
||||
onCloseRequest={() => {
|
||||
app.setOpenDialog(null);
|
||||
}}
|
||||
size={1200}
|
||||
title={false}
|
||||
{...rest}
|
||||
autofocus={false}
|
||||
>
|
||||
<TTDDialogTabs dialog="ttd" tab={tab}>
|
||||
{"__fallback" in rest && rest.__fallback ? (
|
||||
<p className="dialog-mermaid-title">{t("mermaid.title")}</p>
|
||||
) : (
|
||||
<TTDDialogTabTriggers>
|
||||
<TTDDialogTabTrigger tab="text-to-diagram">
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
{t("labels.textToDiagram")}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1px 6px",
|
||||
marginLeft: "10px",
|
||||
fontSize: 10,
|
||||
borderRadius: "12px",
|
||||
background: "pink",
|
||||
color: "#000",
|
||||
}}
|
||||
>
|
||||
AI Beta
|
||||
</div>
|
||||
</div>
|
||||
</TTDDialogTabTrigger>
|
||||
<TTDDialogTabTrigger tab="text-to-drawing">
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
{t("labels.textToDrawing")}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1px 6px",
|
||||
marginLeft: "10px",
|
||||
fontSize: 10,
|
||||
borderRadius: "12px",
|
||||
background: "pink",
|
||||
color: "#000",
|
||||
}}
|
||||
>
|
||||
AI Beta
|
||||
</div>
|
||||
</div>
|
||||
</TTDDialogTabTrigger>
|
||||
<TTDDialogTabTrigger tab="mermaid">Mermaid</TTDDialogTabTrigger>
|
||||
</TTDDialogTabTriggers>
|
||||
)}
|
||||
|
||||
<TTDDialogTab className="ttd-dialog-content" tab="mermaid">
|
||||
<MermaidToExcalidraw
|
||||
mermaidToExcalidrawLib={mermaidToExcalidrawLib}
|
||||
/>
|
||||
</TTDDialogTab>
|
||||
{!("__fallback" in rest) && (
|
||||
<TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
|
||||
<TextToDiagram
|
||||
onTextSubmit={rest.onTextSubmit}
|
||||
mermaidToExcalidrawLib={mermaidToExcalidrawLib}
|
||||
/>
|
||||
</TTDDialogTab>
|
||||
)}
|
||||
{!("__fallback" in rest) && (
|
||||
<TTDDialogTab className="ttd-dialog-content" tab="text-to-drawing">
|
||||
<TextToDrawing onTextSubmit={rest.onTextSubmit} />
|
||||
</TTDDialogTab>
|
||||
)}
|
||||
</TTDDialogTabs>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
);
|
52
src/components/TTDDialog/TTDDialogInput.tsx
Normal file
52
src/components/TTDDialog/TTDDialogInput.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ChangeEventHandler, useEffect, useRef } from "react";
|
||||
import { EVENT } from "../../constants";
|
||||
import { KEYS } from "../../keys";
|
||||
|
||||
interface TTDDialogInputProps {
|
||||
input: string;
|
||||
placeholder: string;
|
||||
onChange: ChangeEventHandler<HTMLTextAreaElement>;
|
||||
onKeyboardSubmit?: () => void;
|
||||
}
|
||||
|
||||
export const TTDDialogInput = ({
|
||||
input,
|
||||
placeholder,
|
||||
onChange,
|
||||
onKeyboardSubmit,
|
||||
}: TTDDialogInputProps) => {
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const callbackRef = useRef(onKeyboardSubmit);
|
||||
callbackRef.current = onKeyboardSubmit;
|
||||
|
||||
useEffect(() => {
|
||||
if (!callbackRef.current) {
|
||||
return;
|
||||
}
|
||||
const textarea = ref.current;
|
||||
if (textarea) {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event[KEYS.CTRL_OR_CMD] && event.key === KEYS.ENTER) {
|
||||
event.preventDefault();
|
||||
callbackRef.current?.();
|
||||
}
|
||||
};
|
||||
textarea.addEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||
return () => {
|
||||
textarea.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<textarea
|
||||
className="ttd-dialog-input"
|
||||
onChange={onChange}
|
||||
value={input}
|
||||
placeholder={placeholder}
|
||||
autoFocus
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
};
|
39
src/components/TTDDialog/TTDDialogOutput.tsx
Normal file
39
src/components/TTDDialog/TTDDialogOutput.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import Spinner from "../Spinner";
|
||||
|
||||
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 {
|
||||
error: Error | null;
|
||||
canvasRef: React.RefObject<HTMLDivElement>;
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
export const TTDDialogOutput = ({
|
||||
error,
|
||||
canvasRef,
|
||||
loaded,
|
||||
}: TTDDialogOutputProps) => {
|
||||
return (
|
||||
<div className="ttd-dialog-output-wrapper">
|
||||
{error && <ErrorComp error={error.message} />}
|
||||
{loaded ? (
|
||||
<div
|
||||
ref={canvasRef}
|
||||
style={{ opacity: error ? "0.15" : 1 }}
|
||||
className="ttd-dialog-output-canvas-container"
|
||||
/>
|
||||
) : (
|
||||
<Spinner size="2rem" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
58
src/components/TTDDialog/TTDDialogPanel.tsx
Normal file
58
src/components/TTDDialog/TTDDialogPanel.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Button } from "../Button";
|
||||
import clsx from "clsx";
|
||||
import Spinner from "../Spinner";
|
||||
|
||||
interface TTDDialogPanelProps {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
panelAction?: {
|
||||
label: string;
|
||||
action: () => void;
|
||||
icon?: ReactNode;
|
||||
};
|
||||
panelActionDisabled?: boolean;
|
||||
onTextSubmitInProgess?: boolean;
|
||||
renderTopRight?: () => ReactNode;
|
||||
renderBottomRight?: () => ReactNode;
|
||||
}
|
||||
|
||||
export const TTDDialogPanel = ({
|
||||
label,
|
||||
children,
|
||||
panelAction,
|
||||
panelActionDisabled = false,
|
||||
onTextSubmitInProgess,
|
||||
renderTopRight,
|
||||
renderBottomRight,
|
||||
}: TTDDialogPanelProps) => {
|
||||
return (
|
||||
<div className="ttd-dialog-panel">
|
||||
<div className="ttd-dialog-panel__header">
|
||||
<label>{label}</label>
|
||||
{renderTopRight?.()}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
<div
|
||||
className={clsx("ttd-dialog-panel-button-container", {
|
||||
invisible: !panelAction,
|
||||
})}
|
||||
style={{ display: "flex", alignItems: "center" }}
|
||||
>
|
||||
<Button
|
||||
className="ttd-dialog-panel-button"
|
||||
onSelect={panelAction ? panelAction.action : () => {}}
|
||||
disabled={panelActionDisabled || onTextSubmitInProgess}
|
||||
>
|
||||
<div className={clsx({ invisible: onTextSubmitInProgess })}>
|
||||
{panelAction?.label}
|
||||
{panelAction?.icon && <span>{panelAction.icon}</span>}
|
||||
</div>
|
||||
{onTextSubmitInProgess && <Spinner />}
|
||||
</Button>
|
||||
{renderBottomRight?.()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
5
src/components/TTDDialog/TTDDialogPanels.tsx
Normal file
5
src/components/TTDDialog/TTDDialogPanels.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export const TTDDialogPanels = ({ children }: { children: ReactNode }) => {
|
||||
return <div className="ttd-dialog-panels">{children}</div>;
|
||||
};
|
17
src/components/TTDDialog/TTDDialogTab.tsx
Normal file
17
src/components/TTDDialog/TTDDialogTab.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as RadixTabs from "@radix-ui/react-tabs";
|
||||
|
||||
export const TTDDialogTab = ({
|
||||
tab,
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
tab: string;
|
||||
children: React.ReactNode;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) => {
|
||||
return (
|
||||
<RadixTabs.Content {...rest} value={tab}>
|
||||
{children}
|
||||
</RadixTabs.Content>
|
||||
);
|
||||
};
|
||||
TTDDialogTab.displayName = "TTDDialogTab";
|
21
src/components/TTDDialog/TTDDialogTabTrigger.tsx
Normal file
21
src/components/TTDDialog/TTDDialogTabTrigger.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as RadixTabs from "@radix-ui/react-tabs";
|
||||
|
||||
export const TTDDialogTabTrigger = ({
|
||||
children,
|
||||
tab,
|
||||
onSelect,
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
tab: string;
|
||||
onSelect?: React.ReactEventHandler<HTMLButtonElement> | undefined;
|
||||
} & Omit<React.HTMLAttributes<HTMLButtonElement>, "onSelect">) => {
|
||||
return (
|
||||
<RadixTabs.Trigger value={tab} asChild onSelect={onSelect}>
|
||||
<button type="button" className="ttd-dialog-tab-trigger" {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
</RadixTabs.Trigger>
|
||||
);
|
||||
};
|
||||
TTDDialogTabTrigger.displayName = "TTDDialogTabTrigger";
|
13
src/components/TTDDialog/TTDDialogTabTriggers.tsx
Normal file
13
src/components/TTDDialog/TTDDialogTabTriggers.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import * as RadixTabs from "@radix-ui/react-tabs";
|
||||
|
||||
export const TTDDialogTabTriggers = ({
|
||||
children,
|
||||
...rest
|
||||
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
|
||||
return (
|
||||
<RadixTabs.List className="ttd-dialog-triggers" {...rest}>
|
||||
{children}
|
||||
</RadixTabs.List>
|
||||
);
|
||||
};
|
||||
TTDDialogTabTriggers.displayName = "TTDDialogTabTriggers";
|
67
src/components/TTDDialog/TTDDialogTabs.tsx
Normal file
67
src/components/TTDDialog/TTDDialogTabs.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as RadixTabs from "@radix-ui/react-tabs";
|
||||
import { ReactNode, useRef } from "react";
|
||||
import { useExcalidrawSetAppState } from "../App";
|
||||
import { isMemberOf } from "../../utils";
|
||||
|
||||
const TTDDialogTabs = (
|
||||
props: {
|
||||
children: ReactNode;
|
||||
} & (
|
||||
| { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" | "text-to-drawing" }
|
||||
| {
|
||||
dialog: "settings";
|
||||
tab: "text-to-diagram" | "diagram-to-code";
|
||||
}
|
||||
),
|
||||
) => {
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const minHeightRef = useRef<number>(0);
|
||||
|
||||
return (
|
||||
<RadixTabs.Root
|
||||
ref={rootRef}
|
||||
className="ttd-dialog-tabs-root"
|
||||
value={props.tab}
|
||||
onValueChange={(
|
||||
// at least in test enviros, `tab` can be `undefined`
|
||||
tab: string | undefined,
|
||||
) => {
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
const modalContentNode =
|
||||
rootRef.current?.closest<HTMLElement>(".Modal__content");
|
||||
if (modalContentNode) {
|
||||
const currHeight = modalContentNode.offsetHeight || 0;
|
||||
if (currHeight > minHeightRef.current) {
|
||||
minHeightRef.current = currHeight;
|
||||
modalContentNode.style.minHeight = `min(${minHeightRef.current}px, 100%)`;
|
||||
}
|
||||
}
|
||||
if (
|
||||
props.dialog === "settings" &&
|
||||
isMemberOf(["text-to-diagram", "diagram-to-code"], tab)
|
||||
) {
|
||||
setAppState({
|
||||
openDialog: { name: props.dialog, tab, source: "settings" },
|
||||
});
|
||||
} else if (
|
||||
props.dialog === "ttd" &&
|
||||
isMemberOf(["text-to-diagram", "mermaid", "text-to-drawing"], tab)
|
||||
) {
|
||||
setAppState({
|
||||
openDialog: { name: props.dialog, tab },
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</RadixTabs.Root>
|
||||
);
|
||||
};
|
||||
|
||||
TTDDialogTabs.displayName = "TTDDialogTabs";
|
||||
|
||||
export default TTDDialogTabs;
|
38
src/components/TTDDialog/TTDDialogTrigger.tsx
Normal file
38
src/components/TTDDialog/TTDDialogTrigger.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ReactNode } from "react";
|
||||
import { useTunnels } from "../../context/tunnels";
|
||||
import DropdownMenu from "../dropdownMenu/DropdownMenu";
|
||||
import { useExcalidrawSetAppState } from "../App";
|
||||
import { brainIcon } from "../icons";
|
||||
import { t } from "../../i18n";
|
||||
import { trackEvent } from "../../analytics";
|
||||
|
||||
export const TTDDialogTrigger = ({
|
||||
children,
|
||||
icon,
|
||||
tab,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
icon?: JSX.Element;
|
||||
tab?: "mermaid" | "text-to-diagram" | "text-to-drawing";
|
||||
}) => {
|
||||
const { TTDDialogTriggerTunnel } = useTunnels();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
return (
|
||||
<TTDDialogTriggerTunnel.In>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
trackEvent("ai", "dialog open", "ttd");
|
||||
setAppState({
|
||||
openDialog: { name: "ttd", tab: tab ?? "text-to-diagram" },
|
||||
});
|
||||
}}
|
||||
icon={icon ?? brainIcon}
|
||||
>
|
||||
{children ?? t("labels.textToDiagram")}
|
||||
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
|
||||
</DropdownMenu.Item>
|
||||
</TTDDialogTriggerTunnel.In>
|
||||
);
|
||||
};
|
||||
TTDDialogTrigger.displayName = "TTDDialogTrigger";
|
228
src/components/TTDDialog/TextToDiagram.tsx
Normal file
228
src/components/TTDDialog/TextToDiagram.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { useRef, useState, ChangeEventHandler } from "react";
|
||||
import { trackEvent } from "../../analytics";
|
||||
import { t } from "../../i18n";
|
||||
import { isFiniteNumber } from "../../utils";
|
||||
import { ArrowRightIcon } from "../icons";
|
||||
import { TTDDialogInput } from "./TTDDialogInput";
|
||||
import { TTDDialogOutput } from "./TTDDialogOutput";
|
||||
import { TTDDialogPanel } from "./TTDDialogPanel";
|
||||
import { TTDDialogPanels } from "./TTDDialogPanels";
|
||||
import {
|
||||
CommonDialogProps,
|
||||
MAX_PROMPT_LENGTH,
|
||||
MIN_PROMPT_LENGTH,
|
||||
MermaidToExcalidrawLibProps,
|
||||
convertMermaidToExcalidraw,
|
||||
insertToEditor,
|
||||
rateLimitsAtom,
|
||||
saveMermaidDataToStorage,
|
||||
} from "./common";
|
||||
import { useApp } from "../App";
|
||||
import { NonDeletedExcalidrawElement } from "../../element/types";
|
||||
import { BinaryFiles } from "../../types";
|
||||
|
||||
export type TextToDiagramProps = CommonDialogProps & {
|
||||
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
|
||||
};
|
||||
|
||||
export const TextToDiagram = ({
|
||||
onTextSubmit,
|
||||
mermaidToExcalidrawLib,
|
||||
}: TextToDiagramProps) => {
|
||||
const app = useApp();
|
||||
|
||||
const someRandomDivRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [text, setText] = useState("");
|
||||
|
||||
const prompt = text.trim();
|
||||
|
||||
const handleTextChange: ChangeEventHandler<HTMLTextAreaElement> = (event) => {
|
||||
setText(event.target.value);
|
||||
};
|
||||
|
||||
const [onTextSubmitInProgess, setOnTextSubmitInProgess] = useState(false);
|
||||
const [rateLimits, setRateLimits] = useAtom(rateLimitsAtom);
|
||||
|
||||
const data = useRef<{
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles | null;
|
||||
}>({ elements: [], files: null });
|
||||
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const onGenerate = async () => {
|
||||
if (
|
||||
prompt.length > MAX_PROMPT_LENGTH ||
|
||||
prompt.length < MIN_PROMPT_LENGTH ||
|
||||
onTextSubmitInProgess ||
|
||||
rateLimits?.rateLimitRemaining === 0
|
||||
) {
|
||||
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 onTextSubmit(prompt, "text-to-diagram");
|
||||
|
||||
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");
|
||||
saveMermaidDataToStorage(generatedResponse);
|
||||
} 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;
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
);
|
||||
}}
|
||||
renderBottomRight={() => {
|
||||
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: data.current });
|
||||
},
|
||||
label: "Insert",
|
||||
icon: ArrowRightIcon,
|
||||
}}
|
||||
>
|
||||
<TTDDialogOutput
|
||||
canvasRef={someRandomDivRef}
|
||||
error={error}
|
||||
loaded={mermaidToExcalidrawLib.loaded}
|
||||
/>
|
||||
</TTDDialogPanel>
|
||||
</TTDDialogPanels>
|
||||
</>
|
||||
);
|
||||
};
|
248
src/components/TTDDialog/TextToDrawing.tsx
Normal file
248
src/components/TTDDialog/TextToDrawing.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { useRef, useState, ChangeEventHandler } from "react";
|
||||
import { trackEvent } from "../../analytics";
|
||||
import { NonDeletedExcalidrawElement } from "../../element/types";
|
||||
import { t } from "../../i18n";
|
||||
import { isFiniteNumber } from "../../utils";
|
||||
import { useApp } from "../App";
|
||||
import { ArrowRightIcon } from "../icons";
|
||||
import { TTDDialogInput } from "./TTDDialogInput";
|
||||
import { TTDDialogOutput } from "./TTDDialogOutput";
|
||||
import { TTDDialogPanel } from "./TTDDialogPanel";
|
||||
import { TTDDialogPanels } from "./TTDDialogPanels";
|
||||
import {
|
||||
CommonDialogProps,
|
||||
MAX_PROMPT_LENGTH,
|
||||
MIN_PROMPT_LENGTH,
|
||||
insertToEditor,
|
||||
rateLimitsAtom,
|
||||
resetPreview,
|
||||
} from "./common";
|
||||
import {
|
||||
convertToExcalidrawElements,
|
||||
exportToCanvas,
|
||||
} from "../../packages/excalidraw/index";
|
||||
import { DEFAULT_EXPORT_PADDING } from "../../constants";
|
||||
import { canvasToBlob } from "../../data/blob";
|
||||
|
||||
export type TextToDrawingProps = CommonDialogProps;
|
||||
|
||||
export const TextToDrawing = ({ onTextSubmit }: TextToDrawingProps) => {
|
||||
const app = useApp();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [text, setText] = useState("");
|
||||
|
||||
const prompt = text.trim();
|
||||
|
||||
const handleTextChange: ChangeEventHandler<HTMLTextAreaElement> = (event) => {
|
||||
setText(event.target.value);
|
||||
};
|
||||
|
||||
const [onTextSubmitInProgess, setOnTextSubmitInProgess] = useState(false);
|
||||
const [rateLimits, setRateLimits] = useAtom(rateLimitsAtom);
|
||||
|
||||
const [data, setData] = useState<
|
||||
readonly NonDeletedExcalidrawElement[] | null
|
||||
>(null);
|
||||
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const onGenerate = async () => {
|
||||
if (
|
||||
prompt.length > MAX_PROMPT_LENGTH ||
|
||||
prompt.length < MIN_PROMPT_LENGTH ||
|
||||
onTextSubmitInProgess ||
|
||||
rateLimits?.rateLimitRemaining === 0
|
||||
) {
|
||||
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", "text-to-drawing");
|
||||
|
||||
const { generatedResponse, error, rateLimit, rateLimitRemaining } =
|
||||
await onTextSubmit(prompt, "text-to-drawing");
|
||||
|
||||
if (isFiniteNumber(rateLimit) && isFiniteNumber(rateLimitRemaining)) {
|
||||
setRateLimits({ rateLimit, rateLimitRemaining });
|
||||
}
|
||||
|
||||
if (error) {
|
||||
setError(error);
|
||||
return;
|
||||
}
|
||||
if (!generatedResponse) {
|
||||
setError(new Error("Generation failed"));
|
||||
return;
|
||||
}
|
||||
|
||||
const canvasNode = containerRef.current;
|
||||
const parent = canvasNode?.parentElement;
|
||||
|
||||
if (!canvasNode || !parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
resetPreview({ canvasRef: containerRef, setError });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(generatedResponse)) {
|
||||
setError(new Error("Generation failed to return an array!"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const elements = convertToExcalidrawElements(generatedResponse, {
|
||||
regenerateIds: true,
|
||||
});
|
||||
|
||||
setData(elements);
|
||||
|
||||
const canvas = await exportToCanvas({
|
||||
elements,
|
||||
files: null,
|
||||
exportPadding: DEFAULT_EXPORT_PADDING,
|
||||
maxWidthOrHeight:
|
||||
Math.max(parent.offsetWidth, parent.offsetHeight) *
|
||||
window.devicePixelRatio,
|
||||
});
|
||||
// if converting to blob fails, there's some problem that will
|
||||
// likely prevent preview and export (e.g. canvas too big)
|
||||
await canvasToBlob(canvas);
|
||||
parent.style.background = "var(--default-bg-color)";
|
||||
canvasNode.replaceChildren(canvas);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
parent.style.background = "var(--default-bg-color)";
|
||||
if (text) {
|
||||
setError(err);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
} 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;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ttd-dialog-desc">This is text to drawing.</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>
|
||||
);
|
||||
}}
|
||||
renderBottomRight={() => {
|
||||
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: () => {
|
||||
if (data) {
|
||||
insertToEditor({
|
||||
app,
|
||||
data: {
|
||||
elements: data,
|
||||
files: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
label: "Insert",
|
||||
icon: ArrowRightIcon,
|
||||
}}
|
||||
>
|
||||
<TTDDialogOutput
|
||||
canvasRef={containerRef}
|
||||
error={error}
|
||||
loaded={true}
|
||||
/>
|
||||
</TTDDialogPanel>
|
||||
</TTDDialogPanels>
|
||||
</>
|
||||
);
|
||||
};
|
193
src/components/TTDDialog/common.ts
Normal file
193
src/components/TTDDialog/common.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
|
||||
import { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
|
||||
import { DEFAULT_EXPORT_PADDING, DEFAULT_FONT_SIZE } from "../../constants";
|
||||
import {
|
||||
convertToExcalidrawElements,
|
||||
exportToCanvas,
|
||||
} from "../../packages/excalidraw/index";
|
||||
import { NonDeletedExcalidrawElement } from "../../element/types";
|
||||
import { AppClassProperties, BinaryFiles } from "../../types";
|
||||
import { canvasToBlob } from "../../data/blob";
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const resetPreview = ({
|
||||
canvasRef,
|
||||
setError,
|
||||
}: {
|
||||
canvasRef: React.RefObject<HTMLDivElement>;
|
||||
setError: (error: Error | null) => void;
|
||||
}) => {
|
||||
const canvasNode = canvasRef.current;
|
||||
|
||||
if (!canvasNode) {
|
||||
return;
|
||||
}
|
||||
const parent = canvasNode.parentElement;
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
parent.style.background = "";
|
||||
setError(null);
|
||||
canvasNode.replaceChildren();
|
||||
};
|
||||
|
||||
export type OnTestSubmitRetValue = {
|
||||
rateLimit?: number | null;
|
||||
rateLimitRemaining?: number | null;
|
||||
} & (
|
||||
| {
|
||||
generatedResponse: any | string | undefined;
|
||||
error?: null | undefined;
|
||||
}
|
||||
| {
|
||||
error: Error;
|
||||
generatedResponse?: null | undefined;
|
||||
}
|
||||
);
|
||||
export interface CommonDialogProps {
|
||||
onTextSubmit(
|
||||
value: string,
|
||||
type: "text-to-diagram" | "text-to-drawing",
|
||||
): Promise<OnTestSubmitRetValue>;
|
||||
}
|
||||
|
||||
export interface MermaidToExcalidrawLibProps {
|
||||
loaded: boolean;
|
||||
api: Promise<{
|
||||
parseMermaidToExcalidraw: (
|
||||
definition: string,
|
||||
options: MermaidOptions,
|
||||
) => Promise<MermaidToExcalidrawResult>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ConvertMermaidToExcalidrawFormatProps {
|
||||
canvasRef: React.RefObject<HTMLDivElement>;
|
||||
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
|
||||
mermaidDefinition: string;
|
||||
setError: (error: Error | null) => void;
|
||||
data: React.MutableRefObject<{
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const convertMermaidToExcalidraw = async ({
|
||||
canvasRef,
|
||||
mermaidToExcalidrawLib,
|
||||
mermaidDefinition,
|
||||
setError,
|
||||
data,
|
||||
}: ConvertMermaidToExcalidrawFormatProps) => {
|
||||
const canvasNode = canvasRef.current;
|
||||
const parent = canvasNode?.parentElement;
|
||||
|
||||
if (!canvasNode || !parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mermaidDefinition) {
|
||||
resetPreview({ canvasRef, setError });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = await mermaidToExcalidrawLib.api;
|
||||
|
||||
let ret;
|
||||
try {
|
||||
ret = await api.parseMermaidToExcalidraw(mermaidDefinition, {
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
});
|
||||
} catch (err: any) {
|
||||
ret = await api.parseMermaidToExcalidraw(
|
||||
mermaidDefinition.replace(/"/g, "'"),
|
||||
{
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
},
|
||||
);
|
||||
}
|
||||
const { elements, files } = ret;
|
||||
setError(null);
|
||||
|
||||
data.current = {
|
||||
elements: convertToExcalidrawElements(elements, {
|
||||
regenerateIds: true,
|
||||
}),
|
||||
files,
|
||||
};
|
||||
|
||||
const canvas = await exportToCanvas({
|
||||
elements: data.current.elements,
|
||||
files: data.current.files,
|
||||
exportPadding: DEFAULT_EXPORT_PADDING,
|
||||
maxWidthOrHeight:
|
||||
Math.max(parent.offsetWidth, parent.offsetHeight) *
|
||||
window.devicePixelRatio,
|
||||
});
|
||||
// if converting to blob fails, there's some problem that will
|
||||
// likely prevent preview and export (e.g. canvas too big)
|
||||
await canvasToBlob(canvas);
|
||||
parent.style.background = "var(--default-bg-color)";
|
||||
canvasNode.replaceChildren(canvas);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
parent.style.background = "var(--default-bg-color)";
|
||||
if (mermaidDefinition) {
|
||||
setError(err);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export const LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW = "mermaid-to-excalidraw";
|
||||
export const saveMermaidDataToStorage = (data: string) => {
|
||||
try {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW, data);
|
||||
} catch (error: any) {
|
||||
// Unable to access window.localStorage
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const insertToEditor = ({
|
||||
app,
|
||||
data,
|
||||
text,
|
||||
shouldSaveMermaidDataToStorage,
|
||||
}: {
|
||||
app: AppClassProperties;
|
||||
data: {
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles | null;
|
||||
};
|
||||
text?: string;
|
||||
shouldSaveMermaidDataToStorage?: boolean;
|
||||
}) => {
|
||||
const { elements: newElements, files } = data;
|
||||
|
||||
if (!newElements.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
app.addElementsFromPasteOrLibrary({
|
||||
elements: newElements,
|
||||
files,
|
||||
position: "center",
|
||||
fitToContent: true,
|
||||
});
|
||||
app.setOpenDialog(null);
|
||||
|
||||
if (shouldSaveMermaidDataToStorage && text) {
|
||||
saveMermaidDataToStorage(text);
|
||||
}
|
||||
};
|
||||
|
||||
export const MIN_PROMPT_LENGTH = 3;
|
||||
export const MAX_PROMPT_LENGTH = 1000;
|
||||
|
||||
export const rateLimitsAtom = atom<{
|
||||
rateLimit: number;
|
||||
rateLimitRemaining: number;
|
||||
} | null>(null);
|
@@ -4,12 +4,15 @@ import {
|
||||
useImperativeHandle,
|
||||
KeyboardEvent,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import "./TextField.scss";
|
||||
import { Button } from "./Button";
|
||||
import { eyeIcon, eyeClosedIcon } from "./icons";
|
||||
|
||||
export type TextFieldProps = {
|
||||
type TextFieldProps = {
|
||||
value?: string;
|
||||
|
||||
onChange?: (value: string) => void;
|
||||
@@ -22,6 +25,7 @@ export type TextFieldProps = {
|
||||
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
isRedacted?: boolean;
|
||||
};
|
||||
|
||||
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
@@ -35,6 +39,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
readonly,
|
||||
selectOnRender,
|
||||
onKeyDown,
|
||||
isRedacted = false,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -48,6 +53,9 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
}
|
||||
}, [selectOnRender]);
|
||||
|
||||
const [isTemporarilyUnredacted, setIsTemporarilyUnredacted] =
|
||||
useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("ExcTextField", {
|
||||
@@ -64,14 +72,26 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
})}
|
||||
>
|
||||
<input
|
||||
className={clsx({
|
||||
"is-redacted": value && isRedacted && !isTemporarilyUnredacted,
|
||||
})}
|
||||
readOnly={readonly}
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
ref={innerRef}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{isRedacted && (
|
||||
<Button
|
||||
onSelect={() =>
|
||||
setIsTemporarilyUnredacted(!isTemporarilyUnredacted)
|
||||
}
|
||||
style={{ border: 0, userSelect: "none" }}
|
||||
>
|
||||
{isTemporarilyUnredacted ? eyeClosedIcon : eyeIcon}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@@ -175,7 +175,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon__LaserPointer .ToolIcon__icon {
|
||||
.ToolIcon__LaserPointer .ToolIcon__icon,
|
||||
.ToolIcon__MagicButton .ToolIcon__icon {
|
||||
width: var(--default-button-size);
|
||||
height: var(--default-button-size);
|
||||
}
|
||||
|
@@ -189,8 +189,6 @@ const getRelevantAppStateProps = (
|
||||
suggestedBindings: appState.suggestedBindings,
|
||||
isRotating: appState.isRotating,
|
||||
elementsToHighlight: appState.elementsToHighlight,
|
||||
openSidebar: appState.openSidebar,
|
||||
showHyperlinkPopup: appState.showHyperlinkPopup,
|
||||
collaborators: appState.collaborators, // Necessary for collab. sessions
|
||||
activeEmbeddable: appState.activeEmbeddable,
|
||||
snapLines: appState.snapLines,
|
||||
|
@@ -63,9 +63,13 @@
|
||||
}
|
||||
|
||||
&__text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
&__shortcut {
|
||||
|
@@ -37,6 +37,32 @@ const DropdownMenuItem = ({
|
||||
</button>
|
||||
);
|
||||
};
|
||||
DropdownMenuItem.displayName = "DropdownMenuItem";
|
||||
|
||||
export const DropDownMenuItemBadge = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
marginLeft: "auto",
|
||||
padding: "1px 4px",
|
||||
background: "pink",
|
||||
borderRadius: 6,
|
||||
fontSize: 11,
|
||||
color: "black",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
DropDownMenuItemBadge.displayName = "DropdownMenuItemBadge";
|
||||
|
||||
DropdownMenuItem.Badge = DropDownMenuItemBadge;
|
||||
|
||||
export default DropdownMenuItem;
|
||||
DropdownMenuItem.displayName = "DropdownMenuItem";
|
||||
|
@@ -1688,3 +1688,80 @@ export const laserPointerToolIcon = createIcon(
|
||||
|
||||
20,
|
||||
);
|
||||
|
||||
export const MagicIcon = createIcon(
|
||||
<g stroke="currentColor" fill="none">
|
||||
<path stroke="none" d="M0 0h24v24H0z" />
|
||||
<path d="M6 21l15 -15l-3 -3l-15 15l3 3" />
|
||||
<path d="M15 6l3 3" />
|
||||
<path d="M9 3a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2" />
|
||||
<path d="M19 13a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const OpenAIIcon = createIcon(
|
||||
<g stroke="currentColor" fill="none">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M11.217 19.384a3.501 3.501 0 0 0 6.783 -1.217v-5.167l-6 -3.35" />
|
||||
<path d="M5.214 15.014a3.501 3.501 0 0 0 4.446 5.266l4.34 -2.534v-6.946" />
|
||||
<path d="M6 7.63c-1.391 -.236 -2.787 .395 -3.534 1.689a3.474 3.474 0 0 0 1.271 4.745l4.263 2.514l6 -3.348" />
|
||||
<path d="M12.783 4.616a3.501 3.501 0 0 0 -6.783 1.217v5.067l6 3.45" />
|
||||
<path d="M18.786 8.986a3.501 3.501 0 0 0 -4.446 -5.266l-4.34 2.534v6.946" />
|
||||
<path d="M18 16.302c1.391 .236 2.787 -.395 3.534 -1.689a3.474 3.474 0 0 0 -1.271 -4.745l-4.308 -2.514l-5.955 3.42" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const fullscreenIcon = createIcon(
|
||||
<g stroke="currentColor" fill="none">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M4 8v-2a2 2 0 0 1 2 -2h2" />
|
||||
<path d="M4 16v2a2 2 0 0 0 2 2h2" />
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v2" />
|
||||
<path d="M16 20h2a2 2 0 0 0 2 -2v-2" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const eyeIcon = createIcon(
|
||||
<g stroke="currentColor" fill="none">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
|
||||
<path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const eyeClosedIcon = createIcon(
|
||||
<g stroke="currentColor" fill="none">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" />
|
||||
<path d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87" />
|
||||
<path d="M3 3l18 18" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const brainIcon = createIcon(
|
||||
<g stroke="currentColor" fill="none">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M15.5 13a3.5 3.5 0 0 0 -3.5 3.5v1a3.5 3.5 0 0 0 7 0v-1.8" />
|
||||
<path d="M8.5 13a3.5 3.5 0 0 1 3.5 3.5v1a3.5 3.5 0 0 1 -7 0v-1.8" />
|
||||
<path d="M17.5 16a3.5 3.5 0 0 0 0 -7h-.5" />
|
||||
<path d="M19 9.3v-2.8a3.5 3.5 0 0 0 -7 0" />
|
||||
<path d="M6.5 16a3.5 3.5 0 0 1 0 -7h.5" />
|
||||
<path d="M5 9.3v-2.8a3.5 3.5 0 0 1 7 0v10" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const drawingIcon = createIcon(
|
||||
<g stroke="currentColor" fill="none">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M20 17v-12c0 -1.121 -.879 -2 -2 -2s-2 .879 -2 2v12l2 2l2 -2z" />
|
||||
<path d="M16 7h4" />
|
||||
<path d="M18 19h-13a2 2 0 1 1 0 -4h4a2 2 0 1 0 0 -4h-3" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
@@ -107,7 +107,7 @@ export const SaveAsImage = () => {
|
||||
<DropdownMenuItem
|
||||
icon={ExportImageIcon}
|
||||
data-testid="image-export-button"
|
||||
onSelect={() => setAppState({ openDialog: "imageExport" })}
|
||||
onSelect={() => setAppState({ openDialog: { name: "imageExport" } })}
|
||||
shortcut={getShortcutFromShortcutName("imageExport")}
|
||||
aria-label={t("buttons.exportImage")}
|
||||
>
|
||||
@@ -230,7 +230,7 @@ export const Export = () => {
|
||||
<DropdownMenuItem
|
||||
icon={ExportIcon}
|
||||
onSelect={() => {
|
||||
setAppState({ openDialog: "jsonExport" });
|
||||
setAppState({ openDialog: { name: "jsonExport" } });
|
||||
}}
|
||||
data-testid="json-export-button"
|
||||
aria-label={t("buttons.export")}
|
||||
|
@@ -80,6 +80,7 @@ export enum EVENT {
|
||||
EXCALIDRAW_LINK = "excalidraw-link",
|
||||
MENU_ITEM_SELECT = "menu.itemSelect",
|
||||
MESSAGE = "message",
|
||||
FULLSCREENCHANGE = "fullscreenchange",
|
||||
}
|
||||
|
||||
export const YOUTUBE_STATES = {
|
||||
@@ -344,4 +345,33 @@ export const DEFAULT_SIDEBAR = {
|
||||
defaultTab: LIBRARY_SIDEBAR_TAB,
|
||||
} as const;
|
||||
|
||||
export const LIBRARY_DISABLED_TYPES = new Set(["embeddable", "image"] as const);
|
||||
export const LIBRARY_DISABLED_TYPES = new Set([
|
||||
"iframe",
|
||||
"embeddable",
|
||||
"image",
|
||||
] as const);
|
||||
|
||||
// use these constants to easily identify reference sites
|
||||
export const TOOL_TYPE = {
|
||||
selection: "selection",
|
||||
rectangle: "rectangle",
|
||||
diamond: "diamond",
|
||||
ellipse: "ellipse",
|
||||
arrow: "arrow",
|
||||
line: "line",
|
||||
freedraw: "freedraw",
|
||||
text: "text",
|
||||
image: "image",
|
||||
eraser: "eraser",
|
||||
hand: "hand",
|
||||
frame: "frame",
|
||||
magicframe: "magicframe",
|
||||
embeddable: "embeddable",
|
||||
laser: "laser",
|
||||
} as const;
|
||||
|
||||
export const EDITOR_LS_KEYS = {
|
||||
OAI_API_KEY: "excalidraw-oai-api-key",
|
||||
// legacy naming (non)scheme
|
||||
PUBLISH_LIBRARY: "publish-library-data",
|
||||
} as const;
|
||||
|
@@ -13,6 +13,7 @@ type TunnelsContextValue = {
|
||||
DefaultSidebarTriggerTunnel: Tunnel;
|
||||
DefaultSidebarTabTriggersTunnel: Tunnel;
|
||||
OverwriteConfirmDialogTunnel: Tunnel;
|
||||
TTDDialogTriggerTunnel: Tunnel;
|
||||
jotaiScope: symbol;
|
||||
};
|
||||
|
||||
@@ -32,6 +33,7 @@ export const useInitializeTunnels = () => {
|
||||
DefaultSidebarTriggerTunnel: tunnel(),
|
||||
DefaultSidebarTabTriggersTunnel: tunnel(),
|
||||
OverwriteConfirmDialogTunnel: tunnel(),
|
||||
TTDDialogTriggerTunnel: tunnel(),
|
||||
jotaiScope: Symbol(),
|
||||
};
|
||||
}, []);
|
||||
|
@@ -5,9 +5,11 @@
|
||||
--zIndex-canvas: 1;
|
||||
--zIndex-interactiveCanvas: 2;
|
||||
--zIndex-wysiwyg: 3;
|
||||
--zIndex-canvasButtons: 3;
|
||||
--zIndex-layerUI: 4;
|
||||
--zIndex-eyeDropperBackdrop: 5;
|
||||
--zIndex-eyeDropperPreview: 6;
|
||||
--zIndex-hyperlinkContainer: 7;
|
||||
|
||||
--zIndex-modal: 1000;
|
||||
--zIndex-popup: 1001;
|
||||
@@ -37,6 +39,7 @@
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
@@ -531,6 +534,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
input.is-redacted {
|
||||
// we don't use type=password because browsers (chrome?) prompt
|
||||
// you to save it which is annoying
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
textarea:not(.excalidraw-wysiwyg) {
|
||||
color: var(--text-primary-color);
|
||||
@@ -643,6 +652,19 @@
|
||||
--button-bg: var(--color-surface-high);
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw__paragraph {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.Modal__content {
|
||||
.excalidraw__paragraph:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.excalidraw__paragraph + .excalidraw__paragraph {
|
||||
margin-top: 0rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ErrorSplash.excalidraw {
|
||||
|
51
src/data/EditorLocalStorage.ts
Normal file
51
src/data/EditorLocalStorage.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { EDITOR_LS_KEYS } from "../constants";
|
||||
import { JSONValue } from "../types";
|
||||
|
||||
export class EditorLocalStorage {
|
||||
static has(key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS]) {
|
||||
try {
|
||||
return !!window.localStorage.getItem(key);
|
||||
} catch (error: any) {
|
||||
console.warn(`localStorage.getItem error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static get<T extends JSONValue>(
|
||||
key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
|
||||
) {
|
||||
try {
|
||||
const value = window.localStorage.getItem(key);
|
||||
if (value) {
|
||||
return JSON.parse(value) as T;
|
||||
}
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
console.warn(`localStorage.getItem error: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static set = (
|
||||
key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
|
||||
value: JSONValue,
|
||||
) => {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.warn(`localStorage.setItem error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
static delete = (
|
||||
name: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
|
||||
) => {
|
||||
try {
|
||||
window.localStorage.removeItem(name);
|
||||
} catch (error: any) {
|
||||
console.warn(`localStorage.removeItem error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
}
|
@@ -14,7 +14,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -50,7 +49,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -81,7 +79,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": {
|
||||
"elementId": "ellipse-1",
|
||||
@@ -135,7 +132,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": {
|
||||
"elementId": "ellipse-1",
|
||||
@@ -194,7 +190,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -232,7 +227,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
},
|
||||
],
|
||||
"containerId": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
@@ -277,7 +271,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
},
|
||||
],
|
||||
"containerId": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
@@ -320,7 +313,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"customData": undefined,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": {
|
||||
"elementId": "text-2",
|
||||
@@ -376,7 +368,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"baseline": 0,
|
||||
"boundElements": null,
|
||||
"containerId": "id48",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
@@ -419,7 +410,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"customData": undefined,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": {
|
||||
"elementId": "id40",
|
||||
@@ -475,7 +465,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"baseline": 0,
|
||||
"boundElements": null,
|
||||
"containerId": "id37",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
@@ -518,7 +507,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -554,7 +542,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -590,7 +577,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"customData": undefined,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": {
|
||||
"elementId": "id44",
|
||||
@@ -646,7 +632,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"baseline": 0,
|
||||
"boundElements": null,
|
||||
"containerId": "id41",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
@@ -691,7 +676,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
},
|
||||
],
|
||||
"containerId": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
@@ -736,7 +720,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
},
|
||||
],
|
||||
"containerId": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
@@ -774,7 +757,6 @@ exports[`Test Transform > should not allow duplicate ids 1`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -805,7 +787,6 @@ exports[`Test Transform > should transform linear elements 1`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
@@ -851,7 +832,6 @@ exports[`Test Transform > should transform linear elements 2`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"endArrowhead": "triangle",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
@@ -897,7 +877,6 @@ exports[`Test Transform > should transform linear elements 3`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
@@ -943,7 +922,6 @@ exports[`Test Transform > should transform linear elements 4`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
@@ -989,7 +967,6 @@ exports[`Test Transform > should transform regular shapes 1`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1020,7 +997,6 @@ exports[`Test Transform > should transform regular shapes 2`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1051,7 +1027,6 @@ exports[`Test Transform > should transform regular shapes 3`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1082,7 +1057,6 @@ exports[`Test Transform > should transform regular shapes 4`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "#c0eb75",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1113,7 +1087,6 @@ exports[`Test Transform > should transform regular shapes 5`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "#ffc9c9",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1144,7 +1117,6 @@ exports[`Test Transform > should transform regular shapes 6`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "#a5d8ff",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "cross-hatch",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1177,7 +1149,6 @@ exports[`Test Transform > should transform text element 1`] = `
|
||||
"baseline": 0,
|
||||
"boundElements": null,
|
||||
"containerId": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
@@ -1217,7 +1188,6 @@ exports[`Test Transform > should transform text element 2`] = `
|
||||
"baseline": 0,
|
||||
"boundElements": null,
|
||||
"containerId": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
@@ -1260,7 +1230,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"customData": undefined,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
@@ -1311,7 +1280,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"customData": undefined,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
@@ -1362,7 +1330,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"customData": undefined,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
@@ -1413,7 +1380,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"customData": undefined,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
@@ -1461,7 +1427,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"baseline": 0,
|
||||
"boundElements": null,
|
||||
"containerId": "id25",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
@@ -1501,7 +1466,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"baseline": 0,
|
||||
"boundElements": null,
|
||||
"containerId": "id26",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
@@ -1541,7 +1505,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"baseline": 0,
|
||||
"boundElements": null,
|
||||
"containerId": "id27",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
@@ -1582,7 +1545,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"baseline": 0,
|
||||
"boundElements": null,
|
||||
"containerId": "id28",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
@@ -1626,7 +1588,6 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1662,7 +1623,6 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1698,7 +1658,6 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1734,7 +1693,6 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1770,7 +1728,6 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1806,7 +1763,6 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1839,7 +1795,6 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"baseline": 0,
|
||||
"boundElements": null,
|
||||
"containerId": "id13",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
@@ -1879,7 +1834,6 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"baseline": 0,
|
||||
"boundElements": null,
|
||||
"containerId": "id14",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
@@ -1920,7 +1874,6 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"baseline": 0,
|
||||
"boundElements": null,
|
||||
"containerId": "id15",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
@@ -1963,7 +1916,6 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"baseline": 0,
|
||||
"boundElements": null,
|
||||
"containerId": "id16",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
@@ -2004,7 +1956,6 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"baseline": 0,
|
||||
"boundElements": null,
|
||||
"containerId": "id17",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
@@ -2046,7 +1997,6 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"baseline": 0,
|
||||
"boundElements": null,
|
||||
"containerId": "id18",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
|
300
src/data/ai/types.ts
Normal file
300
src/data/ai/types.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
export namespace OpenAIInput {
|
||||
type ChatCompletionContentPart =
|
||||
| ChatCompletionContentPartText
|
||||
| ChatCompletionContentPartImage;
|
||||
|
||||
interface ChatCompletionContentPartImage {
|
||||
image_url: ChatCompletionContentPartImage.ImageURL;
|
||||
|
||||
/**
|
||||
* The type of the content part.
|
||||
*/
|
||||
type: "image_url";
|
||||
}
|
||||
|
||||
namespace ChatCompletionContentPartImage {
|
||||
export interface ImageURL {
|
||||
/**
|
||||
* Either a URL of the image or the base64 encoded image data.
|
||||
*/
|
||||
url: string;
|
||||
|
||||
/**
|
||||
* Specifies the detail level of the image.
|
||||
*/
|
||||
detail?: "auto" | "low" | "high";
|
||||
}
|
||||
}
|
||||
|
||||
interface ChatCompletionContentPartText {
|
||||
/**
|
||||
* The text content.
|
||||
*/
|
||||
text: string;
|
||||
|
||||
/**
|
||||
* The type of the content part.
|
||||
*/
|
||||
type: "text";
|
||||
}
|
||||
|
||||
interface ChatCompletionUserMessageParam {
|
||||
/**
|
||||
* The contents of the user message.
|
||||
*/
|
||||
content: string | Array<ChatCompletionContentPart> | null;
|
||||
|
||||
/**
|
||||
* The role of the messages author, in this case `user`.
|
||||
*/
|
||||
role: "user";
|
||||
}
|
||||
|
||||
interface ChatCompletionSystemMessageParam {
|
||||
/**
|
||||
* The contents of the system message.
|
||||
*/
|
||||
content: string | null;
|
||||
|
||||
/**
|
||||
* The role of the messages author, in this case `system`.
|
||||
*/
|
||||
role: "system";
|
||||
}
|
||||
|
||||
export interface ChatCompletionCreateParamsBase {
|
||||
/**
|
||||
* A list of messages comprising the conversation so far.
|
||||
* [Example Python code](https://cookbook.openai.com/examples/how_to_format_inputs_to_chatgpt_models).
|
||||
*/
|
||||
messages: Array<
|
||||
ChatCompletionUserMessageParam | ChatCompletionSystemMessageParam
|
||||
>;
|
||||
|
||||
/**
|
||||
* ID of the model to use. See the
|
||||
* [model endpoint compatibility](https://platform.openai.com/docs/models/model-endpoint-compatibility)
|
||||
* table for details on which models work with the Chat API.
|
||||
*/
|
||||
model:
|
||||
| (string & {})
|
||||
| "gpt-4-1106-preview"
|
||||
| "gpt-4-vision-preview"
|
||||
| "gpt-4"
|
||||
| "gpt-4-0314"
|
||||
| "gpt-4-0613"
|
||||
| "gpt-4-32k"
|
||||
| "gpt-4-32k-0314"
|
||||
| "gpt-4-32k-0613"
|
||||
| "gpt-3.5-turbo"
|
||||
| "gpt-3.5-turbo-16k"
|
||||
| "gpt-3.5-turbo-0301"
|
||||
| "gpt-3.5-turbo-0613"
|
||||
| "gpt-3.5-turbo-16k-0613";
|
||||
|
||||
/**
|
||||
* Number between -2.0 and 2.0. Positive values penalize new tokens based on their
|
||||
* existing frequency in the text so far, decreasing the model's likelihood to
|
||||
* repeat the same line verbatim.
|
||||
*
|
||||
* [See more information about frequency and presence penalties.](https://platform.openai.com/docs/guides/gpt/parameter-details)
|
||||
*/
|
||||
frequency_penalty?: number | null;
|
||||
|
||||
/**
|
||||
* Modify the likelihood of specified tokens appearing in the completion.
|
||||
*
|
||||
* Accepts a JSON object that maps tokens (specified by their token ID in the
|
||||
* tokenizer) to an associated bias value from -100 to 100. Mathematically, the
|
||||
* bias is added to the logits generated by the model prior to sampling. The exact
|
||||
* effect will vary per model, but values between -1 and 1 should decrease or
|
||||
* increase likelihood of selection; values like -100 or 100 should result in a ban
|
||||
* or exclusive selection of the relevant token.
|
||||
*/
|
||||
logit_bias?: Record<string, number> | null;
|
||||
|
||||
/**
|
||||
* The maximum number of [tokens](/tokenizer) to generate in the chat completion.
|
||||
*
|
||||
* The total length of input tokens and generated tokens is limited by the model's
|
||||
* context length.
|
||||
* [Example Python code](https://cookbook.openai.com/examples/how_to_count_tokens_with_tiktoken)
|
||||
* for counting tokens.
|
||||
*/
|
||||
max_tokens?: number | null;
|
||||
|
||||
/**
|
||||
* How many chat completion choices to generate for each input message.
|
||||
*/
|
||||
n?: number | null;
|
||||
|
||||
/**
|
||||
* Number between -2.0 and 2.0. Positive values penalize new tokens based on
|
||||
* whether they appear in the text so far, increasing the model's likelihood to
|
||||
* talk about new topics.
|
||||
*
|
||||
* [See more information about frequency and presence penalties.](https://platform.openai.com/docs/guides/gpt/parameter-details)
|
||||
*/
|
||||
presence_penalty?: number | null;
|
||||
|
||||
/**
|
||||
* This feature is in Beta. If specified, our system will make a best effort to
|
||||
* sample deterministically, such that repeated requests with the same `seed` and
|
||||
* parameters should return the same result. Determinism is not guaranteed, and you
|
||||
* should refer to the `system_fingerprint` response parameter to monitor changes
|
||||
* in the backend.
|
||||
*/
|
||||
seed?: number | null;
|
||||
|
||||
/**
|
||||
* Up to 4 sequences where the API will stop generating further tokens.
|
||||
*/
|
||||
stop?: string | null | Array<string>;
|
||||
|
||||
/**
|
||||
* If set, partial message deltas will be sent, like in ChatGPT. Tokens will be
|
||||
* sent as data-only
|
||||
* [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format)
|
||||
* as they become available, with the stream terminated by a `data: [DONE]`
|
||||
* message.
|
||||
* [Example Python code](https://cookbook.openai.com/examples/how_to_stream_completions).
|
||||
*/
|
||||
stream?: boolean | null;
|
||||
|
||||
/**
|
||||
* What sampling temperature to use, between 0 and 2. Higher values like 0.8 will
|
||||
* make the output more random, while lower values like 0.2 will make it more
|
||||
* focused and deterministic.
|
||||
*
|
||||
* We generally recommend altering this or `top_p` but not both.
|
||||
*/
|
||||
temperature?: number | null;
|
||||
|
||||
/**
|
||||
* An alternative to sampling with temperature, called nucleus sampling, where the
|
||||
* model considers the results of the tokens with top_p probability mass. So 0.1
|
||||
* means only the tokens comprising the top 10% probability mass are considered.
|
||||
*
|
||||
* We generally recommend altering this or `temperature` but not both.
|
||||
*/
|
||||
top_p?: number | null;
|
||||
|
||||
/**
|
||||
* A unique identifier representing your end-user, which can help OpenAI to monitor
|
||||
* and detect abuse.
|
||||
* [Learn more](https://platform.openai.com/docs/guides/safety-best-practices/end-user-ids).
|
||||
*/
|
||||
user?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export namespace OpenAIOutput {
|
||||
export interface ChatCompletion {
|
||||
/**
|
||||
* A unique identifier for the chat completion.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* A list of chat completion choices. Can be more than one if `n` is greater
|
||||
* than 1.
|
||||
*/
|
||||
choices: Array<Choice>;
|
||||
|
||||
/**
|
||||
* The Unix timestamp (in seconds) of when the chat completion was created.
|
||||
*/
|
||||
created: number;
|
||||
|
||||
/**
|
||||
* The model used for the chat completion.
|
||||
*/
|
||||
model: string;
|
||||
|
||||
/**
|
||||
* The object type, which is always `chat.completion`.
|
||||
*/
|
||||
object: "chat.completion";
|
||||
|
||||
/**
|
||||
* This fingerprint represents the backend configuration that the model runs with.
|
||||
*
|
||||
* Can be used in conjunction with the `seed` request parameter to understand when
|
||||
* backend changes have been made that might impact determinism.
|
||||
*/
|
||||
system_fingerprint?: string;
|
||||
|
||||
/**
|
||||
* Usage statistics for the completion request.
|
||||
*/
|
||||
usage?: CompletionUsage;
|
||||
}
|
||||
export interface Choice {
|
||||
/**
|
||||
* The reason the model stopped generating tokens. This will be `stop` if the model
|
||||
* hit a natural stop point or a provided stop sequence, `length` if the maximum
|
||||
* number of tokens specified in the request was reached, `content_filter` if
|
||||
* content was omitted due to a flag from our content filters, `tool_calls` if the
|
||||
* model called a tool, or `function_call` (deprecated) if the model called a
|
||||
* function.
|
||||
*/
|
||||
finish_reason:
|
||||
| "stop"
|
||||
| "length"
|
||||
| "tool_calls"
|
||||
| "content_filter"
|
||||
| "function_call";
|
||||
|
||||
/**
|
||||
* The index of the choice in the list of choices.
|
||||
*/
|
||||
index: number;
|
||||
|
||||
/**
|
||||
* A chat completion message generated by the model.
|
||||
*/
|
||||
message: ChatCompletionMessage;
|
||||
}
|
||||
|
||||
interface ChatCompletionMessage {
|
||||
/**
|
||||
* The contents of the message.
|
||||
*/
|
||||
content: string | null;
|
||||
|
||||
/**
|
||||
* The role of the author of this message.
|
||||
*/
|
||||
role: "assistant";
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage statistics for the completion request.
|
||||
*/
|
||||
interface CompletionUsage {
|
||||
/**
|
||||
* Number of tokens in the generated completion.
|
||||
*/
|
||||
completion_tokens: number;
|
||||
|
||||
/**
|
||||
* Number of tokens in the prompt.
|
||||
*/
|
||||
prompt_tokens: number;
|
||||
|
||||
/**
|
||||
* Total number of tokens used in the request (prompt + completion).
|
||||
*/
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
export interface APIError {
|
||||
readonly status: 400 | 401 | 403 | 404 | 409 | 422 | 429 | 500 | undefined;
|
||||
readonly headers: Headers | undefined;
|
||||
readonly error: { message: string } | undefined;
|
||||
|
||||
readonly code: string | null | undefined;
|
||||
readonly param: string | null | undefined;
|
||||
readonly type: string | undefined;
|
||||
}
|
||||
}
|
@@ -3,10 +3,11 @@ import {
|
||||
copyTextToSystemClipboard,
|
||||
} from "../clipboard";
|
||||
import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants";
|
||||
import { getNonDeletedElements, isFrameElement } from "../element";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
@@ -38,7 +39,7 @@ export const prepareElementsForExport = (
|
||||
exportSelectionOnly &&
|
||||
isSomeElementSelected(elements, { selectedElementIds });
|
||||
|
||||
let exportingFrame: ExcalidrawFrameElement | null = null;
|
||||
let exportingFrame: ExcalidrawFrameLikeElement | null = null;
|
||||
let exportedElements = isExportingSelection
|
||||
? getSelectedElements(
|
||||
elements,
|
||||
@@ -50,7 +51,10 @@ export const prepareElementsForExport = (
|
||||
: elements;
|
||||
|
||||
if (isExportingSelection) {
|
||||
if (exportedElements.length === 1 && isFrameElement(exportedElements[0])) {
|
||||
if (
|
||||
exportedElements.length === 1 &&
|
||||
isFrameLikeElement(exportedElements[0])
|
||||
) {
|
||||
exportingFrame = exportedElements[0];
|
||||
exportedElements = elementsOverlappingBBox({
|
||||
elements,
|
||||
@@ -93,7 +97,7 @@ export const exportCanvas = async (
|
||||
viewBackgroundColor: string;
|
||||
name: string;
|
||||
fileHandle?: FileSystemHandle | null;
|
||||
exportingFrame: ExcalidrawFrameElement | null;
|
||||
exportingFrame: ExcalidrawFrameLikeElement | null;
|
||||
},
|
||||
) => {
|
||||
if (elements.length === 0) {
|
||||
|
104
src/data/magic.ts
Normal file
104
src/data/magic.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Theme } from "../element/types";
|
||||
import { DataURL } from "../types";
|
||||
import { OpenAIInput, OpenAIOutput } from "./ai/types";
|
||||
|
||||
export type MagicCacheData =
|
||||
| {
|
||||
status: "pending";
|
||||
}
|
||||
| { status: "done"; html: string }
|
||||
| {
|
||||
status: "error";
|
||||
message?: string;
|
||||
code: "ERR_GENERATION_INTERRUPTED" | string;
|
||||
};
|
||||
|
||||
const SYSTEM_PROMPT = `You are a skilled front-end developer who builds interactive prototypes from wireframes, and is an expert at CSS Grid and Flex design.
|
||||
Your role is to transform low-fidelity wireframes into working front-end HTML code.
|
||||
|
||||
YOU MUST FOLLOW FOLLOWING RULES:
|
||||
|
||||
- Use HTML, CSS, JavaScript to build a responsive, accessible, polished prototype
|
||||
- Leverage Tailwind for styling and layout (import as script <script src="https://cdn.tailwindcss.com"></script>)
|
||||
- Inline JavaScript when needed
|
||||
- Fetch dependencies from CDNs when needed (using unpkg or skypack)
|
||||
- Source images from Unsplash or create applicable placeholders
|
||||
- Interpret annotations as intended vs literal UI
|
||||
- Fill gaps using your expertise in UX and business logic
|
||||
- generate primarily for desktop UI, but make it responsive.
|
||||
- Use grid and flexbox wherever applicable.
|
||||
- Convert the wireframe in its entirety, don't omit elements if possible.
|
||||
|
||||
If the wireframes, diagrams, or text is unclear or unreadable, refer to provided text for clarification.
|
||||
|
||||
Your goal is a production-ready prototype that brings the wireframes to life.
|
||||
|
||||
Please output JUST THE HTML file containing your best attempt at implementing the provided wireframes.`;
|
||||
|
||||
export async function diagramToHTML({
|
||||
image,
|
||||
apiKey,
|
||||
text,
|
||||
theme = "light",
|
||||
}: {
|
||||
image: DataURL;
|
||||
apiKey: string;
|
||||
text: string;
|
||||
theme?: Theme;
|
||||
}) {
|
||||
const body: OpenAIInput.ChatCompletionCreateParamsBase = {
|
||||
model: "gpt-4-vision-preview",
|
||||
// 4096 are max output tokens allowed for `gpt-4-vision-preview` currently
|
||||
max_tokens: 4096,
|
||||
temperature: 0.1,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: SYSTEM_PROMPT,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: image,
|
||||
detail: "high",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `Above is the reference wireframe. Please make a new website based on these and return just the HTML file. Also, please make it for the ${theme} theme. What follows are the wireframe's text annotations (if any)...`,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let result:
|
||||
| ({ ok: true } & OpenAIOutput.ChatCompletion)
|
||||
| ({ ok: false } & OpenAIOutput.APIError);
|
||||
|
||||
const resp = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
const json: OpenAIOutput.ChatCompletion = await resp.json();
|
||||
result = { ...json, ok: true };
|
||||
} else {
|
||||
const json: OpenAIOutput.APIError = await resp.json();
|
||||
result = { ...json, ok: false };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawElementType,
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawTextElement,
|
||||
FontFamilyValues,
|
||||
@@ -68,6 +69,7 @@ export const AllowedExcalidrawActiveTools: Record<
|
||||
embeddable: true,
|
||||
hand: true,
|
||||
laser: false,
|
||||
magicframe: false,
|
||||
};
|
||||
|
||||
export type RestoredDataState = {
|
||||
@@ -111,7 +113,7 @@ const restoreElementWithProperties = <
|
||||
// @ts-ignore TS complains here but type checks the call sites fine.
|
||||
keyof K
|
||||
> &
|
||||
Partial<Pick<ExcalidrawElement, "type" | "x" | "y">>,
|
||||
Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
|
||||
): T => {
|
||||
const base: Pick<T, keyof ExcalidrawElement> & {
|
||||
[PRECEDING_ELEMENT_KEY]?: string;
|
||||
@@ -159,8 +161,9 @@ const restoreElementWithProperties = <
|
||||
locked: element.locked ?? false,
|
||||
};
|
||||
|
||||
if ("customData" in element) {
|
||||
base.customData = element.customData;
|
||||
if ("customData" in element || "customData" in extra) {
|
||||
base.customData =
|
||||
"customData" in extra ? extra.customData : element.customData;
|
||||
}
|
||||
|
||||
if (PRECEDING_ELEMENT_KEY in element) {
|
||||
@@ -273,7 +276,7 @@ const restoreElement = (
|
||||
|
||||
return restoreElementWithProperties(element, {
|
||||
type:
|
||||
(element.type as ExcalidrawElement["type"] | "draw") === "draw"
|
||||
(element.type as ExcalidrawElementType | "draw") === "draw"
|
||||
? "line"
|
||||
: element.type,
|
||||
startBinding: repairBinding(element.startBinding),
|
||||
@@ -289,15 +292,15 @@ const restoreElement = (
|
||||
|
||||
// generic elements
|
||||
case "ellipse":
|
||||
return restoreElementWithProperties(element, {});
|
||||
case "rectangle":
|
||||
return restoreElementWithProperties(element, {});
|
||||
case "diamond":
|
||||
case "iframe":
|
||||
return restoreElementWithProperties(element, {});
|
||||
case "embeddable":
|
||||
return restoreElementWithProperties(element, {
|
||||
validated: null,
|
||||
});
|
||||
case "magicframe":
|
||||
case "frame":
|
||||
return restoreElementWithProperties(element, {
|
||||
name: element.name ?? null,
|
||||
|
@@ -822,22 +822,4 @@ describe("Test Transform", () => {
|
||||
"Duplicate id found for rect-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("should contains customData if provided", () => {
|
||||
const rawData = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
customData: { createdBy: "user01" },
|
||||
},
|
||||
];
|
||||
const convertedElements = convertToExcalidrawElements(
|
||||
rawData as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
expect(convertedElements[0].customData).toStrictEqual({
|
||||
createdBy: "user01",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -15,6 +15,7 @@ import {
|
||||
ElementConstructorOpts,
|
||||
newFrameElement,
|
||||
newImageElement,
|
||||
newMagicFrameElement,
|
||||
newTextElement,
|
||||
} from "../element/newElement";
|
||||
import {
|
||||
@@ -26,12 +27,13 @@ import {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawGenericElement,
|
||||
ExcalidrawIframeLikeElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawMagicFrameElement,
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawTextElement,
|
||||
FileId,
|
||||
@@ -61,7 +63,12 @@ export type ValidLinearElement = {
|
||||
| {
|
||||
type: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
"image" | "text" | "frame" | "embeddable"
|
||||
| "image"
|
||||
| "text"
|
||||
| "frame"
|
||||
| "magicframe"
|
||||
| "embeddable"
|
||||
| "iframe"
|
||||
>;
|
||||
id?: ExcalidrawGenericElement["id"];
|
||||
}
|
||||
@@ -69,7 +76,12 @@ export type ValidLinearElement = {
|
||||
id: ExcalidrawGenericElement["id"];
|
||||
type?: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
"image" | "text" | "frame" | "embeddable"
|
||||
| "image"
|
||||
| "text"
|
||||
| "frame"
|
||||
| "magicframe"
|
||||
| "embeddable"
|
||||
| "iframe"
|
||||
>;
|
||||
}
|
||||
)
|
||||
@@ -93,7 +105,12 @@ export type ValidLinearElement = {
|
||||
| {
|
||||
type: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
"image" | "text" | "frame" | "embeddable"
|
||||
| "image"
|
||||
| "text"
|
||||
| "frame"
|
||||
| "magicframe"
|
||||
| "embeddable"
|
||||
| "iframe"
|
||||
>;
|
||||
id?: ExcalidrawGenericElement["id"];
|
||||
}
|
||||
@@ -101,7 +118,12 @@ export type ValidLinearElement = {
|
||||
id: ExcalidrawGenericElement["id"];
|
||||
type?: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
"image" | "text" | "frame" | "embeddable"
|
||||
| "image"
|
||||
| "text"
|
||||
| "frame"
|
||||
| "magicframe"
|
||||
| "embeddable"
|
||||
| "iframe"
|
||||
>;
|
||||
}
|
||||
)
|
||||
@@ -137,7 +159,7 @@ export type ValidContainer =
|
||||
export type ExcalidrawElementSkeleton =
|
||||
| Extract<
|
||||
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
ExcalidrawEmbeddableElement | ExcalidrawFreeDrawElement
|
||||
ExcalidrawIframeLikeElement | ExcalidrawFreeDrawElement
|
||||
>
|
||||
| ({
|
||||
type: Extract<ExcalidrawLinearElement["type"], "line">;
|
||||
@@ -163,7 +185,12 @@ export type ExcalidrawElementSkeleton =
|
||||
type: "frame";
|
||||
children: readonly ExcalidrawElement["id"][];
|
||||
name?: string;
|
||||
} & Partial<ExcalidrawFrameElement>);
|
||||
} & Partial<ExcalidrawFrameElement>)
|
||||
| ({
|
||||
type: "magicframe";
|
||||
children: readonly ExcalidrawElement["id"][];
|
||||
name?: string;
|
||||
} & Partial<ExcalidrawMagicFrameElement>);
|
||||
|
||||
const DEFAULT_LINEAR_ELEMENT_PROPS = {
|
||||
width: 100,
|
||||
@@ -547,7 +574,16 @@ export const convertToExcalidrawElements = (
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "magicframe": {
|
||||
excalidrawElement = newMagicFrameElement({
|
||||
x: 0,
|
||||
y: 0,
|
||||
...element,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "freedraw":
|
||||
case "iframe":
|
||||
case "embeddable": {
|
||||
excalidrawElement = element;
|
||||
break;
|
||||
@@ -656,7 +692,7 @@ export const convertToExcalidrawElements = (
|
||||
// need to calculate coordinates and dimensions of frame which is possibe after all
|
||||
// frame children are processed.
|
||||
for (const [id, element] of elementsWithIds) {
|
||||
if (element.type !== "frame") {
|
||||
if (element.type !== "frame" && element.type !== "magicframe") {
|
||||
continue;
|
||||
}
|
||||
const frame = elementStore.getElement(id);
|
||||
|
14
src/element/ElementCanvasButtons.scss
Normal file
14
src/element/ElementCanvasButtons.scss
Normal file
@@ -0,0 +1,14 @@
|
||||
.excalidraw {
|
||||
.excalidraw-canvas-buttons {
|
||||
position: absolute;
|
||||
|
||||
box-shadow: 0px 2px 4px 0 rgb(0 0 0 / 30%);
|
||||
z-index: var(--zIndex-canvasButtons);
|
||||
background: var(--island-bg-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
}
|
60
src/element/ElementCanvasButtons.tsx
Normal file
60
src/element/ElementCanvasButtons.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { AppState } from "../types";
|
||||
import { sceneCoordsToViewportCoords } from "../utils";
|
||||
import { NonDeletedExcalidrawElement } from "./types";
|
||||
import { getElementAbsoluteCoords } from ".";
|
||||
import { useExcalidrawAppState } from "../components/App";
|
||||
|
||||
import "./ElementCanvasButtons.scss";
|
||||
|
||||
const CONTAINER_PADDING = 5;
|
||||
|
||||
const getContainerCoords = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
) => {
|
||||
const [x1, y1] = getElementAbsoluteCoords(element);
|
||||
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
|
||||
{ sceneX: x1 + element.width, sceneY: y1 },
|
||||
appState,
|
||||
);
|
||||
const x = viewportX - appState.offsetLeft + 10;
|
||||
const y = viewportY - appState.offsetTop;
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
export const ElementCanvasButtons = ({
|
||||
children,
|
||||
element,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
element: NonDeletedExcalidrawElement;
|
||||
}) => {
|
||||
const appState = useExcalidrawAppState();
|
||||
|
||||
if (
|
||||
appState.contextMenu ||
|
||||
appState.draggingElement ||
|
||||
appState.resizingElement ||
|
||||
appState.isRotating ||
|
||||
appState.openMenu ||
|
||||
appState.viewModeEnabled
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { x, y } = getContainerCoords(element, appState);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="excalidraw-canvas-buttons"
|
||||
style={{
|
||||
top: `${y}px`,
|
||||
left: `${x}px`,
|
||||
// width: CONTAINER_WIDTH,
|
||||
padding: CONTAINER_PADDING,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -6,7 +6,7 @@
|
||||
justify-content: space-between;
|
||||
position: absolute;
|
||||
box-shadow: 0px 2px 4px 0 rgb(0 0 0 / 30%);
|
||||
z-index: 100;
|
||||
z-index: var(--zIndex-hyperlinkContainer);
|
||||
background: var(--island-bg-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
box-sizing: border-box;
|
||||
|
@@ -121,7 +121,7 @@ export const Hyperlink = ({
|
||||
setToast({ message: embedLink.warning, closable: true });
|
||||
}
|
||||
const ar = embedLink
|
||||
? embedLink.aspectRatio.w / embedLink.aspectRatio.h
|
||||
? embedLink.intrinsicSize.w / embedLink.intrinsicSize.h
|
||||
: 1;
|
||||
const hasLinkChanged =
|
||||
embeddableLinkCache.get(element.id) !== element.link;
|
||||
@@ -210,6 +210,7 @@ export const Hyperlink = ({
|
||||
};
|
||||
const { x, y } = getCoordsForPopover(element, appState);
|
||||
if (
|
||||
appState.contextMenu ||
|
||||
appState.draggingElement ||
|
||||
appState.resizingElement ||
|
||||
appState.isRotating ||
|
||||
|
@@ -18,7 +18,6 @@ import {
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawRectangleElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawEllipseElement,
|
||||
@@ -27,7 +26,8 @@ import {
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawLinearElement,
|
||||
StrokeRoundness,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
ExcalidrawIframeLikeElement,
|
||||
} from "./types";
|
||||
|
||||
import {
|
||||
@@ -41,7 +41,8 @@ import { Drawable } from "roughjs/bin/core";
|
||||
import { AppState } from "../types";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isEmbeddableElement,
|
||||
isFrameLikeElement,
|
||||
isIframeLikeElement,
|
||||
isImageElement,
|
||||
} from "./typeChecks";
|
||||
import { isTextElement } from ".";
|
||||
@@ -64,7 +65,7 @@ const isElementDraggableFromInside = (
|
||||
const isDraggableFromInside =
|
||||
!isTransparent(element.backgroundColor) ||
|
||||
hasBoundTextElement(element) ||
|
||||
isEmbeddableElement(element);
|
||||
isIframeLikeElement(element);
|
||||
if (element.type === "line") {
|
||||
return isDraggableFromInside && isPathALoop(element.points);
|
||||
}
|
||||
@@ -186,7 +187,7 @@ export const isPointHittingElementBoundingBox = (
|
||||
// by its frame, whether it has been selected or not
|
||||
// this logic here is not ideal
|
||||
// TODO: refactor it later...
|
||||
if (element.type === "frame") {
|
||||
if (isFrameLikeElement(element)) {
|
||||
return hitTestPointAgainstElement({
|
||||
element,
|
||||
point: [x, y],
|
||||
@@ -255,6 +256,7 @@ type HitTestArgs = {
|
||||
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
|
||||
switch (args.element.type) {
|
||||
case "rectangle":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "image":
|
||||
case "text":
|
||||
@@ -282,7 +284,8 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
|
||||
"This should not happen, we need to investigate why it does.",
|
||||
);
|
||||
return false;
|
||||
case "frame": {
|
||||
case "frame":
|
||||
case "magicframe": {
|
||||
// check distance to frame element first
|
||||
if (
|
||||
args.check(
|
||||
@@ -314,8 +317,10 @@ export const distanceToBindableElement = (
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
return distanceToRectangle(element, point);
|
||||
case "diamond":
|
||||
return distanceToDiamond(element, point);
|
||||
@@ -346,8 +351,8 @@ const distanceToRectangle = (
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawFrameElement,
|
||||
| ExcalidrawIframeLikeElement
|
||||
| ExcalidrawFrameLikeElement,
|
||||
point: Point,
|
||||
): number => {
|
||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
|
||||
@@ -662,8 +667,10 @@ export const determineFocusDistance = (
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
ret = c / (hwidth * (nabs + q * mabs));
|
||||
break;
|
||||
case "diamond":
|
||||
@@ -700,8 +707,10 @@ export const determineFocusPoint = (
|
||||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
|
||||
break;
|
||||
case "ellipse":
|
||||
@@ -752,8 +761,10 @@ const getSortedElementLineIntersections = (
|
||||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
const corners = getCorners(element);
|
||||
intersections = corners
|
||||
.flatMap((point, i) => {
|
||||
@@ -788,8 +799,8 @@ const getCorners = (
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawFrameElement,
|
||||
| ExcalidrawIframeLikeElement
|
||||
| ExcalidrawFrameLikeElement,
|
||||
scale: number = 1,
|
||||
): GA.Point[] => {
|
||||
const hx = (scale * element.width) / 2;
|
||||
@@ -798,8 +809,10 @@ const getCorners = (
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
return [
|
||||
GA.point(hx, hy),
|
||||
GA.point(hx, -hy),
|
||||
@@ -948,8 +961,8 @@ export const findFocusPointForRectangulars = (
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawFrameElement,
|
||||
| ExcalidrawIframeLikeElement
|
||||
| ExcalidrawFrameLikeElement,
|
||||
// Between -1 and 1 for how far away should the focus point be relative
|
||||
// to the size of the element. Sign determines orientation.
|
||||
relativeDistance: number,
|
||||
|
@@ -11,7 +11,7 @@ import Scene from "../scene/Scene";
|
||||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isFrameElement,
|
||||
isFrameLikeElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
@@ -33,7 +33,7 @@ export const dragSelectedElements = (
|
||||
selectedElements,
|
||||
);
|
||||
const frames = selectedElements
|
||||
.filter((e) => isFrameElement(e))
|
||||
.filter((e) => isFrameLikeElement(e))
|
||||
.map((f) => f.id);
|
||||
|
||||
if (frames.length > 0) {
|
||||
|
@@ -6,45 +6,38 @@ import { getFontString, updateActiveTool } from "../utils";
|
||||
import { setCursorForShape } from "../cursor";
|
||||
import { newTextElement } from "./newElement";
|
||||
import { getContainerElement, wrapText } from "./textElement";
|
||||
import { isEmbeddableElement } from "./typeChecks";
|
||||
import {
|
||||
isFrameLikeElement,
|
||||
isIframeElement,
|
||||
isIframeLikeElement,
|
||||
} from "./typeChecks";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
ExcalidrawIframeLikeElement,
|
||||
IframeData,
|
||||
NonDeletedExcalidrawElement,
|
||||
Theme,
|
||||
} from "./types";
|
||||
|
||||
type EmbeddedLink =
|
||||
| ({
|
||||
aspectRatio: { w: number; h: number };
|
||||
warning?: string;
|
||||
sandbox?: { allowSameOrigin?: boolean };
|
||||
} & (
|
||||
| { type: "video" | "generic"; link: string }
|
||||
| { type: "document"; srcdoc: (theme: Theme) => string }
|
||||
))
|
||||
| null;
|
||||
|
||||
const embeddedLinkCache = new Map<string, EmbeddedLink>();
|
||||
const embeddedLinkCache = new Map<string, IframeData>();
|
||||
|
||||
const RE_YOUTUBE =
|
||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
|
||||
|
||||
const RE_VIMEO =
|
||||
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
||||
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
||||
const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/;
|
||||
|
||||
const RE_GH_GIST = /^https:\/\/gist\.github\.com/;
|
||||
const RE_GH_GIST_EMBED =
|
||||
/https?:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)\.js["']/i;
|
||||
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist.github.com\/.*?)\.js["']/i;
|
||||
|
||||
// not anchored to start to allow <blockquote> twitter embeds
|
||||
const RE_TWITTER = /(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com/;
|
||||
const RE_TWITTER = /(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?twitter.com/;
|
||||
const RE_TWITTER_EMBED =
|
||||
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:twitter|x)\.com\/[^"']*)/i;
|
||||
/^<blockquote[\s\S]*?\shref=["'](https:\/\/twitter.com\/[^"']*)/i;
|
||||
|
||||
const RE_VALTOWN =
|
||||
/^https:\/\/(?:www\.)?val\.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
|
||||
/^https:\/\/(?:www\.)?val.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
|
||||
|
||||
const RE_GENERIC_EMBED =
|
||||
/^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
|
||||
@@ -68,11 +61,13 @@ const ALLOWED_DOMAINS = new Set([
|
||||
"dddice.com",
|
||||
]);
|
||||
|
||||
const createSrcDoc = (body: string) => {
|
||||
export const createSrcDoc = (body: string) => {
|
||||
return `<html><body>${body}</body></html>`;
|
||||
};
|
||||
|
||||
export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
|
||||
export const getEmbedLink = (
|
||||
link: string | null | undefined,
|
||||
): IframeData | null => {
|
||||
if (!link) {
|
||||
return null;
|
||||
}
|
||||
@@ -105,8 +100,12 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
|
||||
break;
|
||||
}
|
||||
aspectRatio = isPortrait ? { w: 315, h: 560 } : { w: 560, h: 315 };
|
||||
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
|
||||
return { link, aspectRatio, type };
|
||||
embeddedLinkCache.set(originalLink, {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
});
|
||||
return { link, intrinsicSize: aspectRatio, type };
|
||||
}
|
||||
|
||||
const vimeoLink = link.match(RE_VIMEO);
|
||||
@@ -120,8 +119,12 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
|
||||
aspectRatio = { w: 560, h: 315 };
|
||||
//warning deliberately ommited so it is displayed only once per link
|
||||
//same link next time will be served from cache
|
||||
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
|
||||
return { link, aspectRatio, type, warning };
|
||||
embeddedLinkCache.set(originalLink, {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
});
|
||||
return { link, intrinsicSize: aspectRatio, type, warning };
|
||||
}
|
||||
|
||||
const figmaLink = link.match(RE_FIGMA);
|
||||
@@ -131,40 +134,67 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
|
||||
link,
|
||||
)}`;
|
||||
aspectRatio = { w: 550, h: 550 };
|
||||
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
|
||||
return { link, aspectRatio, type };
|
||||
embeddedLinkCache.set(originalLink, {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
});
|
||||
return { link, intrinsicSize: aspectRatio, type };
|
||||
}
|
||||
|
||||
const valLink = link.match(RE_VALTOWN);
|
||||
if (valLink) {
|
||||
link =
|
||||
valLink[1] === "embed" ? valLink[0] : valLink[0].replace("/v", "/embed");
|
||||
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
|
||||
return { link, aspectRatio, type };
|
||||
embeddedLinkCache.set(originalLink, {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
});
|
||||
return { link, intrinsicSize: aspectRatio, type };
|
||||
}
|
||||
|
||||
if (RE_TWITTER.test(link)) {
|
||||
// the embed srcdoc still supports twitter.com domain only
|
||||
link = link.replace(/\bx.com\b/, "twitter.com");
|
||||
|
||||
const ret: EmbeddedLink = {
|
||||
type: "document",
|
||||
srcdoc: (theme: string) =>
|
||||
createSrcDoc(
|
||||
`<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${link}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
|
||||
),
|
||||
aspectRatio: { w: 480, h: 480 },
|
||||
sandbox: { allowSameOrigin: true },
|
||||
};
|
||||
let ret: IframeData;
|
||||
// assume embed code
|
||||
if (/<blockquote/.test(link)) {
|
||||
const srcDoc = createSrcDoc(link);
|
||||
ret = {
|
||||
type: "document",
|
||||
srcdoc: () => srcDoc,
|
||||
intrinsicSize: { w: 480, h: 480 },
|
||||
};
|
||||
// assume regular tweet url
|
||||
} else {
|
||||
ret = {
|
||||
type: "document",
|
||||
srcdoc: (theme: string) =>
|
||||
createSrcDoc(
|
||||
`<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${link}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
|
||||
),
|
||||
intrinsicSize: { w: 480, h: 480 },
|
||||
};
|
||||
}
|
||||
embeddedLinkCache.set(originalLink, ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (RE_GH_GIST.test(link)) {
|
||||
const ret: EmbeddedLink = {
|
||||
type: "document",
|
||||
srcdoc: () =>
|
||||
createSrcDoc(`
|
||||
let ret: IframeData;
|
||||
// assume embed code
|
||||
if (/<script>/.test(link)) {
|
||||
const srcDoc = createSrcDoc(link);
|
||||
ret = {
|
||||
type: "document",
|
||||
srcdoc: () => srcDoc,
|
||||
intrinsicSize: { w: 550, h: 720 },
|
||||
};
|
||||
// assume regular url
|
||||
} else {
|
||||
ret = {
|
||||
type: "document",
|
||||
srcdoc: () =>
|
||||
createSrcDoc(`
|
||||
<script src="${link}.js"></script>
|
||||
<style type="text/css">
|
||||
* { margin: 0px; }
|
||||
@@ -172,25 +202,26 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
|
||||
.gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
|
||||
</style>
|
||||
`),
|
||||
aspectRatio: { w: 550, h: 720 },
|
||||
};
|
||||
intrinsicSize: { w: 550, h: 720 },
|
||||
};
|
||||
}
|
||||
embeddedLinkCache.set(link, ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
embeddedLinkCache.set(link, { link, aspectRatio, type });
|
||||
return { link, aspectRatio, type };
|
||||
embeddedLinkCache.set(link, { link, intrinsicSize: aspectRatio, type });
|
||||
return { link, intrinsicSize: aspectRatio, type };
|
||||
};
|
||||
|
||||
export const isEmbeddableOrLabel = (
|
||||
export const isIframeLikeOrItsLabel = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
): Boolean => {
|
||||
if (isEmbeddableElement(element)) {
|
||||
if (isIframeLikeElement(element)) {
|
||||
return true;
|
||||
}
|
||||
if (element.type === "text") {
|
||||
const container = getContainerElement(element);
|
||||
if (container && isEmbeddableElement(container)) {
|
||||
if (container && isFrameLikeElement(container)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -198,10 +229,16 @@ export const isEmbeddableOrLabel = (
|
||||
};
|
||||
|
||||
export const createPlaceholderEmbeddableLabel = (
|
||||
element: ExcalidrawEmbeddableElement,
|
||||
element: ExcalidrawIframeLikeElement,
|
||||
): ExcalidrawElement => {
|
||||
const text =
|
||||
!element.link || element?.link === "" ? "Empty Web-Embed" : element.link;
|
||||
let text: string;
|
||||
if (isIframeElement(element)) {
|
||||
text = "IFrame element";
|
||||
} else {
|
||||
text =
|
||||
!element.link || element?.link === "" ? "Empty Web-Embed" : element.link;
|
||||
}
|
||||
|
||||
const fontSize = Math.max(
|
||||
Math.min(element.width / 2, element.width / text.length),
|
||||
element.width / 30,
|
||||
@@ -291,8 +328,8 @@ export const extractSrc = (htmlString: string): string => {
|
||||
}
|
||||
|
||||
const gistMatch = htmlString.match(RE_GH_GIST_EMBED);
|
||||
if (gistMatch && gistMatch.length === 3) {
|
||||
return `https://gist.github.com/${gistMatch[1]}/${gistMatch[2]}`;
|
||||
if (gistMatch && gistMatch.length === 2) {
|
||||
return gistMatch[1];
|
||||
}
|
||||
|
||||
if (RE_GIPHY.test(htmlString)) {
|
||||
|
@@ -2,7 +2,6 @@ import {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
ExcalidrawFrameElement,
|
||||
} from "./types";
|
||||
import { isInvisiblySmallElement } from "./sizeHelpers";
|
||||
import { isLinearElementType } from "./typeChecks";
|
||||
@@ -50,11 +49,7 @@ export {
|
||||
getDragOffsetXY,
|
||||
dragNewElement,
|
||||
} from "./dragElements";
|
||||
export {
|
||||
isTextElement,
|
||||
isExcalidrawElement,
|
||||
isFrameElement,
|
||||
} from "./typeChecks";
|
||||
export { isTextElement, isExcalidrawElement } from "./typeChecks";
|
||||
export { textWysiwyg } from "./textWysiwyg";
|
||||
export { redrawTextBoundingBox } from "./textElement";
|
||||
export {
|
||||
@@ -74,17 +69,10 @@ export const getVisibleElements = (elements: readonly ExcalidrawElement[]) =>
|
||||
(el) => !el.isDeleted && !isInvisiblySmallElement(el),
|
||||
) as readonly NonDeletedExcalidrawElement[];
|
||||
|
||||
export const getNonDeletedElements = (elements: readonly ExcalidrawElement[]) =>
|
||||
elements.filter(
|
||||
(element) => !element.isDeleted,
|
||||
) as readonly NonDeletedExcalidrawElement[];
|
||||
|
||||
export const getNonDeletedFrames = (
|
||||
frames: readonly ExcalidrawFrameElement[],
|
||||
export const getNonDeletedElements = <T extends ExcalidrawElement>(
|
||||
elements: readonly T[],
|
||||
) =>
|
||||
frames.filter(
|
||||
(frame) => !frame.isDeleted,
|
||||
) as readonly NonDeleted<ExcalidrawFrameElement>[];
|
||||
elements.filter((element) => !element.isDeleted) as readonly NonDeleted<T>[];
|
||||
|
||||
export const isNonDeletedElement = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
|
@@ -14,6 +14,8 @@ import {
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
ExcalidrawMagicFrameElement,
|
||||
ExcalidrawIframeElement,
|
||||
} from "../element/types";
|
||||
import {
|
||||
arrayToMap,
|
||||
@@ -67,7 +69,6 @@ export type ElementConstructorOpts = MarkOptional<
|
||||
| "roundness"
|
||||
| "locked"
|
||||
| "opacity"
|
||||
| "customData"
|
||||
>;
|
||||
|
||||
const _newElementBase = <T extends ExcalidrawElement>(
|
||||
@@ -121,7 +122,6 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
updated: getUpdatedTimestamp(),
|
||||
link,
|
||||
locked,
|
||||
customData: rest.customData,
|
||||
};
|
||||
return element;
|
||||
};
|
||||
@@ -145,6 +145,16 @@ export const newEmbeddableElement = (
|
||||
};
|
||||
};
|
||||
|
||||
export const newIframeElement = (
|
||||
opts: {
|
||||
type: "iframe";
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawIframeElement> => {
|
||||
return {
|
||||
..._newElementBase<ExcalidrawIframeElement>("iframe", opts),
|
||||
};
|
||||
};
|
||||
|
||||
export const newFrameElement = (
|
||||
opts: {
|
||||
name?: string;
|
||||
@@ -162,6 +172,23 @@ export const newFrameElement = (
|
||||
return frameElement;
|
||||
};
|
||||
|
||||
export const newMagicFrameElement = (
|
||||
opts: {
|
||||
name?: string;
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawMagicFrameElement> => {
|
||||
const frameElement = newElementWith(
|
||||
{
|
||||
..._newElementBase<ExcalidrawMagicFrameElement>("magicframe", opts),
|
||||
type: "magicframe",
|
||||
name: opts?.name || null,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return frameElement;
|
||||
};
|
||||
|
||||
/** computes element x/y offset based on textAlign/verticalAlign */
|
||||
const getTextElementPositionOffsets = (
|
||||
opts: {
|
||||
|
@@ -27,7 +27,7 @@ import {
|
||||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isFrameElement,
|
||||
isFrameLikeElement,
|
||||
isFreeDrawElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
@@ -163,7 +163,7 @@ const rotateSingleElement = (
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
let angle: number;
|
||||
if (isFrameElement(element)) {
|
||||
if (isFrameLikeElement(element)) {
|
||||
angle = 0;
|
||||
} else {
|
||||
angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
|
||||
@@ -900,7 +900,7 @@ const rotateMultipleElements = (
|
||||
}
|
||||
|
||||
elements
|
||||
.filter((element) => element.type !== "frame")
|
||||
.filter((element) => !isFrameLikeElement(element))
|
||||
.forEach((element) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { getFontString, arrayToMap, isTestEnv } from "../utils";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawElementType,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
@@ -867,7 +868,7 @@ const VALID_CONTAINER_TYPES = new Set([
|
||||
]);
|
||||
|
||||
export const isValidTextContainer = (element: {
|
||||
type: ExcalidrawElement["type"];
|
||||
type: ExcalidrawElementType;
|
||||
}) => VALID_CONTAINER_TYPES.has(element.type);
|
||||
|
||||
export const computeContainerDimensionForBoundText = (
|
||||
|
@@ -8,7 +8,7 @@ import { Bounds, getElementAbsoluteCoords } from "./bounds";
|
||||
import { rotate } from "../math";
|
||||
import { InteractiveCanvasAppState, Zoom } from "../types";
|
||||
import { isTextElement } from ".";
|
||||
import { isFrameElement, isLinearElement } from "./typeChecks";
|
||||
import { isFrameLikeElement, isLinearElement } from "./typeChecks";
|
||||
import { DEFAULT_SPACING } from "../renderer/renderScene";
|
||||
|
||||
export type TransformHandleDirection =
|
||||
@@ -257,7 +257,7 @@ export const getTransformHandles = (
|
||||
}
|
||||
} else if (isTextElement(element)) {
|
||||
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
|
||||
} else if (isFrameElement(element)) {
|
||||
} else if (isFrameLikeElement(element)) {
|
||||
omitSides = {
|
||||
rotation: true,
|
||||
};
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { ROUNDNESS } from "../constants";
|
||||
import { AppState } from "../types";
|
||||
import { ElementOrToolType } from "../types";
|
||||
import { MarkNonNullable } from "../utility-types";
|
||||
import { assertNever } from "../utils";
|
||||
import {
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
ExcalidrawEmbeddableElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawGenericElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
InitializedExcalidrawImageElement,
|
||||
ExcalidrawImageElement,
|
||||
@@ -16,21 +15,13 @@ import {
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawFrameElement,
|
||||
RoundnessType,
|
||||
ExcalidrawFrameLikeElement,
|
||||
ExcalidrawElementType,
|
||||
ExcalidrawIframeElement,
|
||||
ExcalidrawIframeLikeElement,
|
||||
ExcalidrawMagicFrameElement,
|
||||
} from "./types";
|
||||
|
||||
export const isGenericElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawGenericElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(element.type === "selection" ||
|
||||
element.type === "rectangle" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "ellipse" ||
|
||||
element.type === "embeddable")
|
||||
);
|
||||
};
|
||||
|
||||
export const isInitializedImageElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is InitializedExcalidrawImageElement => {
|
||||
@@ -49,6 +40,20 @@ export const isEmbeddableElement = (
|
||||
return !!element && element.type === "embeddable";
|
||||
};
|
||||
|
||||
export const isIframeElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawIframeElement => {
|
||||
return !!element && element.type === "iframe";
|
||||
};
|
||||
|
||||
export const isIframeLikeElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawIframeLikeElement => {
|
||||
return (
|
||||
!!element && (element.type === "iframe" || element.type === "embeddable")
|
||||
);
|
||||
};
|
||||
|
||||
export const isTextElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawTextElement => {
|
||||
@@ -61,6 +66,21 @@ export const isFrameElement = (
|
||||
return element != null && element.type === "frame";
|
||||
};
|
||||
|
||||
export const isMagicFrameElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawMagicFrameElement => {
|
||||
return element != null && element.type === "magicframe";
|
||||
};
|
||||
|
||||
export const isFrameLikeElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawFrameLikeElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(element.type === "frame" || element.type === "magicframe")
|
||||
);
|
||||
};
|
||||
|
||||
export const isFreeDrawElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawFreeDrawElement => {
|
||||
@@ -68,7 +88,7 @@ export const isFreeDrawElement = (
|
||||
};
|
||||
|
||||
export const isFreeDrawElementType = (
|
||||
elementType: ExcalidrawElement["type"],
|
||||
elementType: ExcalidrawElementType,
|
||||
): boolean => {
|
||||
return elementType === "freedraw";
|
||||
};
|
||||
@@ -86,7 +106,7 @@ export const isArrowElement = (
|
||||
};
|
||||
|
||||
export const isLinearElementType = (
|
||||
elementType: AppState["activeTool"]["type"],
|
||||
elementType: ElementOrToolType,
|
||||
): boolean => {
|
||||
return (
|
||||
elementType === "arrow" || elementType === "line" // || elementType === "freedraw"
|
||||
@@ -105,7 +125,7 @@ export const isBindingElement = (
|
||||
};
|
||||
|
||||
export const isBindingElementType = (
|
||||
elementType: AppState["activeTool"]["type"],
|
||||
elementType: ElementOrToolType,
|
||||
): boolean => {
|
||||
return elementType === "arrow";
|
||||
};
|
||||
@@ -121,8 +141,10 @@ export const isBindableElement = (
|
||||
element.type === "diamond" ||
|
||||
element.type === "ellipse" ||
|
||||
element.type === "image" ||
|
||||
element.type === "iframe" ||
|
||||
element.type === "embeddable" ||
|
||||
element.type === "frame" ||
|
||||
element.type === "magicframe" ||
|
||||
(element.type === "text" && !element.containerId))
|
||||
);
|
||||
};
|
||||
@@ -144,7 +166,7 @@ export const isTextBindableContainer = (
|
||||
export const isExcalidrawElement = (
|
||||
element: any,
|
||||
): element is ExcalidrawElement => {
|
||||
const type: ExcalidrawElement["type"] | undefined = element?.type;
|
||||
const type: ExcalidrawElementType | undefined = element?.type;
|
||||
if (!type) {
|
||||
return false;
|
||||
}
|
||||
@@ -152,12 +174,14 @@ export const isExcalidrawElement = (
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "rectangle":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "ellipse":
|
||||
case "arrow":
|
||||
case "freedraw":
|
||||
case "line":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
case "image":
|
||||
case "selection": {
|
||||
return true;
|
||||
@@ -190,7 +214,7 @@ export const isBoundToContainer = (
|
||||
};
|
||||
|
||||
export const isUsingAdaptiveRadius = (type: string) =>
|
||||
type === "rectangle" || type === "embeddable";
|
||||
type === "rectangle" || type === "embeddable" || type === "iframe";
|
||||
|
||||
export const isUsingProportionalRadius = (type: string) =>
|
||||
type === "line" || type === "arrow" || type === "diamond";
|
||||
|
@@ -7,6 +7,7 @@ import {
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { MarkNonNullable, ValueOf } from "../utility-types";
|
||||
import { MagicCacheData } from "../data/magic";
|
||||
|
||||
export type ChartType = "bar" | "line";
|
||||
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
|
||||
@@ -97,6 +98,26 @@ export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
|
||||
validated: boolean | null;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawIframeElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "iframe";
|
||||
// TODO move later to AI-specific frame
|
||||
customData?: { generationData?: MagicCacheData };
|
||||
}>;
|
||||
|
||||
export type ExcalidrawIframeLikeElement =
|
||||
| ExcalidrawIframeElement
|
||||
| ExcalidrawEmbeddableElement;
|
||||
|
||||
export type IframeData =
|
||||
| {
|
||||
intrinsicSize: { w: number; h: number };
|
||||
warning?: string;
|
||||
} & (
|
||||
| { type: "video" | "generic"; link: string }
|
||||
| { type: "document"; srcdoc: (theme: Theme) => string }
|
||||
);
|
||||
|
||||
export type ExcalidrawImageElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "image";
|
||||
@@ -117,6 +138,15 @@ export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
|
||||
name: string | null;
|
||||
};
|
||||
|
||||
export type ExcalidrawMagicFrameElement = _ExcalidrawElementBase & {
|
||||
type: "magicframe";
|
||||
name: string | null;
|
||||
};
|
||||
|
||||
export type ExcalidrawFrameLikeElement =
|
||||
| ExcalidrawFrameElement
|
||||
| ExcalidrawMagicFrameElement;
|
||||
|
||||
/**
|
||||
* These are elements that don't have any additional properties.
|
||||
*/
|
||||
@@ -138,6 +168,8 @@ export type ExcalidrawElement =
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawFrameElement
|
||||
| ExcalidrawMagicFrameElement
|
||||
| ExcalidrawIframeElement
|
||||
| ExcalidrawEmbeddableElement;
|
||||
|
||||
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
||||
@@ -170,8 +202,10 @@ export type ExcalidrawBindableElement =
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawIframeElement
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawFrameElement;
|
||||
| ExcalidrawFrameElement
|
||||
| ExcalidrawMagicFrameElement;
|
||||
|
||||
export type ExcalidrawTextContainer =
|
||||
| ExcalidrawRectangleElement
|
||||
@@ -217,3 +251,5 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
|
||||
}>;
|
||||
|
||||
export type FileId = string & { _brand: "FileId" };
|
||||
|
||||
export type ExcalidrawElementType = ExcalidrawElement["type"];
|
||||
|
84
src/frame.ts
84
src/frame.ts
@@ -5,7 +5,7 @@ import {
|
||||
} from "./element";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./element/types";
|
||||
@@ -18,11 +18,11 @@ import { arrayToMap } from "./utils";
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
import { AppClassProperties, AppState, StaticCanvasAppState } from "./types";
|
||||
import { getElementsWithinSelection, getSelectedElements } from "./scene";
|
||||
import { isFrameElement } from "./element";
|
||||
import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups";
|
||||
import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene";
|
||||
import { getElementLineSegments } from "./element/bounds";
|
||||
import { doLineSegmentsIntersect } from "./packages/utils";
|
||||
import { isFrameElement, isFrameLikeElement } from "./element/typeChecks";
|
||||
|
||||
// --------------------------- Frame State ------------------------------------
|
||||
export const bindElementsToFramesAfterDuplication = (
|
||||
@@ -58,7 +58,7 @@ export const bindElementsToFramesAfterDuplication = (
|
||||
|
||||
export function isElementIntersectingFrame(
|
||||
element: ExcalidrawElement,
|
||||
frame: ExcalidrawFrameElement,
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
) {
|
||||
const frameLineSegments = getElementLineSegments(frame);
|
||||
|
||||
@@ -75,20 +75,20 @@ export function isElementIntersectingFrame(
|
||||
|
||||
export const getElementsCompletelyInFrame = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
frame: ExcalidrawFrameElement,
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
) =>
|
||||
omitGroupsContainingFrames(
|
||||
omitGroupsContainingFrameLikes(
|
||||
getElementsWithinSelection(elements, frame, false),
|
||||
).filter(
|
||||
(element) =>
|
||||
(element.type !== "frame" && !element.frameId) ||
|
||||
(!isFrameLikeElement(element) && !element.frameId) ||
|
||||
element.frameId === frame.id,
|
||||
);
|
||||
|
||||
export const isElementContainingFrame = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
element: ExcalidrawElement,
|
||||
frame: ExcalidrawFrameElement,
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
) => {
|
||||
return getElementsWithinSelection(elements, element).some(
|
||||
(e) => e.id === frame.id,
|
||||
@@ -97,12 +97,12 @@ export const isElementContainingFrame = (
|
||||
|
||||
export const getElementsIntersectingFrame = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
frame: ExcalidrawFrameElement,
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
) => elements.filter((element) => isElementIntersectingFrame(element, frame));
|
||||
|
||||
export const elementsAreInFrameBounds = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
frame: ExcalidrawFrameElement,
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
) => {
|
||||
const [selectionX1, selectionY1, selectionX2, selectionY2] =
|
||||
getElementAbsoluteCoords(frame);
|
||||
@@ -120,7 +120,7 @@ export const elementsAreInFrameBounds = (
|
||||
|
||||
export const elementOverlapsWithFrame = (
|
||||
element: ExcalidrawElement,
|
||||
frame: ExcalidrawFrameElement,
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
) => {
|
||||
return (
|
||||
elementsAreInFrameBounds([element], frame) ||
|
||||
@@ -134,7 +134,7 @@ export const isCursorInFrame = (
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
frame: NonDeleted<ExcalidrawFrameElement>,
|
||||
frame: NonDeleted<ExcalidrawFrameLikeElement>,
|
||||
) => {
|
||||
const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame);
|
||||
|
||||
@@ -148,7 +148,7 @@ export const isCursorInFrame = (
|
||||
export const groupsAreAtLeastIntersectingTheFrame = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
groupIds: readonly string[],
|
||||
frame: ExcalidrawFrameElement,
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
) => {
|
||||
const elementsInGroup = groupIds.flatMap((groupId) =>
|
||||
getElementsInGroup(elements, groupId),
|
||||
@@ -168,7 +168,7 @@ export const groupsAreAtLeastIntersectingTheFrame = (
|
||||
export const groupsAreCompletelyOutOfFrame = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
groupIds: readonly string[],
|
||||
frame: ExcalidrawFrameElement,
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
) => {
|
||||
const elementsInGroup = groupIds.flatMap((groupId) =>
|
||||
getElementsInGroup(elements, groupId),
|
||||
@@ -192,14 +192,14 @@ export const groupsAreCompletelyOutOfFrame = (
|
||||
/**
|
||||
* Returns a map of frameId to frame elements. Includes empty frames.
|
||||
*/
|
||||
export const groupByFrames = (elements: readonly ExcalidrawElement[]) => {
|
||||
export const groupByFrameLikes = (elements: readonly ExcalidrawElement[]) => {
|
||||
const frameElementsMap = new Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement[]
|
||||
>();
|
||||
|
||||
for (const element of elements) {
|
||||
const frameId = isFrameElement(element) ? element.id : element.frameId;
|
||||
const frameId = isFrameLikeElement(element) ? element.id : element.frameId;
|
||||
if (frameId && !frameElementsMap.has(frameId)) {
|
||||
frameElementsMap.set(frameId, getFrameChildren(elements, frameId));
|
||||
}
|
||||
@@ -213,12 +213,12 @@ export const getFrameChildren = (
|
||||
frameId: string,
|
||||
) => allElements.filter((element) => element.frameId === frameId);
|
||||
|
||||
export const getFrameElements = (
|
||||
export const getFrameLikeElements = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
): ExcalidrawFrameElement[] => {
|
||||
return allElements.filter((element) =>
|
||||
isFrameElement(element),
|
||||
) as ExcalidrawFrameElement[];
|
||||
): ExcalidrawFrameLikeElement[] => {
|
||||
return allElements.filter((element): element is ExcalidrawFrameLikeElement =>
|
||||
isFrameLikeElement(element),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -232,7 +232,7 @@ export const getFrameElements = (
|
||||
export const getRootElements = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
) => {
|
||||
const frameElements = arrayToMap(getFrameElements(allElements));
|
||||
const frameElements = arrayToMap(getFrameLikeElements(allElements));
|
||||
return allElements.filter(
|
||||
(element) =>
|
||||
frameElements.has(element.id) ||
|
||||
@@ -243,7 +243,7 @@ export const getRootElements = (
|
||||
|
||||
export const getElementsInResizingFrame = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
frame: ExcalidrawFrameElement,
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
appState: AppState,
|
||||
): ExcalidrawElement[] => {
|
||||
const prevElementsInFrame = getFrameChildren(allElements, frame.id);
|
||||
@@ -336,9 +336,9 @@ export const getElementsInResizingFrame = (
|
||||
|
||||
export const getElementsInNewFrame = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
frame: ExcalidrawFrameElement,
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
) => {
|
||||
return omitGroupsContainingFrames(
|
||||
return omitGroupsContainingFrameLikes(
|
||||
allElements,
|
||||
getElementsCompletelyInFrame(allElements, frame),
|
||||
);
|
||||
@@ -356,12 +356,12 @@ export const getContainingFrame = (
|
||||
if (element.frameId) {
|
||||
if (elementsMap) {
|
||||
return (elementsMap.get(element.frameId) ||
|
||||
null) as null | ExcalidrawFrameElement;
|
||||
null) as null | ExcalidrawFrameLikeElement;
|
||||
}
|
||||
return (
|
||||
(Scene.getScene(element)?.getElement(
|
||||
element.frameId,
|
||||
) as ExcalidrawFrameElement) || null
|
||||
) as ExcalidrawFrameLikeElement) || null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -377,7 +377,7 @@ export const getContainingFrame = (
|
||||
export const addElementsToFrame = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
elementsToAdd: NonDeletedExcalidrawElement[],
|
||||
frame: ExcalidrawFrameElement,
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
) => {
|
||||
const { currTargetFrameChildrenMap } = allElements.reduce(
|
||||
(acc, element, index) => {
|
||||
@@ -397,7 +397,7 @@ export const addElementsToFrame = (
|
||||
|
||||
// - add bound text elements if not already in the array
|
||||
// - filter out elements that are already in the frame
|
||||
for (const element of omitGroupsContainingFrames(
|
||||
for (const element of omitGroupsContainingFrameLikes(
|
||||
allElements,
|
||||
elementsToAdd,
|
||||
)) {
|
||||
@@ -438,7 +438,7 @@ export const removeElementsFromFrame = (
|
||||
>();
|
||||
|
||||
const toRemoveElementsByFrame = new Map<
|
||||
ExcalidrawFrameElement["id"],
|
||||
ExcalidrawFrameLikeElement["id"],
|
||||
ExcalidrawElement[]
|
||||
>();
|
||||
|
||||
@@ -474,7 +474,7 @@ export const removeElementsFromFrame = (
|
||||
|
||||
export const removeAllElementsFromFrame = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
frame: ExcalidrawFrameElement,
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
appState: AppState,
|
||||
) => {
|
||||
const elementsInFrame = getFrameChildren(allElements, frame.id);
|
||||
@@ -484,7 +484,7 @@ export const removeAllElementsFromFrame = (
|
||||
export const replaceAllElementsInFrame = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
nextElementsInFrame: ExcalidrawElement[],
|
||||
frame: ExcalidrawFrameElement,
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
appState: AppState,
|
||||
) => {
|
||||
return addElementsToFrame(
|
||||
@@ -524,7 +524,7 @@ export const updateFrameMembershipOfSelectedElements = (
|
||||
elementsToFilter.forEach((element) => {
|
||||
if (
|
||||
element.frameId &&
|
||||
!isFrameElement(element) &&
|
||||
!isFrameLikeElement(element) &&
|
||||
!isElementInFrame(element, allElements, appState)
|
||||
) {
|
||||
elementsToRemove.add(element);
|
||||
@@ -540,7 +540,7 @@ export const updateFrameMembershipOfSelectedElements = (
|
||||
* filters out elements that are inside groups that contain a frame element
|
||||
* anywhere in the group tree
|
||||
*/
|
||||
export const omitGroupsContainingFrames = (
|
||||
export const omitGroupsContainingFrameLikes = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
/** subset of elements you want to filter. Optional perf optimization so we
|
||||
* don't have to filter all elements unnecessarily
|
||||
@@ -558,7 +558,9 @@ export const omitGroupsContainingFrames = (
|
||||
const rejectedGroupIds = new Set<string>();
|
||||
for (const groupId of uniqueGroupIds) {
|
||||
if (
|
||||
getElementsInGroup(allElements, groupId).some((el) => isFrameElement(el))
|
||||
getElementsInGroup(allElements, groupId).some((el) =>
|
||||
isFrameLikeElement(el),
|
||||
)
|
||||
) {
|
||||
rejectedGroupIds.add(groupId);
|
||||
}
|
||||
@@ -636,7 +638,7 @@ export const isElementInFrame = (
|
||||
}
|
||||
|
||||
for (const elementInGroup of allElementsInGroup) {
|
||||
if (isFrameElement(elementInGroup)) {
|
||||
if (isFrameLikeElement(elementInGroup)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -650,3 +652,15 @@ export const isElementInFrame = (
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getFrameLikeTitle = (
|
||||
element: ExcalidrawFrameLikeElement,
|
||||
frameIdx: number,
|
||||
) => {
|
||||
const existingName = element.name?.trim();
|
||||
if (existingName) {
|
||||
return existingName;
|
||||
}
|
||||
// TODO name frames AI only is specific to AI frames
|
||||
return isFrameElement(element) ? `Frame ${frameIdx}` : `AI Frame ${frameIdx}`;
|
||||
};
|
||||
|
@@ -11,6 +11,8 @@
|
||||
"copyAsPng": "Copy to clipboard as PNG",
|
||||
"copyAsSvg": "Copy to clipboard as SVG",
|
||||
"copyText": "Copy to clipboard as text",
|
||||
"copySource": "Copy source to clipboard",
|
||||
"convertToCode": "Convert to code",
|
||||
"bringForward": "Bring forward",
|
||||
"sendToBack": "Send to back",
|
||||
"bringToFront": "Bring to front",
|
||||
@@ -130,7 +132,10 @@
|
||||
"sidebarLock": "Keep sidebar open",
|
||||
"selectAllElementsInFrame": "Select all elements in frame",
|
||||
"removeAllElementsFromFrame": "Remove all elements from frame",
|
||||
"eyeDropper": "Pick color from canvas"
|
||||
"eyeDropper": "Pick color from canvas",
|
||||
"textToDiagram": "Text to diagram",
|
||||
"prompt": "Prompt",
|
||||
"textToDrawing": "Text to drawing"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "No items added yet...",
|
||||
@@ -218,6 +223,7 @@
|
||||
},
|
||||
"libraryElementTypeError": {
|
||||
"embeddable": "Embeddable elements cannot be added to the library.",
|
||||
"iframe": "IFrame elements cannot be added to the library.",
|
||||
"image": "Support for adding images to the library coming soon!"
|
||||
},
|
||||
"asyncPasteFailedOnRead": "Couldn't paste (couldn't read from system clipboard).",
|
||||
@@ -240,11 +246,13 @@
|
||||
"link": "Add/ Update link for a selected shape",
|
||||
"eraser": "Eraser",
|
||||
"frame": "Frame tool",
|
||||
"magicframe": "Wireframe to code",
|
||||
"embeddable": "Web Embed",
|
||||
"laser": "Laser pointer",
|
||||
"hand": "Hand (panning tool)",
|
||||
"extraTools": "More tools",
|
||||
"mermaidToExcalidraw": "Mermaid to Excalidraw"
|
||||
"mermaidToExcalidraw": "Mermaid to Excalidraw",
|
||||
"magicSettings": "AI settings"
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "Canvas actions",
|
||||
|
@@ -13,52 +13,8 @@ Please add the latest change on the top under the correct section.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Features
|
||||
|
||||
- Add `onPointerUp` prop [#7638](https://github.com/excalidraw/excalidraw/pull/7638).
|
||||
|
||||
- Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Keep customData when converting to ExcalidrawElement. [#7656](https://github.com/excalidraw/excalidraw/pull/7656)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
|
||||
|
||||
- Create an `ESM` build for `@excalidraw/excalidraw`. The API is in progress and subject to change before stable release. There are some changes on how the package will be consumed
|
||||
|
||||
#### Bundler
|
||||
|
||||
- CSS needs to be imported so you will need to import the css along with the excalidraw component
|
||||
|
||||
```js
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
```
|
||||
|
||||
- The `types` path is updated
|
||||
|
||||
Instead of importing from `@excalidraw/excalidraw/types/`, you will need to import from `@excalidraw/excalidraw/dist/excalidraw` or `@excalidraw/excalidraw/dist/utils` depending on the types you are using.
|
||||
|
||||
However this we will be fixing before stable release, so in case you want to try it out you will need to update the types for now.
|
||||
|
||||
#### Browser
|
||||
|
||||
- Since its `ESM` so now script type `module` can be used to load it and css needs to be loaded as well.
|
||||
|
||||
```html
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/@excalidraw/excalidraw@next/dist/browser/dev/index.css"
|
||||
/>
|
||||
<script type="module">
|
||||
import * as ExcalidrawLib from "https://unpkg.com/@excalidraw/excalidraw@next/dist/browser/dev/index.js";
|
||||
window.ExcalidrawLib = ExcalidrawLib;
|
||||
</script>
|
||||
```
|
||||
|
||||
- `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336)
|
||||
|
||||
## 0.17.1 (2023-11-28)
|
||||
@@ -73,9 +29,13 @@ define: {
|
||||
}
|
||||
```
|
||||
|
||||
### Breaking Changes
|
||||
## Excalidraw Library
|
||||
|
||||
- `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336)
|
||||
### Fixes
|
||||
|
||||
- Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343)
|
||||
|
||||
---
|
||||
|
||||
## 0.17.0 (2023-11-14)
|
||||
|
||||
|
@@ -76,6 +76,8 @@ const {
|
||||
MainMenu,
|
||||
LiveCollaborationTrigger,
|
||||
convertToExcalidrawElements,
|
||||
TTDDialog,
|
||||
TTDDialogTrigger,
|
||||
} = window.ExcalidrawLib;
|
||||
|
||||
const COMMENT_ICON_DIMENSION = 32;
|
||||
@@ -681,7 +683,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
||||
}
|
||||
initialData={initialStatePromiseRef.current.promise}
|
||||
onChange={(elements, state) => {
|
||||
console.info("Elements :", elements, "State : ", state);
|
||||
// console.info("Elements :", elements, "State : ", state);
|
||||
}}
|
||||
onPointerUpdate={(payload: {
|
||||
pointer: { x: number; y: number };
|
||||
@@ -737,6 +739,20 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
||||
Toggle Custom Sidebar
|
||||
</Sidebar.Trigger>
|
||||
{renderMenu()}
|
||||
{excalidrawAPI && (
|
||||
<TTDDialogTrigger icon={<span>😀</span>}>
|
||||
Text to diagram
|
||||
</TTDDialogTrigger>
|
||||
)}
|
||||
<TTDDialog
|
||||
onTextSubmit={async (_) => {
|
||||
console.info("submit");
|
||||
// sleep for 2s
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
throw new Error("error, go away now");
|
||||
// return "dummy";
|
||||
}}
|
||||
/>
|
||||
</Excalidraw>
|
||||
{Object.keys(commentIcons || []).length > 0 && renderCommentIcons()}
|
||||
{comment && renderComment()}
|
||||
|
@@ -44,6 +44,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
children,
|
||||
validateEmbeddable,
|
||||
renderEmbeddable,
|
||||
aiEnabled,
|
||||
} = props;
|
||||
|
||||
const canvasActions = props.UIOptions?.canvasActions;
|
||||
@@ -122,6 +123,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
onScrollChange={onScrollChange}
|
||||
validateEmbeddable={validateEmbeddable}
|
||||
renderEmbeddable={renderEmbeddable}
|
||||
aiEnabled={aiEnabled !== false}
|
||||
>
|
||||
{children}
|
||||
</App>
|
||||
@@ -244,6 +246,8 @@ export { WelcomeScreen };
|
||||
export { LiveCollaborationTrigger };
|
||||
|
||||
export { DefaultSidebar } from "../../components/DefaultSidebar";
|
||||
export { TTDDialog } from "../../components/TTDDialog/TTDDialog";
|
||||
export { TTDDialogTrigger } from "../../components/TTDDialog/TTDDialogTrigger";
|
||||
|
||||
export { normalizeLink } from "../../data/url";
|
||||
export { convertToExcalidrawElements } from "../../data/transform";
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@excalidraw/excalidraw",
|
||||
"version": "0.17.4",
|
||||
"version": "0.17.1",
|
||||
"main": "main.js",
|
||||
"types": "types/packages/excalidraw/index.d.ts",
|
||||
"files": [
|
||||
|
@@ -6,7 +6,7 @@ import { getDefaultAppState } from "../appState";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
NonDeleted,
|
||||
} from "../element/types";
|
||||
import { restore } from "../data/restore";
|
||||
@@ -26,7 +26,7 @@ type ExportOpts = {
|
||||
appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
|
||||
files: BinaryFiles | null;
|
||||
maxWidthOrHeight?: number;
|
||||
exportingFrame?: ExcalidrawFrameElement | null;
|
||||
exportingFrame?: ExcalidrawFrameLikeElement | null;
|
||||
getDimensions?: (
|
||||
width: number,
|
||||
height: number,
|
||||
|
@@ -13,6 +13,7 @@ import {
|
||||
isInitializedImageElement,
|
||||
isArrowElement,
|
||||
hasBoundTextElement,
|
||||
isMagicFrameElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getElementAbsoluteCoords } from "../element/bounds";
|
||||
import type { RoughCanvas } from "roughjs/bin/canvas";
|
||||
@@ -272,6 +273,7 @@ const drawElementOnCanvas = (
|
||||
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "diamond":
|
||||
case "ellipse": {
|
||||
@@ -594,6 +596,7 @@ export const renderElement = (
|
||||
appState: StaticCanvasAppState,
|
||||
) => {
|
||||
switch (element.type) {
|
||||
case "magicframe":
|
||||
case "frame": {
|
||||
if (appState.frameRendering.enabled && appState.frameRendering.outline) {
|
||||
context.save();
|
||||
@@ -606,6 +609,12 @@ export const renderElement = (
|
||||
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
|
||||
context.strokeStyle = FRAME_STYLE.strokeColor;
|
||||
|
||||
// TODO change later to only affect AI frames
|
||||
if (isMagicFrameElement(element)) {
|
||||
context.strokeStyle =
|
||||
appState.theme === "light" ? "#7affd7" : "#1d8264";
|
||||
}
|
||||
|
||||
if (FRAME_STYLE.radius && context.roundRect) {
|
||||
context.beginPath();
|
||||
context.roundRect(
|
||||
@@ -666,6 +675,7 @@ export const renderElement = (
|
||||
case "arrow":
|
||||
case "image":
|
||||
case "text":
|
||||
case "iframe":
|
||||
case "embeddable": {
|
||||
// TODO investigate if we can do this in situ. Right now we need to call
|
||||
// beforehand because math helpers (such as getElementAbsoluteCoords)
|
||||
@@ -951,6 +961,7 @@ export const renderElementToSvg = (
|
||||
addToRoot(g || node, element);
|
||||
break;
|
||||
}
|
||||
case "iframe":
|
||||
case "embeddable": {
|
||||
// render placeholder rectangle
|
||||
const shape = ShapeCache.generateElementShape(element, true);
|
||||
@@ -1252,7 +1263,8 @@ export const renderElementToSvg = (
|
||||
break;
|
||||
}
|
||||
// frames are not rendered and only acts as a container
|
||||
case "frame": {
|
||||
case "frame":
|
||||
case "magicframe": {
|
||||
if (
|
||||
renderConfig.frameRendering.enabled &&
|
||||
renderConfig.frameRendering.outline
|
||||
|
@@ -16,7 +16,7 @@ import {
|
||||
NonDeleted,
|
||||
GroupId,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
} from "../element/types";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
@@ -70,11 +70,12 @@ import {
|
||||
import { renderSnaps } from "./renderSnaps";
|
||||
import {
|
||||
isEmbeddableElement,
|
||||
isFrameElement,
|
||||
isFrameLikeElement,
|
||||
isIframeLikeElement,
|
||||
isLinearElement,
|
||||
} from "../element/typeChecks";
|
||||
import {
|
||||
isEmbeddableOrLabel,
|
||||
isIframeLikeOrItsLabel,
|
||||
createPlaceholderEmbeddableLabel,
|
||||
} from "../element/embeddable";
|
||||
import {
|
||||
@@ -362,7 +363,7 @@ const renderLinearElementPointHighlight = (
|
||||
};
|
||||
|
||||
const frameClip = (
|
||||
frame: ExcalidrawFrameElement,
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
@@ -515,7 +516,7 @@ const _renderInteractiveScene = ({
|
||||
}
|
||||
|
||||
const isFrameSelected = selectedElements.some((element) =>
|
||||
isFrameElement(element),
|
||||
isFrameLikeElement(element),
|
||||
);
|
||||
|
||||
// Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
|
||||
@@ -963,7 +964,7 @@ const _renderStaticScene = ({
|
||||
|
||||
// Paint visible elements
|
||||
visibleElements
|
||||
.filter((el) => !isEmbeddableOrLabel(el))
|
||||
.filter((el) => !isIframeLikeOrItsLabel(el))
|
||||
.forEach((element) => {
|
||||
try {
|
||||
const frameId = element.frameId || appState.frameToHighlight?.id;
|
||||
@@ -996,15 +997,16 @@ const _renderStaticScene = ({
|
||||
|
||||
// render embeddables on top
|
||||
visibleElements
|
||||
.filter((el) => isEmbeddableOrLabel(el))
|
||||
.filter((el) => isIframeLikeOrItsLabel(el))
|
||||
.forEach((element) => {
|
||||
try {
|
||||
const render = () => {
|
||||
renderElement(element, rc, context, renderConfig, appState);
|
||||
|
||||
if (
|
||||
isEmbeddableElement(element) &&
|
||||
(isExporting || !element.validated) &&
|
||||
isIframeLikeElement(element) &&
|
||||
(isExporting ||
|
||||
(isEmbeddableElement(element) && !element.validated)) &&
|
||||
element.width &&
|
||||
element.height
|
||||
) {
|
||||
@@ -1242,8 +1244,10 @@ const renderBindingHighlightForBindableElement = (
|
||||
case "rectangle":
|
||||
case "text":
|
||||
case "image":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
strokeRectWithRotation(
|
||||
context,
|
||||
x1 - padding,
|
||||
@@ -1284,7 +1288,7 @@ const renderBindingHighlightForBindableElement = (
|
||||
const renderFrameHighlight = (
|
||||
context: CanvasRenderingContext2D,
|
||||
appState: InteractiveCanvasAppState,
|
||||
frame: NonDeleted<ExcalidrawFrameElement>,
|
||||
frame: NonDeleted<ExcalidrawFrameLikeElement>,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame);
|
||||
const width = x2 - x1;
|
||||
@@ -1469,7 +1473,7 @@ export const renderSceneToSvg = (
|
||||
};
|
||||
// render elements
|
||||
elements
|
||||
.filter((el) => !isEmbeddableOrLabel(el))
|
||||
.filter((el) => !isIframeLikeOrItsLabel(el))
|
||||
.forEach((element) => {
|
||||
if (!element.isDeleted) {
|
||||
try {
|
||||
@@ -1490,7 +1494,7 @@ export const renderSceneToSvg = (
|
||||
|
||||
// render embeddables on top
|
||||
elements
|
||||
.filter((el) => isEmbeddableElement(el))
|
||||
.filter((el) => isIframeLikeElement(el))
|
||||
.forEach((element) => {
|
||||
if (!element.isDeleted) {
|
||||
try {
|
||||
|
@@ -2,15 +2,11 @@ import {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
} from "../element/types";
|
||||
import {
|
||||
getNonDeletedElements,
|
||||
getNonDeletedFrames,
|
||||
isNonDeletedElement,
|
||||
} from "../element";
|
||||
import { getNonDeletedElements, isNonDeletedElement } from "../element";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { isFrameElement } from "../element/typeChecks";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { getSelectedElements } from "./selection";
|
||||
import { AppState } from "../types";
|
||||
import { Assert, SameType } from "../utility-types";
|
||||
@@ -107,8 +103,9 @@ class Scene {
|
||||
|
||||
private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
|
||||
private elements: readonly ExcalidrawElement[] = [];
|
||||
private nonDeletedFrames: readonly NonDeleted<ExcalidrawFrameElement>[] = [];
|
||||
private frames: readonly ExcalidrawFrameElement[] = [];
|
||||
private nonDeletedFramesLikes: readonly NonDeleted<ExcalidrawFrameLikeElement>[] =
|
||||
[];
|
||||
private frames: readonly ExcalidrawFrameLikeElement[] = [];
|
||||
private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
|
||||
private selectedElementsCache: {
|
||||
selectedElementIds: AppState["selectedElementIds"] | null;
|
||||
@@ -179,8 +176,8 @@ class Scene {
|
||||
return selectedElements;
|
||||
}
|
||||
|
||||
getNonDeletedFrames(): readonly NonDeleted<ExcalidrawFrameElement>[] {
|
||||
return this.nonDeletedFrames;
|
||||
getNonDeletedFramesLikes(): readonly NonDeleted<ExcalidrawFrameLikeElement>[] {
|
||||
return this.nonDeletedFramesLikes;
|
||||
}
|
||||
|
||||
getElement<T extends ExcalidrawElement>(id: T["id"]): T | null {
|
||||
@@ -235,18 +232,18 @@ class Scene {
|
||||
mapElementIds = true,
|
||||
) {
|
||||
this.elements = nextElements;
|
||||
const nextFrames: ExcalidrawFrameElement[] = [];
|
||||
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
||||
this.elementsMap.clear();
|
||||
nextElements.forEach((element) => {
|
||||
if (isFrameElement(element)) {
|
||||
nextFrames.push(element);
|
||||
if (isFrameLikeElement(element)) {
|
||||
nextFrameLikes.push(element);
|
||||
}
|
||||
this.elementsMap.set(element.id, element);
|
||||
Scene.mapElementToScene(element, this);
|
||||
});
|
||||
this.nonDeletedElements = getNonDeletedElements(this.elements);
|
||||
this.frames = nextFrames;
|
||||
this.nonDeletedFrames = getNonDeletedFrames(this.frames);
|
||||
this.frames = nextFrameLikes;
|
||||
this.nonDeletedFramesLikes = getNonDeletedElements(this.frames);
|
||||
|
||||
this.informMutation();
|
||||
}
|
||||
@@ -277,7 +274,7 @@ class Scene {
|
||||
destroy() {
|
||||
this.nonDeletedElements = [];
|
||||
this.elements = [];
|
||||
this.nonDeletedFrames = [];
|
||||
this.nonDeletedFramesLikes = [];
|
||||
this.frames = [];
|
||||
this.elementsMap.clear();
|
||||
this.selectedElementsCache.selectedElementIds = null;
|
||||
|
@@ -14,7 +14,12 @@ import { generateFreeDrawShape } from "../renderer/renderElement";
|
||||
import { isTransparent, assertNever } from "../utils";
|
||||
import { simplify } from "points-on-curve";
|
||||
import { ROUGHNESS } from "../constants";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import {
|
||||
isEmbeddableElement,
|
||||
isIframeElement,
|
||||
isIframeLikeElement,
|
||||
isLinearElement,
|
||||
} from "../element/typeChecks";
|
||||
import { canChangeRoundness } from "./comparisons";
|
||||
|
||||
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
||||
@@ -78,6 +83,7 @@ export const generateRoughOptions = (
|
||||
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "diamond":
|
||||
case "ellipse": {
|
||||
@@ -109,13 +115,13 @@ export const generateRoughOptions = (
|
||||
}
|
||||
};
|
||||
|
||||
const modifyEmbeddableForRoughOptions = (
|
||||
const modifyIframeLikeForRoughOptions = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
isExporting: boolean,
|
||||
) => {
|
||||
if (
|
||||
element.type === "embeddable" &&
|
||||
(isExporting || !element.validated) &&
|
||||
isIframeLikeElement(element) &&
|
||||
(isExporting || (isEmbeddableElement(element) && !element.validated)) &&
|
||||
isTransparent(element.backgroundColor) &&
|
||||
isTransparent(element.strokeColor)
|
||||
) {
|
||||
@@ -125,6 +131,16 @@ const modifyEmbeddableForRoughOptions = (
|
||||
backgroundColor: "#d3d3d3",
|
||||
fillStyle: "solid",
|
||||
} as const;
|
||||
} else if (isIframeElement(element)) {
|
||||
return {
|
||||
...element,
|
||||
strokeColor: isTransparent(element.strokeColor)
|
||||
? "#000000"
|
||||
: element.strokeColor,
|
||||
backgroundColor: isTransparent(element.backgroundColor)
|
||||
? "#f4f4f6"
|
||||
: element.backgroundColor,
|
||||
};
|
||||
}
|
||||
return element;
|
||||
};
|
||||
@@ -143,6 +159,7 @@ export const _generateElementShape = (
|
||||
): Drawable | Drawable[] | null => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "iframe":
|
||||
case "embeddable": {
|
||||
let shape: ElementShapes[typeof element.type];
|
||||
// this is for rendering the stroke/bg of the embeddable, especially
|
||||
@@ -159,7 +176,7 @@ export const _generateElementShape = (
|
||||
h - r
|
||||
} L 0 ${r} Q 0 0, ${r} 0`,
|
||||
generateRoughOptions(
|
||||
modifyEmbeddableForRoughOptions(element, isExporting),
|
||||
modifyIframeLikeForRoughOptions(element, isExporting),
|
||||
true,
|
||||
),
|
||||
);
|
||||
@@ -170,7 +187,7 @@ export const _generateElementShape = (
|
||||
element.width,
|
||||
element.height,
|
||||
generateRoughOptions(
|
||||
modifyEmbeddableForRoughOptions(element, isExporting),
|
||||
modifyIframeLikeForRoughOptions(element, isExporting),
|
||||
false,
|
||||
),
|
||||
);
|
||||
@@ -373,6 +390,7 @@ export const _generateElementShape = (
|
||||
return shape;
|
||||
}
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
case "text":
|
||||
case "image": {
|
||||
const shape: ElementShapes[typeof element.type] = null;
|
||||
|
@@ -1,22 +1,25 @@
|
||||
import { isEmbeddableElement } from "../element/typeChecks";
|
||||
import { isIframeElement } from "../element/typeChecks";
|
||||
import {
|
||||
ExcalidrawEmbeddableElement,
|
||||
ExcalidrawIframeElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import { ElementOrToolType } from "../types";
|
||||
|
||||
export const hasBackground = (type: string) =>
|
||||
export const hasBackground = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
type === "iframe" ||
|
||||
type === "embeddable" ||
|
||||
type === "ellipse" ||
|
||||
type === "diamond" ||
|
||||
type === "line" ||
|
||||
type === "freedraw";
|
||||
|
||||
export const hasStrokeColor = (type: string) =>
|
||||
type !== "image" && type !== "frame";
|
||||
export const hasStrokeColor = (type: ElementOrToolType) =>
|
||||
type !== "image" && type !== "frame" && type !== "magicframe";
|
||||
|
||||
export const hasStrokeWidth = (type: string) =>
|
||||
export const hasStrokeWidth = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
type === "iframe" ||
|
||||
type === "embeddable" ||
|
||||
type === "ellipse" ||
|
||||
type === "diamond" ||
|
||||
@@ -24,22 +27,24 @@ export const hasStrokeWidth = (type: string) =>
|
||||
type === "arrow" ||
|
||||
type === "line";
|
||||
|
||||
export const hasStrokeStyle = (type: string) =>
|
||||
export const hasStrokeStyle = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
type === "iframe" ||
|
||||
type === "embeddable" ||
|
||||
type === "ellipse" ||
|
||||
type === "diamond" ||
|
||||
type === "arrow" ||
|
||||
type === "line";
|
||||
|
||||
export const canChangeRoundness = (type: string) =>
|
||||
export const canChangeRoundness = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
type === "iframe" ||
|
||||
type === "embeddable" ||
|
||||
type === "arrow" ||
|
||||
type === "line" ||
|
||||
type === "diamond";
|
||||
|
||||
export const canHaveArrowheads = (type: string) => type === "arrow";
|
||||
export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow";
|
||||
|
||||
export const getElementAtPosition = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
@@ -67,7 +72,7 @@ export const getElementsAtPosition = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
|
||||
) => {
|
||||
const embeddables: ExcalidrawEmbeddableElement[] = [];
|
||||
const iframeLikes: ExcalidrawIframeElement[] = [];
|
||||
// The parameter elements comes ordered from lower z-index to higher.
|
||||
// We want to preserve that order on the returned array.
|
||||
// Exception being embeddables which should be on top of everything else in
|
||||
@@ -75,13 +80,13 @@ export const getElementsAtPosition = (
|
||||
const elsAtPos = elements.filter((element) => {
|
||||
const hit = !element.isDeleted && isAtPositionFn(element);
|
||||
if (hit) {
|
||||
if (isEmbeddableElement(element)) {
|
||||
embeddables.push(element);
|
||||
if (isIframeElement(element)) {
|
||||
iframeLikes.push(element);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return elsAtPos.concat(embeddables);
|
||||
return elsAtPos.concat(iframeLikes);
|
||||
};
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import rough from "roughjs/bin/rough";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
ExcalidrawTextElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
@@ -27,11 +27,16 @@ import {
|
||||
updateImageCache,
|
||||
} from "../element/image";
|
||||
import { elementsOverlappingBBox } from "../packages/withinBounds";
|
||||
import { getFrameElements, getRootElements } from "../frame";
|
||||
import { isFrameElement, newTextElement } from "../element";
|
||||
import {
|
||||
getFrameLikeElements,
|
||||
getFrameLikeTitle,
|
||||
getRootElements,
|
||||
} from "../frame";
|
||||
import { newTextElement } from "../element";
|
||||
import { Mutable } from "../utility-types";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import Scene from "./Scene";
|
||||
import { isFrameElement, isFrameLikeElement } from "../element/typeChecks";
|
||||
|
||||
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
||||
|
||||
@@ -100,10 +105,15 @@ const addFrameLabelsAsTextElements = (
|
||||
opts: Pick<AppState, "exportWithDarkMode">,
|
||||
) => {
|
||||
const nextElements: NonDeletedExcalidrawElement[] = [];
|
||||
let frameIdx = 0;
|
||||
let frameIndex = 0;
|
||||
let magicFrameIndex = 0;
|
||||
for (const element of elements) {
|
||||
if (isFrameElement(element)) {
|
||||
frameIdx++;
|
||||
if (isFrameLikeElement(element)) {
|
||||
if (isFrameElement(element)) {
|
||||
frameIndex++;
|
||||
} else {
|
||||
magicFrameIndex++;
|
||||
}
|
||||
let textElement: Mutable<ExcalidrawTextElement> = newTextElement({
|
||||
x: element.x,
|
||||
y: element.y - FRAME_STYLE.nameOffsetY,
|
||||
@@ -114,7 +124,10 @@ const addFrameLabelsAsTextElements = (
|
||||
strokeColor: opts.exportWithDarkMode
|
||||
? FRAME_STYLE.nameColorDarkTheme
|
||||
: FRAME_STYLE.nameColorLightTheme,
|
||||
text: element.name || `Frame ${frameIdx}`,
|
||||
text: getFrameLikeTitle(
|
||||
element,
|
||||
isFrameElement(element) ? frameIndex : magicFrameIndex,
|
||||
),
|
||||
});
|
||||
textElement.y -= textElement.height;
|
||||
|
||||
@@ -129,7 +142,7 @@ const addFrameLabelsAsTextElements = (
|
||||
};
|
||||
|
||||
const getFrameRenderingConfig = (
|
||||
exportingFrame: ExcalidrawFrameElement | null,
|
||||
exportingFrame: ExcalidrawFrameLikeElement | null,
|
||||
frameRendering: AppState["frameRendering"] | null,
|
||||
): AppState["frameRendering"] => {
|
||||
frameRendering = frameRendering || getDefaultAppState().frameRendering;
|
||||
@@ -148,7 +161,7 @@ const prepareElementsForRender = ({
|
||||
exportWithDarkMode,
|
||||
}: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
exportingFrame: ExcalidrawFrameElement | null | undefined;
|
||||
exportingFrame: ExcalidrawFrameLikeElement | null | undefined;
|
||||
frameRendering: AppState["frameRendering"];
|
||||
exportWithDarkMode: AppState["exportWithDarkMode"];
|
||||
}) => {
|
||||
@@ -184,7 +197,7 @@ export const exportToCanvas = async (
|
||||
exportBackground: boolean;
|
||||
exportPadding?: number;
|
||||
viewBackgroundColor: string;
|
||||
exportingFrame?: ExcalidrawFrameElement | null;
|
||||
exportingFrame?: ExcalidrawFrameLikeElement | null;
|
||||
},
|
||||
createCanvas: (
|
||||
width: number,
|
||||
@@ -274,7 +287,7 @@ export const exportToSvg = async (
|
||||
files: BinaryFiles | null,
|
||||
opts?: {
|
||||
renderEmbeddables?: boolean;
|
||||
exportingFrame?: ExcalidrawFrameElement | null;
|
||||
exportingFrame?: ExcalidrawFrameLikeElement | null;
|
||||
},
|
||||
): Promise<SVGSVGElement> => {
|
||||
const tempScene = __createSceneForElementsHack__(elements);
|
||||
@@ -360,7 +373,7 @@ export const exportToSvg = async (
|
||||
const offsetX = -minX + exportPadding;
|
||||
const offsetY = -minY + exportPadding;
|
||||
|
||||
const frameElements = getFrameElements(elements);
|
||||
const frameElements = getFrameLikeElements(elements);
|
||||
|
||||
let exportingFrameClipPath = "";
|
||||
for (const frame of frameElements) {
|
||||
|
@@ -4,7 +4,7 @@ import {
|
||||
} from "../element/types";
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "../element";
|
||||
import { AppState, InteractiveCanvasAppState } from "../types";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
|
||||
import {
|
||||
elementOverlapsWithFrame,
|
||||
getContainingFrame,
|
||||
@@ -27,7 +27,7 @@ export const excludeElementsInFramesFromSelection = <
|
||||
const framesInSelection = new Set<T["id"]>();
|
||||
|
||||
selectedElements.forEach((element) => {
|
||||
if (element.type === "frame") {
|
||||
if (isFrameLikeElement(element)) {
|
||||
framesInSelection.add(element.id);
|
||||
}
|
||||
});
|
||||
@@ -190,7 +190,7 @@ export const getSelectedElements = (
|
||||
if (opts?.includeElementsInFrames) {
|
||||
const elementsToInclude: ExcalidrawElement[] = [];
|
||||
selectedElements.forEach((element) => {
|
||||
if (element.type === "frame") {
|
||||
if (isFrameLikeElement(element)) {
|
||||
getFrameChildren(elements, element.id).forEach((e) =>
|
||||
elementsToInclude.push(e),
|
||||
);
|
||||
|
@@ -98,6 +98,7 @@ export type ElementShapes = {
|
||||
rectangle: Drawable;
|
||||
ellipse: Drawable;
|
||||
diamond: Drawable;
|
||||
iframe: Drawable;
|
||||
embeddable: Drawable;
|
||||
freedraw: Drawable | null;
|
||||
arrow: Drawable[];
|
||||
@@ -105,4 +106,5 @@ export type ElementShapes = {
|
||||
text: null;
|
||||
image: null;
|
||||
frame: null;
|
||||
magicframe: null;
|
||||
};
|
||||
|
@@ -83,14 +83,6 @@ export const SHAPES = [
|
||||
numericKey: KEYS["0"],
|
||||
fillable: false,
|
||||
},
|
||||
// TODO: frame, create icon and set up numeric key
|
||||
// {
|
||||
// icon: RectangleIcon,
|
||||
// value: "frame",
|
||||
// key: KEYS.F,
|
||||
// numericKey: KEYS.SUBTRACT,
|
||||
// fillable: false,
|
||||
// },
|
||||
] as const;
|
||||
|
||||
export const findShapeByKey = (key: string) => {
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { TOOL_TYPE } from "./constants";
|
||||
import {
|
||||
Bounds,
|
||||
getCommonBounds,
|
||||
@@ -5,7 +6,7 @@ import {
|
||||
getElementAbsoluteCoords,
|
||||
} from "./element/bounds";
|
||||
import { MaybeTransformHandleType } from "./element/transformHandles";
|
||||
import { isBoundToContainer, isFrameElement } from "./element/typeChecks";
|
||||
import { isBoundToContainer, isFrameLikeElement } from "./element/typeChecks";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
@@ -262,7 +263,7 @@ const getReferenceElements = (
|
||||
appState: AppState,
|
||||
) => {
|
||||
const selectedFrames = selectedElements
|
||||
.filter((element) => isFrameElement(element))
|
||||
.filter((element) => isFrameLikeElement(element))
|
||||
.map((frame) => frame.id);
|
||||
|
||||
return getVisibleAndNonSelectedElements(
|
||||
@@ -1352,10 +1353,11 @@ export const isActiveToolNonLinearSnappable = (
|
||||
activeToolType: AppState["activeTool"]["type"],
|
||||
) => {
|
||||
return (
|
||||
activeToolType === "rectangle" ||
|
||||
activeToolType === "ellipse" ||
|
||||
activeToolType === "diamond" ||
|
||||
activeToolType === "frame" ||
|
||||
activeToolType === "image"
|
||||
activeToolType === TOOL_TYPE.rectangle ||
|
||||
activeToolType === TOOL_TYPE.ellipse ||
|
||||
activeToolType === TOOL_TYPE.diamond ||
|
||||
activeToolType === TOOL_TYPE.frame ||
|
||||
activeToolType === TOOL_TYPE.magicframe ||
|
||||
activeToolType === TOOL_TYPE.image
|
||||
);
|
||||
};
|
||||
|
@@ -102,7 +102,7 @@ describe("Test <MermaidToExcalidraw/>", () => {
|
||||
<Excalidraw
|
||||
initialData={{
|
||||
appState: {
|
||||
openDialog: "mermaid",
|
||||
openDialog: { name: "ttd", tab: "mermaid" },
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
@@ -110,16 +110,16 @@ describe("Test <MermaidToExcalidraw/>", () => {
|
||||
});
|
||||
|
||||
it("should open mermaid popup when active tool is mermaid", async () => {
|
||||
const dialog = document.querySelector(".dialog-mermaid")!;
|
||||
const dialog = document.querySelector(".ttd-dialog")!;
|
||||
await waitFor(() => dialog.querySelector("canvas"));
|
||||
expect(dialog.outerHTML).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should close the popup and set the tool to selection when close button clicked", () => {
|
||||
const dialog = document.querySelector(".dialog-mermaid")!;
|
||||
const dialog = document.querySelector(".ttd-dialog")!;
|
||||
const closeBtn = dialog.querySelector(".Dialog__close")!;
|
||||
fireEvent.click(closeBtn);
|
||||
expect(document.querySelector(".dialog-mermaid")).toBe(null);
|
||||
expect(document.querySelector(".ttd-dialog")).toBe(null);
|
||||
expect(window.h.state.activeTool).toStrictEqual({
|
||||
customType: null,
|
||||
lastActiveTool: null,
|
||||
@@ -129,9 +129,12 @@ describe("Test <MermaidToExcalidraw/>", () => {
|
||||
});
|
||||
|
||||
it("should show error in preview when mermaid library throws error", async () => {
|
||||
const dialog = document.querySelector(".dialog-mermaid")!;
|
||||
const selector = ".dialog-mermaid-panels-text textarea";
|
||||
let editor = await getTextEditor(selector, false);
|
||||
const dialog = document.querySelector(".ttd-dialog")!;
|
||||
|
||||
expect(dialog).not.toBeNull();
|
||||
|
||||
const selector = ".ttd-dialog-input";
|
||||
let editor = await getTextEditor(selector, true);
|
||||
|
||||
expect(dialog.querySelector('[data-testid="mermaid-error"]')).toBeNull();
|
||||
|
||||
@@ -151,17 +154,8 @@ describe("Test <MermaidToExcalidraw/>", () => {
|
||||
editor = await getTextEditor(selector, false);
|
||||
|
||||
expect(editor.textContent).toBe("flowchart TD1");
|
||||
expect(dialog.querySelector('[data-testid="mermaid-error"]'))
|
||||
.toMatchInlineSnapshot(`
|
||||
<div
|
||||
class="mermaid-error"
|
||||
data-testid="mermaid-error"
|
||||
>
|
||||
Error!
|
||||
<p>
|
||||
ERROR
|
||||
</p>
|
||||
</div>
|
||||
`);
|
||||
expect(
|
||||
dialog.querySelector('[data-testid="mermaid-error"]'),
|
||||
).toMatchInlineSnapshot("null");
|
||||
});
|
||||
});
|
||||
|
@@ -1,10 +1,10 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `
|
||||
"<div class=\\"Modal Dialog dialog-mermaid\\" role=\\"dialog\\" aria-modal=\\"true\\" aria-labelledby=\\"dialog-title\\" data-prevent-outside-click=\\"true\\"><div class=\\"Modal__background\\"></div><div class=\\"Modal__content\\" style=\\"--max-width: 1200px;\\" tabindex=\\"0\\"><div class=\\"Island\\"><h2 id=\\"test-id-dialog-title\\" class=\\"Dialog__title\\"><span class=\\"Dialog__titleContent\\"><p class=\\"dialog-mermaid-title\\">Mermaid to Excalidraw</p><span class=\\"dialog-mermaid-desc\\">Currently only <a href=\\"https://mermaid.js.org/syntax/flowchart.html\\">Flowcharts</a> and <a href=\\"https://mermaid.js.org/syntax/sequenceDiagram.html\\">Sequence Diagrams</a> are supported. The other types will be rendered as image in Excalidraw.<br></span></span></h2><button class=\\"Dialog__close\\" title=\\"Close\\" aria-label=\\"Close\\"><svg aria-hidden=\\"true\\" focusable=\\"false\\" role=\\"img\\" viewBox=\\"0 0 20 20\\" class=\\"\\" fill=\\"none\\" stroke=\\"currentColor\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><g clip-path=\\"url(#a)\\" stroke=\\"currentColor\\" stroke-width=\\"1.25\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><path d=\\"M15 5 5 15M5 5l10 10\\"></path></g><defs><clipPath id=\\"a\\"><path fill=\\"#fff\\" d=\\"M0 0h20v20H0z\\"></path></clipPath></defs></svg></button><div class=\\"Dialog__content\\"><div class=\\"dialog-mermaid-body\\"><div class=\\"dialog-mermaid-panels\\"><div class=\\"dialog-mermaid-panels-text\\"><label>Mermaid Syntax</label><textarea>flowchart TD
|
||||
"<div class=\\"Modal Dialog ttd-dialog\\" role=\\"dialog\\" aria-modal=\\"true\\" aria-labelledby=\\"dialog-title\\" data-prevent-outside-click=\\"true\\"><div class=\\"Modal__background\\"></div><div class=\\"Modal__content\\" style=\\"--max-width: 1200px;\\" tabindex=\\"0\\"><div class=\\"Island\\"><button class=\\"Dialog__close\\" title=\\"Close\\" aria-label=\\"Close\\"><svg aria-hidden=\\"true\\" focusable=\\"false\\" role=\\"img\\" viewBox=\\"0 0 20 20\\" class=\\"\\" fill=\\"none\\" stroke=\\"currentColor\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><g clip-path=\\"url(#a)\\" stroke=\\"currentColor\\" stroke-width=\\"1.25\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><path d=\\"M15 5 5 15M5 5l10 10\\"></path></g><defs><clipPath id=\\"a\\"><path fill=\\"#fff\\" d=\\"M0 0h20v20H0z\\"></path></clipPath></defs></svg></button><div class=\\"Dialog__content\\"><div dir=\\"ltr\\" data-orientation=\\"horizontal\\" class=\\"ttd-dialog-tabs-root\\"><p class=\\"dialog-mermaid-title\\">Mermaid to Excalidraw</p><div data-state=\\"active\\" data-orientation=\\"horizontal\\" role=\\"tabpanel\\" aria-labelledby=\\"radix-:r0:-trigger-mermaid\\" id=\\"radix-:r0:-content-mermaid\\" tabindex=\\"0\\" class=\\"ttd-dialog-content\\" style=\\"animation-duration: 0s;\\"><div class=\\"ttd-dialog-desc\\">Currently only <a href=\\"https://mermaid.js.org/syntax/flowchart.html\\">Flowcharts</a> and <a href=\\"https://mermaid.js.org/syntax/sequenceDiagram.html\\">Sequence Diagrams</a> are supported. The other types will be rendered as image in Excalidraw.</div><div class=\\"ttd-dialog-panels\\"><div class=\\"ttd-dialog-panel\\"><div class=\\"ttd-dialog-panel__header\\"><label>Mermaid Syntax</label></div><textarea class=\\"ttd-dialog-input\\" placeholder=\\"Write Mermaid diagram defintion here...\\">flowchart TD
|
||||
A[Christmas] -->|Get money| B(Go shopping)
|
||||
B --> C{Let me think}
|
||||
C -->|One| D[Laptop]
|
||||
C -->|Two| E[iPhone]
|
||||
C -->|Three| F[Car]</textarea></div><div class=\\"dialog-mermaid-panels-preview\\"><label>Preview</label><div class=\\"dialog-mermaid-panels-preview-wrapper\\"><div style=\\"opacity: 1;\\" class=\\"dialog-mermaid-panels-preview-canvas-container\\"><canvas width=\\"89\\" height=\\"158\\" dir=\\"ltr\\"></canvas></div></div></div></div><div class=\\"dialog-mermaid-buttons\\"><button type=\\"button\\" class=\\"excalidraw-button dialog-mermaid-insert\\">Insert<span><svg aria-hidden=\\"true\\" focusable=\\"false\\" role=\\"img\\" viewBox=\\"0 0 20 20\\" class=\\"\\" fill=\\"none\\" stroke=\\"currentColor\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><g stroke-width=\\"1.25\\"><path d=\\"M4.16602 10H15.8327\\"></path><path d=\\"M12.5 13.3333L15.8333 10\\"></path><path d=\\"M12.5 6.66666L15.8333 9.99999\\"></path></g></svg></span></button></div></div></div></div></div></div>"
|
||||
C -->|Three| F[Car]</textarea><div class=\\"ttd-dialog-panel-button-container invisible\\" style=\\"display: flex; align-items: center;\\"><button type=\\"button\\" class=\\"excalidraw-button ttd-dialog-panel-button\\"><div class=\\"\\"></div></button></div></div><div class=\\"ttd-dialog-panel\\"><div class=\\"ttd-dialog-panel__header\\"><label>Preview</label></div><div class=\\"ttd-dialog-output-wrapper\\"><div style=\\"opacity: 1;\\" class=\\"ttd-dialog-output-canvas-container\\"><canvas width=\\"89\\" height=\\"158\\" dir=\\"ltr\\"></canvas></div></div><div class=\\"ttd-dialog-panel-button-container\\" style=\\"display: flex; align-items: center;\\"><button type=\\"button\\" class=\\"excalidraw-button ttd-dialog-panel-button\\"><div class=\\"\\">Insert<span><svg aria-hidden=\\"true\\" focusable=\\"false\\" role=\\"img\\" viewBox=\\"0 0 20 20\\" class=\\"\\" fill=\\"none\\" stroke=\\"currentColor\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><g stroke-width=\\"1.25\\"><path d=\\"M4.16602 10H15.8327\\"></path><path d=\\"M12.5 13.3333L15.8333 10\\"></path><path d=\\"M12.5 6.66666L15.8333 9.99999\\"></path></g></svg></span></div></button></div></div></div></div></div></div></div></div></div>"
|
||||
`;
|
||||
|
@@ -385,7 +385,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"angle": 0,
|
||||
"backgroundColor": "red",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
@@ -420,7 +419,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"angle": 0,
|
||||
"backgroundColor": "red",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
@@ -582,7 +580,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -642,7 +639,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -784,7 +780,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -817,7 +812,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -877,7 +871,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -921,7 +914,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -951,7 +943,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -995,7 +986,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1025,7 +1015,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1167,7 +1156,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1200,7 +1188,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1260,7 +1247,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1304,7 +1290,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1334,7 +1319,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1378,7 +1362,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1408,7 +1391,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1552,7 +1534,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1612,7 +1593,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1752,7 +1732,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1812,7 +1791,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1854,7 +1832,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -1996,7 +1973,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2029,7 +2005,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2089,7 +2064,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2133,7 +2107,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2163,7 +2136,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2310,7 +2282,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
@@ -2345,7 +2316,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
@@ -2407,7 +2377,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2451,7 +2420,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2481,7 +2449,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2528,7 +2495,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
@@ -2560,7 +2526,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
@@ -2706,7 +2671,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"angle": 0,
|
||||
"backgroundColor": "#a5d8ff",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "cross-hatch",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2739,7 +2703,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"angle": 0,
|
||||
"backgroundColor": "#a5d8ff",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "cross-hatch",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2799,7 +2762,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2843,7 +2805,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2873,7 +2834,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2917,7 +2877,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2947,7 +2906,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -2991,7 +2949,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -3021,7 +2978,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"angle": 0,
|
||||
"backgroundColor": "#a5d8ff",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -3065,7 +3021,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -3095,7 +3050,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"angle": 0,
|
||||
"backgroundColor": "#a5d8ff",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "cross-hatch",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -3139,7 +3093,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -3169,7 +3122,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"angle": 0,
|
||||
"backgroundColor": "#a5d8ff",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "cross-hatch",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -3213,7 +3165,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -3243,7 +3194,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"angle": 0,
|
||||
"backgroundColor": "#a5d8ff",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "cross-hatch",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -3287,7 +3237,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -3317,7 +3266,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"angle": 0,
|
||||
"backgroundColor": "#a5d8ff",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "cross-hatch",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -3361,7 +3309,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"angle": 0,
|
||||
"backgroundColor": "#a5d8ff",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "cross-hatch",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -3391,7 +3338,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"angle": 0,
|
||||
"backgroundColor": "#a5d8ff",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "cross-hatch",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -3533,7 +3479,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -3566,7 +3511,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -3626,7 +3570,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -3670,7 +3613,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -3700,7 +3642,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -3744,7 +3685,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -3774,7 +3714,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -3916,7 +3855,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -3949,7 +3887,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -4009,7 +3946,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -4053,7 +3989,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -4083,7 +4018,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -4127,7 +4061,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -4157,7 +4090,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -4302,7 +4234,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -4335,7 +4266,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -4395,7 +4325,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -4439,7 +4368,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -4469,7 +4397,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -4516,7 +4443,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
@@ -4548,7 +4474,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
@@ -4595,7 +4520,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -4625,7 +4549,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -5043,7 +4966,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -5076,7 +4998,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -5136,7 +5057,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -5180,7 +5100,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -5210,7 +5129,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -5630,7 +5548,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
@@ -5665,7 +5582,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
@@ -5727,7 +5643,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -5771,7 +5686,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -5801,7 +5715,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -5848,7 +5761,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
@@ -5880,7 +5792,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
@@ -6927,7 +6838,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -6960,7 +6870,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
|
||||
"angle": 0,
|
||||
"backgroundColor": "red",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -6993,7 +6902,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
|
||||
"angle": 0,
|
||||
"backgroundColor": "red",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
@@ -7053,7 +6961,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] hi
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user