Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44390cb146 | ||
|
|
9da3e47877 | ||
|
|
dd8a7d41e2 | ||
|
|
fda5c6fdf7 | ||
|
|
3d1631f375 | ||
|
|
9f6edd8eaa | ||
|
|
0d60253de7 | ||
|
|
4027a5b245 | ||
|
|
49f2c88978 | ||
|
|
156b8b422b | ||
|
|
e2982a2968 |
@@ -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,
|
||||
};
|
||||
|
||||
+15
-7
@@ -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,
|
||||
|
||||
@@ -339,7 +339,7 @@ export const ShapesSwitcher = ({
|
||||
Generate
|
||||
</div>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setOpenDialog("mermaid")}
|
||||
onSelect={() => app.setOpenDialog({ name: "mermaid" })}
|
||||
icon={mermaidLogoIcon}
|
||||
data-testid="toolbar-embeddable"
|
||||
>
|
||||
@@ -349,14 +349,20 @@ export const ShapesSwitcher = ({
|
||||
{app.props.aiEnabled !== false && (
|
||||
<>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.onMagicButtonSelect()}
|
||||
onSelect={() => app.onMagicframeToolSelect()}
|
||||
icon={MagicIcon}
|
||||
data-testid="toolbar-magicframe"
|
||||
>
|
||||
{t("toolBar.magicframe")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setOpenDialog("magicSettings")}
|
||||
onSelect={() => {
|
||||
trackEvent("ai", "d2c-settings", "settings");
|
||||
app.setOpenDialog({
|
||||
name: "magicSettings",
|
||||
source: "settings",
|
||||
});
|
||||
}}
|
||||
icon={OpenAIIcon}
|
||||
data-testid="toolbar-magicSettings"
|
||||
>
|
||||
|
||||
+92
-43
@@ -1038,6 +1038,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state.activeEmbeddable?.element === el &&
|
||||
this.state.activeEmbeddable?.state === "hover";
|
||||
|
||||
// Modify the scale based on el.scale property
|
||||
const [xScale, yScale] = el.scale ?? [1, 1];
|
||||
const scaledTransform = `scale(${scale * xScale}, ${scale * yScale})`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={el.id}
|
||||
@@ -1048,14 +1052,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
transform: isVisible
|
||||
? `translate(${x - this.state.offsetLeft}px, ${
|
||||
y - this.state.offsetTop
|
||||
}px) scale(${scale})`
|
||||
}px) ${scaledTransform}`
|
||||
: "none",
|
||||
display: isVisible ? "block" : "none",
|
||||
opacity: el.opacity / 100,
|
||||
["--embeddable-radius" as string]: `${getCornerRadius(
|
||||
Math.min(el.width, el.height),
|
||||
el,
|
||||
)}px`,
|
||||
["--embeddable-radius" as string]: `${
|
||||
getCornerRadius(Math.min(el.width, el.height), el) / xScale
|
||||
}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -1077,8 +1080,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}}*/
|
||||
className="excalidraw__embeddable-container__inner"
|
||||
style={{
|
||||
width: isVisible ? `${el.width}px` : 0,
|
||||
height: isVisible ? `${el.height}px` : 0,
|
||||
width: isVisible ? `${el.width / xScale}px` : 0,
|
||||
height: isVisible ? `${el.height / yScale}px` : 0,
|
||||
transform: isVisible ? `rotate(${el.angle}rad)` : "none",
|
||||
pointerEvents: isActive
|
||||
? POINTER_EVENTS.enabled
|
||||
@@ -1093,7 +1096,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
<div
|
||||
className="excalidraw__embeddable__outer"
|
||||
style={{
|
||||
padding: `${el.strokeWidth}px`,
|
||||
padding: `${el.strokeWidth / el.scale[0]}px`,
|
||||
}}
|
||||
>
|
||||
{(isEmbeddableElement(el)
|
||||
@@ -1435,7 +1438,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
onMagicSettingsConfirm={this.onMagicSettingsConfirm}
|
||||
>
|
||||
{this.props.children}
|
||||
{this.state.openDialog === "mermaid" && (
|
||||
{this.state.openDialog?.name === "mermaid" && (
|
||||
<MermaidToExcalidraw />
|
||||
)}
|
||||
</LayerUI>
|
||||
@@ -1467,6 +1470,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
onChange={() =>
|
||||
this.onMagicFrameGenerate(
|
||||
firstSelectedElement,
|
||||
"button",
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -1697,11 +1701,15 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return text;
|
||||
}
|
||||
|
||||
private async onMagicFrameGenerate(magicFrame: ExcalidrawMagicFrameElement) {
|
||||
private async onMagicFrameGenerate(
|
||||
magicFrame: ExcalidrawMagicFrameElement,
|
||||
source: "button" | "upstream",
|
||||
) {
|
||||
if (!this.OPENAI_KEY) {
|
||||
this.setState({
|
||||
openDialog: "magicSettings",
|
||||
openDialog: { name: "magicSettings", source: "generation" },
|
||||
});
|
||||
trackEvent("ai", "d2c-generate", "missing-key");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1712,7 +1720,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}).filter((el) => !isMagicFrameElement(el));
|
||||
|
||||
if (!magicFrameChildren.length) {
|
||||
this.setState({ errorMessage: "Cannot generate from an empty frame" });
|
||||
if (source === "button") {
|
||||
this.setState({ errorMessage: "Cannot generate from an empty frame" });
|
||||
trackEvent("ai", "d2c-generate", "no-children");
|
||||
} else {
|
||||
this.setActiveTool({ type: "magicframe" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1751,6 +1764,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
const textFromFrameChildren = this.getTextFromElements(magicFrameChildren);
|
||||
|
||||
trackEvent("ai", "d2c-generate", "generating");
|
||||
|
||||
const result = await diagramToHTML({
|
||||
image: dataURL,
|
||||
apiKey: this.OPENAI_KEY,
|
||||
@@ -1759,6 +1774,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
trackEvent("ai", "d2c-generate", "generating-failed");
|
||||
console.error(result.error);
|
||||
this.updateMagicGeneration({
|
||||
frameElement,
|
||||
@@ -1770,6 +1786,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
return;
|
||||
}
|
||||
trackEvent("ai", "d2c-generate", "generating-done");
|
||||
|
||||
if (result.choices[0].message.content == null) {
|
||||
this.updateMagicGeneration({
|
||||
@@ -1813,7 +1830,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
private OPENAI_KEY_IS_PERSISTED: boolean =
|
||||
EditorLocalStorage.has(EDITOR_LS_KEYS.OAI_API_KEY) || false;
|
||||
|
||||
private onOpenAIKeyChange = (openAIKey: string, shouldPersist: boolean) => {
|
||||
private onOpenAIKeyChange = (
|
||||
openAIKey: string | null,
|
||||
shouldPersist: boolean,
|
||||
) => {
|
||||
this.OPENAI_KEY = openAIKey || null;
|
||||
if (shouldPersist) {
|
||||
const didPersist = EditorLocalStorage.set(
|
||||
@@ -1826,26 +1846,41 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
};
|
||||
|
||||
private onMagicSettingsConfirm = (apiKey: string, shouldPersist: boolean) => {
|
||||
this.onOpenAIKeyChange(apiKey, shouldPersist);
|
||||
private onMagicSettingsConfirm = (
|
||||
apiKey: string,
|
||||
shouldPersist: boolean,
|
||||
source: "tool" | "generation" | "settings",
|
||||
) => {
|
||||
this.OPENAI_KEY = apiKey || null;
|
||||
this.onOpenAIKeyChange(this.OPENAI_KEY, shouldPersist);
|
||||
|
||||
if (source === "settings") {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedElements = this.scene.getSelectedElements({
|
||||
selectedElementIds: this.state.selectedElementIds,
|
||||
});
|
||||
|
||||
if (apiKey) {
|
||||
const selectedElements = this.scene.getSelectedElements({
|
||||
selectedElementIds: this.state.selectedElementIds,
|
||||
});
|
||||
if (selectedElements.length) {
|
||||
this.onMagicButtonSelect();
|
||||
this.onMagicframeToolSelect();
|
||||
} else {
|
||||
this.setActiveTool({ type: "magicframe" });
|
||||
}
|
||||
} else {
|
||||
this.OPENAI_KEY = null;
|
||||
} else if (!isMagicFrameElement(selectedElements[0])) {
|
||||
// even if user didn't end up setting api key, let's pick the tool
|
||||
// so they can draw up a frame and move forward
|
||||
this.setActiveTool({ type: "magicframe" });
|
||||
}
|
||||
};
|
||||
|
||||
public onMagicButtonSelect = () => {
|
||||
public onMagicframeToolSelect = () => {
|
||||
if (!this.OPENAI_KEY) {
|
||||
this.setState({
|
||||
openDialog: "magicSettings",
|
||||
openDialog: { name: "magicSettings", source: "tool" },
|
||||
});
|
||||
trackEvent("ai", "d2c-tool", "missing-key");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1855,19 +1890,33 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
if (selectedElements.length === 0) {
|
||||
this.setActiveTool({ type: TOOL_TYPE.magicframe });
|
||||
trackEvent("ai", "d2c-tool", "empty-selection");
|
||||
} else {
|
||||
if (selectedElements.some((el) => isFrameLikeElement(el))) {
|
||||
const selectedMagicFrame: ExcalidrawMagicFrameElement | false =
|
||||
selectedElements.length === 1 &&
|
||||
isMagicFrameElement(selectedElements[0]) &&
|
||||
selectedElements[0];
|
||||
|
||||
// case: user selected elements containing frame-like(s) or are frame
|
||||
// members, we don't want to wrap into another magicframe
|
||||
// (unless the only selected element is a magic frame which we reuse)
|
||||
if (
|
||||
!selectedMagicFrame &&
|
||||
selectedElements.some((el) => isFrameLikeElement(el) || el.frameId)
|
||||
) {
|
||||
this.setActiveTool({ type: TOOL_TYPE.magicframe });
|
||||
return;
|
||||
}
|
||||
|
||||
let frame: ExcalidrawMagicFrameElement | null = null;
|
||||
if (
|
||||
selectedElements.length === 1 &&
|
||||
isMagicFrameElement(selectedElements[0])
|
||||
) {
|
||||
frame = selectedElements[0];
|
||||
trackEvent("ai", "d2c-tool", "existing-selection");
|
||||
|
||||
let frame: ExcalidrawMagicFrameElement;
|
||||
if (selectedMagicFrame) {
|
||||
// a single magicframe already selected -> use it
|
||||
frame = selectedMagicFrame;
|
||||
} else {
|
||||
// selected elements aren't wrapped in magic frame yet -> wrap now
|
||||
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(selectedElements);
|
||||
const padding = 50;
|
||||
|
||||
@@ -1880,19 +1929,19 @@ class App extends React.Component<AppProps, AppState> {
|
||||
opacity: 100,
|
||||
locked: false,
|
||||
});
|
||||
|
||||
this.scene.addNewElement(frame);
|
||||
|
||||
for (const child of selectedElements) {
|
||||
mutateElement(child, { frameId: frame.id });
|
||||
}
|
||||
|
||||
this.setState({
|
||||
selectedElementIds: { [frame.id]: true },
|
||||
});
|
||||
}
|
||||
|
||||
this.scene.addNewElement(frame);
|
||||
|
||||
for (const child of selectedElements) {
|
||||
mutateElement(child, { frameId: frame.id });
|
||||
}
|
||||
|
||||
this.setState({
|
||||
selectedElementIds: { [frame.id]: true },
|
||||
});
|
||||
|
||||
this.onMagicFrameGenerate(frame);
|
||||
this.onMagicFrameGenerate(frame, "upstream");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3551,7 +3600,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
if (event.key === KEYS.QUESTION_MARK) {
|
||||
this.setState({
|
||||
openDialog: "help",
|
||||
openDialog: { name: "help" },
|
||||
});
|
||||
return;
|
||||
} else if (
|
||||
@@ -3560,7 +3609,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
event[KEYS.CTRL_OR_CMD]
|
||||
) {
|
||||
event.preventDefault();
|
||||
this.setState({ openDialog: "imageExport" });
|
||||
this.setState({ openDialog: { name: "imageExport" } });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -86,7 +86,11 @@ interface LayerUIProps {
|
||||
openAIKey: string | null;
|
||||
isOpenAIKeyPersisted: boolean;
|
||||
onOpenAIAPIKeyChange: (apiKey: string, shouldPersist: boolean) => void;
|
||||
onMagicSettingsConfirm: (apiKey: string, shouldPersist: boolean) => void;
|
||||
onMagicSettingsConfirm: (
|
||||
apiKey: string,
|
||||
shouldPersist: boolean,
|
||||
source: "tool" | "generation" | "settings",
|
||||
) => void;
|
||||
}
|
||||
|
||||
const DefaultMainMenu: React.FC<{
|
||||
@@ -177,7 +181,7 @@ const LayerUI = ({
|
||||
const renderImageExportDialog = () => {
|
||||
if (
|
||||
!UIOptions.canvasActions.saveAsImage ||
|
||||
appState.openDialog !== "imageExport"
|
||||
appState.openDialog?.name !== "imageExport"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -448,21 +452,26 @@ const LayerUI = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{appState.openDialog === "help" && (
|
||||
{appState.openDialog?.name === "help" && (
|
||||
<HelpDialog
|
||||
onClose={() => {
|
||||
setAppState({ openDialog: null });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{appState.openDialog === "magicSettings" && (
|
||||
{appState.openDialog?.name === "magicSettings" && (
|
||||
<MagicSettings
|
||||
openAIKey={openAIKey}
|
||||
isPersisted={isOpenAIKeyPersisted}
|
||||
onChange={onOpenAIAPIKeyChange}
|
||||
onConfirm={(apiKey, shouldPersist) => {
|
||||
setAppState({ openDialog: null });
|
||||
onMagicSettingsConfirm(apiKey, shouldPersist);
|
||||
const source =
|
||||
appState.openDialog?.name === "magicSettings"
|
||||
? appState.openDialog?.source
|
||||
: "settings";
|
||||
setAppState({ openDialog: null }, () => {
|
||||
onMagicSettingsConfirm(apiKey, shouldPersist, source);
|
||||
});
|
||||
}}
|
||||
onClose={() => {
|
||||
setAppState({ openDialog: null });
|
||||
|
||||
@@ -2,28 +2,14 @@ import { useState } from "react";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { TextField } from "./TextField";
|
||||
import { MagicIcon, OpenAIIcon } from "./icons";
|
||||
|
||||
import "./MagicSettings.scss";
|
||||
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";
|
||||
|
||||
const InlineButton = ({ 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>
|
||||
);
|
||||
};
|
||||
import "./MagicSettings.scss";
|
||||
|
||||
export const MagicSettings = (props: {
|
||||
openAIKey: string | null;
|
||||
@@ -70,18 +56,17 @@ export const MagicSettings = (props: {
|
||||
className="MagicSettings"
|
||||
autofocus={false}
|
||||
>
|
||||
<p
|
||||
<Paragraph
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
For the diagram-to-code feature we use{" "}
|
||||
<InlineButton icon={OpenAIIcon} />
|
||||
For the diagram-to-code feature we use <InlineIcon icon={OpenAIIcon} />
|
||||
OpenAI.
|
||||
</p>
|
||||
<p>
|
||||
</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
|
||||
@@ -100,13 +85,13 @@ export const MagicSettings = (props: {
|
||||
generate your own API key
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
</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.
|
||||
</p>
|
||||
</Paragraph>
|
||||
<TextField
|
||||
isPassword
|
||||
isRedacted
|
||||
value={keyInputValue}
|
||||
placeholder="Paste your API key here"
|
||||
label="OpenAI API key"
|
||||
@@ -117,22 +102,22 @@ export const MagicSettings = (props: {
|
||||
selectOnRender
|
||||
onKeyDown={(event) => event.key === KEYS.ENTER && onConfirm()}
|
||||
/>
|
||||
<p>
|
||||
<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.
|
||||
</p>
|
||||
</Paragraph>
|
||||
|
||||
<CheckboxItem checked={shouldPersist} onChange={setShouldPersist}>
|
||||
Persist API key in browser storage
|
||||
</CheckboxItem>
|
||||
|
||||
<p>
|
||||
Once API key is set, you can use the <InlineButton icon={MagicIcon} />{" "}
|
||||
<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>{" "}
|
||||
<InlineButton icon={OpenAIIcon} />.
|
||||
</p>
|
||||
<InlineIcon icon={OpenAIIcon} />.
|
||||
</Paragraph>
|
||||
|
||||
<FilledButton
|
||||
className="MagicSettings__confirm"
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -25,7 +25,7 @@ type TextFieldProps = {
|
||||
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
isPassword?: boolean;
|
||||
isRedacted?: boolean;
|
||||
};
|
||||
|
||||
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
@@ -39,7 +39,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
readonly,
|
||||
selectOnRender,
|
||||
onKeyDown,
|
||||
isPassword = false,
|
||||
isRedacted = false,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -53,7 +53,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
}
|
||||
}, [selectOnRender]);
|
||||
|
||||
const [isVisible, setIsVisible] = useState<boolean>(true);
|
||||
const [isTemporarilyUnredacted, setIsTemporarilyUnredacted] =
|
||||
useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -71,7 +72,9 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
})}
|
||||
>
|
||||
<input
|
||||
type={isPassword && isVisible ? "password" : undefined}
|
||||
className={clsx({
|
||||
"is-redacted": value && isRedacted && !isTemporarilyUnredacted,
|
||||
})}
|
||||
readOnly={readonly}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
@@ -79,12 +82,14 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{isPassword && (
|
||||
{isRedacted && (
|
||||
<Button
|
||||
onSelect={() => setIsVisible(!isVisible)}
|
||||
style={{ border: 0 }}
|
||||
onSelect={() =>
|
||||
setIsTemporarilyUnredacted(!isTemporarilyUnredacted)
|
||||
}
|
||||
style={{ border: 0, userSelect: "none" }}
|
||||
>
|
||||
{isVisible ? eyeIcon : eyeClosedIcon}
|
||||
{isTemporarilyUnredacted ? eyeClosedIcon : eyeIcon}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -533,6 +533,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);
|
||||
@@ -728,4 +734,8 @@
|
||||
letter-spacing: 0.6px;
|
||||
font-family: "Assistant";
|
||||
}
|
||||
|
||||
.excalidraw__paragraph {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -294,11 +294,15 @@ const restoreElement = (
|
||||
case "ellipse":
|
||||
case "rectangle":
|
||||
case "diamond":
|
||||
case "iframe":
|
||||
return restoreElementWithProperties(element, {});
|
||||
case "iframe":
|
||||
return restoreElementWithProperties(element, {
|
||||
scale: element.scale ?? [1, 1],
|
||||
});
|
||||
case "embeddable":
|
||||
return restoreElementWithProperties(element, {
|
||||
validated: null,
|
||||
scale: element.scale ?? [1, 1],
|
||||
});
|
||||
case "magicframe":
|
||||
case "frame":
|
||||
|
||||
+12
-4
@@ -22,6 +22,7 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { Mutable } from "../utility-types";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import Scene from "../scene/Scene";
|
||||
|
||||
export type RectangleBox = {
|
||||
x: number;
|
||||
@@ -59,10 +60,17 @@ export class ElementBounds {
|
||||
|
||||
const bounds = ElementBounds.calculateBounds(element);
|
||||
|
||||
ElementBounds.boundsCache.set(element, {
|
||||
version: element.version,
|
||||
bounds,
|
||||
});
|
||||
// hack to ensure that downstream checks could retrieve element Scene
|
||||
// so as to have correctly calculated bounds
|
||||
// FIXME remove when we get rid of all the id:Scene / element:Scene mapping
|
||||
const shouldCache = Scene.getScene(element);
|
||||
|
||||
if (shouldCache) {
|
||||
ElementBounds.boundsCache.set(element, {
|
||||
version: element.version,
|
||||
bounds,
|
||||
});
|
||||
}
|
||||
|
||||
return bounds;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
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]*$/;
|
||||
/^(?: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]+)(?:\?.*)?$/;
|
||||
|
||||
@@ -142,6 +142,7 @@ export const newEmbeddableElement = (
|
||||
return {
|
||||
..._newElementBase<ExcalidrawEmbeddableElement>("embeddable", opts),
|
||||
validated: opts.validated,
|
||||
scale: [1, 1],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -152,6 +153,7 @@ export const newIframeElement = (
|
||||
): NonDeleted<ExcalidrawIframeElement> => {
|
||||
return {
|
||||
..._newElementBase<ExcalidrawIframeElement>("iframe", opts),
|
||||
scale: [1, 1],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isIframeLikeElement,
|
||||
isFrameLikeElement,
|
||||
isFreeDrawElement,
|
||||
isImageElement,
|
||||
@@ -586,15 +587,31 @@ export const resizeSingleElement = (
|
||||
};
|
||||
|
||||
if ("scale" in element && "scale" in stateAtResizeStart) {
|
||||
mutateElement(element, {
|
||||
scale: [
|
||||
// defaulting because scaleX/Y can be 0/-0
|
||||
(Math.sign(newBoundsX2 - stateAtResizeStart.x) ||
|
||||
stateAtResizeStart.scale[0]) * stateAtResizeStart.scale[0],
|
||||
(Math.sign(newBoundsY2 - stateAtResizeStart.y) ||
|
||||
stateAtResizeStart.scale[1]) * stateAtResizeStart.scale[1],
|
||||
],
|
||||
});
|
||||
if (isIframeLikeElement(element)) {
|
||||
if (shouldMaintainAspectRatio) {
|
||||
const scale: [number, number] = [
|
||||
Math.abs(
|
||||
eleNewWidth /
|
||||
(stateAtResizeStart.width / stateAtResizeStart.scale[0]),
|
||||
),
|
||||
Math.abs(
|
||||
eleNewHeight /
|
||||
(stateAtResizeStart.height / stateAtResizeStart.scale[1]),
|
||||
),
|
||||
];
|
||||
mutateElement(element, { scale });
|
||||
}
|
||||
} else {
|
||||
mutateElement(element, {
|
||||
scale: [
|
||||
// defaulting because scaleX/Y can be 0/-0
|
||||
(Math.sign(newBoundsX2 - stateAtResizeStart.x) ||
|
||||
stateAtResizeStart.scale[0]) * stateAtResizeStart.scale[0],
|
||||
(Math.sign(newBoundsY2 - stateAtResizeStart.y) ||
|
||||
stateAtResizeStart.scale[1]) * stateAtResizeStart.scale[1],
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -96,6 +96,7 @@ export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
|
||||
* may not have access to host-app supplied url validator during restore.
|
||||
*/
|
||||
validated: boolean | null;
|
||||
scale: [number, number];
|
||||
}>;
|
||||
|
||||
export type ExcalidrawIframeElement = _ExcalidrawElementBase &
|
||||
@@ -103,6 +104,7 @@ export type ExcalidrawIframeElement = _ExcalidrawElementBase &
|
||||
type: "iframe";
|
||||
// TODO move later to AI-specific frame
|
||||
customData?: { generationData?: MagicCacheData };
|
||||
scale: [number, number];
|
||||
}>;
|
||||
|
||||
export type ExcalidrawIframeLikeElement =
|
||||
|
||||
@@ -11,6 +11,12 @@ The change should be grouped under one of the below section and must contain PR
|
||||
Please add the latest change on the top under the correct section.
|
||||
-->
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336)
|
||||
|
||||
## 0.17.0 (2023-11-14)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
isInitializedImageElement,
|
||||
isArrowElement,
|
||||
hasBoundTextElement,
|
||||
isIframeLikeElement,
|
||||
isMagicFrameElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getElementAbsoluteCoords } from "../element/bounds";
|
||||
@@ -520,7 +521,8 @@ const drawElementFromCanvas = (
|
||||
|
||||
if (
|
||||
"scale" in elementWithCanvas.element &&
|
||||
!isPendingImageElement(element, renderConfig)
|
||||
!isPendingImageElement(element, renderConfig) &&
|
||||
!isIframeLikeElement(element)
|
||||
) {
|
||||
context.scale(
|
||||
elementWithCanvas.element.scale[0],
|
||||
@@ -996,6 +998,8 @@ export const renderElementToSvg = (
|
||||
renderConfig,
|
||||
);
|
||||
|
||||
const scaleX = element.scale?.[0] || 1;
|
||||
const scaleY = element.scale?.[1] || 1;
|
||||
// render embeddable element + iframe
|
||||
const embeddableNode = roughSVGDrawWithPrecision(
|
||||
rsvg,
|
||||
@@ -1007,7 +1011,7 @@ export const renderElementToSvg = (
|
||||
"transform",
|
||||
`translate(${offsetX || 0} ${
|
||||
offsetY || 0
|
||||
}) rotate(${degree} ${cx} ${cy})`,
|
||||
}) rotate(${degree} ${cx} ${cy}) scale(${scaleX}, ${scaleY})`,
|
||||
);
|
||||
while (embeddableNode.firstChild) {
|
||||
embeddableNode.removeChild(embeddableNode.firstChild);
|
||||
@@ -1038,8 +1042,8 @@ export const renderElementToSvg = (
|
||||
SVG_NS,
|
||||
"foreignObject",
|
||||
);
|
||||
foreignObject.style.width = `${element.width}px`;
|
||||
foreignObject.style.height = `${element.height}px`;
|
||||
foreignObject.style.width = `${element.width / scaleX}px`;
|
||||
foreignObject.style.height = `${element.height / scaleY}px`;
|
||||
foreignObject.style.border = "none";
|
||||
const div = foreignObject.ownerDocument!.createElementNS(SVG_NS, "div");
|
||||
div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
|
||||
|
||||
@@ -102,7 +102,7 @@ describe("Test <MermaidToExcalidraw/>", () => {
|
||||
<Excalidraw
|
||||
initialData={{
|
||||
appState: {
|
||||
openDialog: "mermaid",
|
||||
openDialog: { name: "mermaid" },
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
|
||||
Vendored
+1
@@ -35,6 +35,7 @@ export const embeddableFixture: ExcalidrawElement = {
|
||||
...elementBase,
|
||||
type: "embeddable",
|
||||
validated: null,
|
||||
scale: [1, 1],
|
||||
};
|
||||
export const ellipseFixture: ExcalidrawElement = {
|
||||
...elementBase,
|
||||
|
||||
+10
-7
@@ -245,12 +245,15 @@ export interface AppState {
|
||||
openPopup: "canvasBackground" | "elementBackground" | "elementStroke" | null;
|
||||
openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
|
||||
openDialog:
|
||||
| "imageExport"
|
||||
| "help"
|
||||
| "jsonExport"
|
||||
| "mermaid"
|
||||
| "magicSettings"
|
||||
| null;
|
||||
| null
|
||||
| { name: "imageExport" | "help" | "jsonExport" | "mermaid" }
|
||||
| {
|
||||
name: "magicSettings";
|
||||
source:
|
||||
| "tool" // when magicframe tool is selected
|
||||
| "generation" // when magicframe generate button is clicked
|
||||
| "settings"; // when AI settings dialog is explicitly invoked
|
||||
};
|
||||
/**
|
||||
* Reflects user preference for whether the default sidebar should be docked.
|
||||
*
|
||||
@@ -549,7 +552,7 @@ export type AppClassProperties = {
|
||||
setActiveTool: App["setActiveTool"];
|
||||
setOpenDialog: App["setOpenDialog"];
|
||||
insertEmbeddableElement: App["insertEmbeddableElement"];
|
||||
onMagicButtonSelect: App["onMagicButtonSelect"];
|
||||
onMagicframeToolSelect: App["onMagicframeToolSelect"];
|
||||
};
|
||||
|
||||
export type PointerDownState = Readonly<{
|
||||
|
||||
Reference in New Issue
Block a user